Hmbown/CodeWhale · error

Stdio transport closed

Error message

Stdio transport closed{exit}

What it means

Variant of the stdio transport closed error when no stderr context could be captured: the child's stdout hit EOF and it exited, but its stderr tail was empty or unavailable. Only the exit status (if retrievable) is reported.

Solutions

  1. Check the exit status in the message for a signal or nonzero code.
  2. Run the MCP server command manually with stderr visible to see its output.
  3. Verify the configured command/args and environment.
  4. If the child was killed, check ulimits, cgroup limits, or supervisor policies.
Defensive patterns

Strategy: try-catch

Try / catch

Err(e) if e.to_string().starts_with("Stdio transport closed") && !e.to_string().contains('\n') => {
    // no stderr captured: reproduce manually with stderr visible
    return Err(anyhow!("MCP server exited silently: {e}; run the command manually to see stderr"));
}

Prevention

When it happens

Trigger: Same as the stderr-annotated variant — EOF on child stdout with child exited — but format_stderr_context returned None (empty stderr tail).

Common situations: Child exits silently (e.g. killed by signal before writing anything); server binary writes nothing to stderr; stderr pipe not captured for that spawn mode.

Understand the failure class

Background: ECONNREFUSED and "connection refused" / "could not connect to server" errors: what they mean and how to fix them — this error's family across 44 libraries.

Related errors


AI-assisted analysis of Hmbown/CodeWhale@73e0f67d83 (2026-09-22). Data as JSON: /api/errors/6c732e21f2d6cedd. Report an issue: GitHub.

Appendix: source

Thrown at crates/tui/src/mcp/stdio.rs:356

                Err(err) => {
                    if let Some(stderr) = format_stderr_context(&self.stderr_tail).await {
                        anyhow::bail!("Stdio transport read error: {err}\n{stderr}");
                    }
                    return Err(err.into());
                }
            };
            if bytes == 0 {
                // Let the stderr drain task catch up before snapshotting, and
                // name the exit status: a reviewed plugin's stderr is never
                // retained, so the status is the only reason the operator
                // gets when the child dies before the handshake (#5916).
                tokio::task::yield_now().await;
                let exit = self.child.lock().await.try_wait().ok().flatten();
                let exit = exit.map_or_else(String::new, |status| format!(" ({status})"));
                if let Some(stderr) = format_stderr_context(&self.stderr_tail).await {
                    anyhow::bail!("Stdio transport closed{exit}\n{stderr}");
                }
                anyhow::bail!("Stdio transport closed{exit}");
            }

            let line_bytes = std::mem::take(&mut self.pending_line);
            let line = String::from_utf8_lossy(&line_bytes);
            let trimmed = line.trim();
            if trimmed.is_empty() {
                continue;
            }

            return Ok(trimmed.as_bytes().to_vec());
        }
    }

    /// Send SIGTERM and wait up to `STDIO_SHUTDOWN_GRACE` for graceful exit,
    /// then force termination and reap the child as the backstop.
    async fn shutdown(&mut self) {
        let mut child = self.child.lock().await;
        terminate_child(&mut child).await;

View on GitHub (pinned to 73e0f67d83)