Hmbown/CodeWhale · error

Stdio transport read error

Error message

Stdio transport read error: {err}\n{stderr}

What it means

The stdio MCP transport failed while reading from the child process's stdout. When a captured stderr tail is available it is appended to the error so the operator sees the child's own diagnostic output alongside the I/O error.

Solutions

  1. Read the embedded stderr tail — it usually contains the child's panic or error message.
  2. Verify the MCP server command/args in config point to a working binary (run it manually).
  3. Check the child isn't being killed by OOM or a resource limit.
  4. Update or reinstall the MCP server package if the binary is corrupted.
Defensive patterns

Strategy: try-catch

Validate before calling

// before spawning, ensure the command resolves
which::which(&cfg.command).map_err(|_| format!("command not found: {}", cfg.command))?;

Try / catch

match transport.recv().await {
    Err(e) if e.to_string().starts_with("Stdio transport read error") => {
        // stderr tail is embedded — surface it to the operator, then restart the child
        log::error!("{e}");
        transport = StdioTransport::spawn(&cfg).await?;
    }
    other => other?,
}

Prevention

When it happens

Trigger: recv() awaiting the next length-prefixed message from the child's stdout hits an I/O error — child crashed mid-write, pipe broken, descriptor closed, or the process was killed.

Common situations: MCP server binary panics or OOM-killed mid-response; child writes invalid data then dies; OS pipe buffer/EOF issues; spawning with a bad command leaves no reader.

Related errors


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

Appendix: source

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

            Err(_) => false,
        }
    }

    async fn recv(&mut self) -> Result<Vec<u8>> {
        loop {
            // Bounded read: a server emitting a newline-free multi-GB "line"
            // must not OOM us (read_line is unbounded).
            let bytes = match read_line_capped(
                &mut self.reader,
                &mut self.pending_line,
                MAX_MCP_RESPONSE_BYTES,
            )
            .await
            {
                Ok(b) => b,
                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}");
            }

View on GitHub (pinned to 73e0f67d83)