Skip to content

child_process: a child's data/exit/close are delivered BEFORE the continuation that awaited its 'spawn' — cc's MCP stdio client says 'Not connected' where node says 'Connection closed' #9535

Description

@proggeramlug

Found through the MCP debug logs that #9491 unlocked (#9500 / #9531): with the log tree finally written, cc's stdio mcp cases still differ from node in what they log, and this is the reason for ~9 of the 15 harness cases (every stdio server that exits at once — the /bin/echo, /bin/true fixtures):

node : Connection failed after 1ms: MCP error -32000: Connection closed
perry: Connection failed after 1ms: Not connected

Mechanism (MCP SDK 1.30.0, client/stdio.js)

start() resolves its promise from the child's 'spawn' listener; the 'close' listener sets this._process = undefined; send() throws Error('Not connected') when this._process?.stdin is gone (stdio.js:189). Under node the continuation that awaited start() runs before the child's data/exit/close — the initialize request is written, the child is gone, and the pending request rejects with Connection closed when 'close' lands later. Under perry the child's whole lifecycle is delivered before that continuation resumes, so _process is already cleared when send() runs.

Minimal repro (no SDK)

import { spawn } from "node:child_process";
const order: string[] = [];
const c = spawn("/bin/echo", ["hello"]);
c.on("spawn", () => order.push("spawn"));
c.on("exit", () => order.push("exit"));
c.on("close", () => order.push("close"));
c.stdout!.on("data", () => order.push("data"));
await new Promise<void>((r) => c.on("spawn", () => r()));
order.push("resumed-after-spawn");
await Promise.resolve();
order.push("microtask");
await new Promise<void>((r) => setImmediate(r));
order.push("immediate");
setTimeout(() => console.log(order.join(" ")), 300);
node 26.5.1 (linux): spawn resumed-after-spawn microtask data exit immediate close
perry main 0b24670dd9 : spawn data exit close resumed-after-spawn microtask immediate

Node runs a microtask checkpoint after every emitted callback, so a promise resolved from 'spawn' resumes before the loop delivers the next event. Perry's reactor pump drains the child's queued events (Data/Eof/Exiteddata/end/exit/close) in one go without a microtask checkpoint between emits — crates/perry-runtime/src/child_process/reactor.rs, the main-thread pump. A child that finishes inside one turn therefore has its entire lifecycle emitted before any promise continuation that one of those events resolved.

What matters

This is the remaining divergence for the stdio mcp add/list/get family once #9499 (the self-server hang) is in: the same ordering rule reaches every consumer that awaits 'spawn' before writing (execa's spawned promise, cross-spawn users, node-pty wrappers). Expected fix shape: run the microtask checkpoint after each emitted child-process event (or at minimum after 'spawn'), the way the timer and fs callback paths already do; the fixture above should print node's line.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions