Hmbown/CodeWhale · error

connection stdin poisoned by an earlier panic

Error message

connection stdin poisoned by an earlier panic

What it means

The MCP stdio client wraps the child server's stdin in an `Arc<Mutex<ChildStdin>>`; the reader thread locks this mutex to write JSON-RPC responses to server-initiated requests (e.g. pings). Rust mutexes are poisoned when a holder panics, so `lock()` returns Err and this error is raised: some earlier code path panicked while holding the stdin lock, and the client refuses to guess whether the child's stdin is still coherent. The failure is reported back as an Invalid message on the response channel and the reader loop stops.

Solutions

  1. Find the original panic: it happened while the stdin mutex was held — check logs/backtraces around `write_jsonrpc_line` and request sends; fix that panic (e.g. handle broken-pipe write errors instead of unwrapping).
  2. Restart the MCP server connection (re-spawn via `spawn_with_timeouts`); a poisoned mutex is not recoverable in-process for that child.
  3. Check the server binary for crashes mid-protocol: a child dying while stdin is locked often precipitates the panicking write path.
  4. If you own the client code, switch lock sites to `lock().unwrap_or_else(|p| p.into_inner())` only if you can prove the stdin state is still valid, or use a poisoning-free lock (parking_lot) deliberately.
  5. Verify the JSON-RPC payload passed to `write_jsonrpc_line` is serializable and that no panic hook aborts mid-write.

Example fix

// before: panic propagates and poisons the stdin mutex
stdin.lock().unwrap().write_all(&payload).unwrap();
// after: propagate the error instead of panicking while holding the lock
let mut guard = stdin.lock().map_err(|_| anyhow!("stdin mutex poisoned"))?;
guard.write_all(&payload).context("write jsonrpc request")?;
Defensive patterns

Strategy: try-catch

Validate before calling

// No pre-call check can detect poisoning directly; verify the child is alive
// and the connection was spawned cleanly before use.
assert!(child_alive(&connection), "MCP server child already exited; respawn before sending requests");

Type guard

fn connection_healthy(conn: &Connection) -> bool {
    conn.stdin.as_ref().map_or(false, |s| !s.is_poisoned())
}

Try / catch

match connection.request(&server, method, params) {
    Err(e) if e.to_string().contains("stdin poisoned") => {
        eprintln!("MCP stdin poisoned; re-spawning server '{server}'");
        let connection = spawn_with_timeouts(server, ...)?; // recovery = respawn
    }
    Err(e) => eprintln!("request failed: {e:#}"),
    Ok(v) => { /* use response */ }
}

Prevention

When it happens

Trigger: Calling `spawn_with_timeouts` (or any subsequent request over the spawned stdio connection) while a server-initiated request arrives and `stdin.lock()` fails because a previous lock holder panicked — e.g. a panic inside `write_jsonrpc_line` or the code that held the lock during a client request write.

Common situations: A panic while writing a request to a misbehaving/crashing MCP server (broken pipe handling that unwraps); a bug in the write path (serde serialization panic); concurrency between the request writer and the reader thread's response writer triggering a panic path; running a server binary that closes stdin abruptly.

Understand the failure class

Background: "This is a bug, please report it": internal invariant violations, unreachable panics, and SNH errors explained — this error's family across 47 libraries.

Related errors


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

Appendix: source

Thrown at crates/mcp/src/stdio_client.rs:596

                        // client request is in flight. Route them before the
                        // zero-capacity response channel: blocking there until
                        // the next client call would violate MCP ping's prompt
                        // response requirement. Valid notifications need no
                        // response and likewise must not occupy the rendezvous.
                        if let Ok(message) = serde_json::from_str::<Value>(&line)
                            && message.get("jsonrpc").and_then(Value::as_str) == Some("2.0")
                            && message.get("method").and_then(Value::as_str).is_some()
                        {
                            let Some(response) = response_to_server_request(&message) else {
                                continue;
                            };
                            let Some(stdin) = response_stdin.upgrade() else {
                                break;
                            };
                            let result = stdin
                                .lock()
                                .map_err(|_| {
                                    anyhow!("connection stdin poisoned by an earlier panic")
                                })
                                .and_then(|mut stdin| write_jsonrpc_line(&mut *stdin, &response));
                            // Never retain the temporary strong handle while a
                            // failure waits on the rendezvous channel. Drop
                            // must remain able to close child stdin promptly.
                            drop(stdin);
                            if let Err(error) = result {
                                let _ = sender.send(ChildStdoutMessage::Invalid(format!(
                                    "MCP server '{response_server_name}': failed to answer idle child request: {error:#}"
                                )));
                                break;
                            }
                            continue;
                        }
                        if sender.send(ChildStdoutMessage::Line(line)).is_err() {
                            break;
                        }
                    }

View on GitHub (pinned to 73e0f67d83)