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/Exited → data/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.
Found through the MCP debug logs that #9491 unlocked (#9500 / #9531): with the log tree finally written, cc's stdio
mcpcases 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/truefixtures):Mechanism (MCP SDK 1.30.0,
client/stdio.js)start()resolves its promise from the child's'spawn'listener; the'close'listener setsthis._process = undefined;send()throwsError('Not connected')whenthis._process?.stdinis gone (stdio.js:189). Under node the continuation that awaitedstart()runs before the child'sdata/exit/close— the initialize request is written, the child is gone, and the pending request rejects withConnection closedwhen'close'lands later. Under perry the child's whole lifecycle is delivered before that continuation resumes, so_processis already cleared whensend()runs.Minimal repro (no SDK)
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/Exited→data/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/getfamily once #9499 (the self-server hang) is in: the same ordering rule reaches every consumer that awaits'spawn'before writing (execa'sspawnedpromise, 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.