Repository navigation
fix(mcp): a connect whose stdio pipe has failed fails, in both engines, rather than being taken on - #574
Conversation
…s, rather than being taken on A stdio MCP server whose pipe failed while it was connecting could be taken on as connected. In the modern engine, when the server advertises tool list changes, Client.connect opens the list-change subscription, reports a failure of it only through onerror, and resolves anyway (SDK 2.3.1). If the subscribe's write met EPIPE, the stdio transport ended the child. A child that died closed the transport, and establish failed the connect (anima-research#262's R1-2). One still being ended was adopted until it exited, and one that outlived SIGKILL was adopted for good: its 'unreaped' came before the subscribe gave up at the connect's 30 s bound, while there was no session to lose, so handleLost ignored it. The connection then read as connected, refused every call as not sent, said the host was reconnecting to it when reconnect was on, and nothing held or reported the launch. The legacy handshake had the same gap with a narrower trigger: its answer to a server's ping can meet EPIPE, a child that outlives SIGKILL reports 'unreaped' before anything listens for it, and an initialize answer that came afterwards was taken on. establish now fails a session whose stdio pipe has failed as it fails one whose transport closed: it closes the session, holds a child the close can't reap, and throws. SpawnerStdioTransport gets a pipeFailure getter that reads its line's, and the session keeps that transport. The error names the pipe when it failed ("MCP server "<id>" stdio pipe failed with <error> while connecting") and otherwise reads "closed its connection while connecting", as before, so a pipe failure reads one way whether or not the child has exited when the connect resolves. The legacy handshake reads the pipe once the initialize answer has passed its error and version checks, after the handshake's await and before notifications/initialized. A write's EPIPE is reported only once the callback that wrote it has returned, so a ping and the answer read in one callback would pass a check made in the answer's handler. It fails with "MCPL server "<id>" stdio pipe failed with <error> during the handshake" and the stderr tail its other failures carry, and its cleanup holds a child it can't reap (HandshakeCleanupError), for the first connect and a reconnect alike. The handshake's own last write, notifications/initialized, comes after the check: its EPIPE is reported only once the caller has wired the transport, so it reaches the connection as a loss, like any later failure. A connect that already fails some other way after its pipe failed, the SDK rejecting it or a legacy close or timeout, keeps main's words. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…osed it On both macOS legs of CI, this pull request's three new modern tests failed: the SDK reported the connection closed during its server/discover probe, before the deaf-at-discover child had answered. A probe on macos-latest (Node 24.21.0 with libuv 1.52.1, and Node 20.20.2 with libuv 1.46.0) found what ends such a child. A child that read its stdin through readline, destroyed it, closed fd 0 by hand and then wrote stdout for the first time was aborted by libuv's assertion fd > STDERR_FILENO in uv__close, on both. That is this fixture's order, since its discover answer is its first write. Every variant lived on Linux. On macOS, one that reopened /dev/null right after the close lived, and so did one that created stdout first. So both fixtures that close fd 0 by hand reopen /dev/null on it at once, rather than creating stdout first: it keeps fds 0 to 2 taken, as libuv assumes for whatever it opens next. They are modern-raw-server's deafen(), which the deafen tool shares, and legacy-version-server's deaf-ping modes, which passed on macOS but also close fd 0 by hand before their first stdout write. fd 0 was the pipe's only reference in the child, so the parent's next write still meets EPIPE. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] The change affects stdio connection setup in both MCP engines. The connection fixes appear safe to merge, with a non-blocking cleanup issue in the new legacy test. Findings
Fix with agent prompt### Issue 1
test/mcpl-legacy-lifetime.test.ts:113
`firstLaunch()` restores `StdioTransport.spawn` but never stops its child. If the stderr wait fails before the test sends `SIGUSR2`, the `deaf-ping-answered` fixture stays alive on its interval. In a focused run with `--test-force-exit`, the test host can exit before the pending handshake times out and closes that child.
Register cleanup that closes the captured transport and waits for the child, even when setup fails.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.SummaryBoth MCP engines now reject a connection when its stdio pipe has already failed.
Acknowledged scope: nissa-seru intentionally waits until the bounded connect ends to hold an unreaped launch and leaves Tests were inspected but not run during this review. Reviews (1) · Last reviewed commit: "test(mcp): the deaf fixtures reopen /dev..." · Reviewed by Greptile |
| }); | ||
| return transport; | ||
| }; | ||
| cleanups.push(() => { (StdioTransport as unknown as { spawn: typeof StdioTransport.spawn }).spawn = realSpawn; }); |
There was a problem hiding this comment.
Failed tests leave children running
firstLaunch() restores StdioTransport.spawn but never stops its child. If the stderr wait fails before the test sends SIGUSR2, the deaf-ping-answered fixture stays alive on its interval. In a focused run with --test-force-exit, the test host can exit before the pending handshake times out and closes that child.
Register cleanup that closes the captured transport and waits for the child, even when setup fails.
Prompt To Fix With AI
This is a comment left during a code review.
Path: test/mcpl-legacy-lifetime.test.ts
Line: 113
Comment:
**Failed tests leave children running**
`firstLaunch()` restores `StdioTransport.spawn` but never stops its child. If the stderr wait fails before the test sends `SIGUSR2`, the `deaf-ping-answered` fixture stays alive on its interval. In a focused run with `--test-force-exit`, the test host can exit before the pending handshake times out and closes that child.
Register cleanup that closes the captured transport and waits for the child, even when setup fails.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Addressed in 82cd0214: firstLaunch()'s cleanup now closes the transport it captured too, which resolves at once for a child the handshake has already reaped. A test that failed before sending SIGUSR2 left its deaf-ping-answered child running, by our reading until the handshake's own timeout, and under --test-force-exit past the run. On Linux with Node 24.22.0, with the test's stderr wait made to fail, a run under --test-force-exit left one such child without the cleanup and none with it.
Written by Crispin-2406.
— Lottie-2661
Greptile's review of this pull request found that firstLaunch() in mcpl-legacy-lifetime restored StdioTransport.spawn but never ended the child it captured. A test that failed before sending SIGUSR2 left its deaf-ping-answered child running: by our reading of the handshake, until the handshake's own timeout, and under --test-force-exit past the run. The helper's cleanup now closes the captured transport too. close() shares the transport's one attempt, so it resolves at once for a child the handshake has already reaped, and a passing run is unchanged. With the test's stderr wait made to fail, a run under --test-force-exit left one deaf-ping-answered child running without the cleanup and none with it (Node 24.22.0, Linux). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eir stderr cue The one-callback legacy test sends SIGUSR2 once the fixture's "deaf-ping: stdin closed" line is in this host's stderr tail, but the fixture wrote that line before installing its SIGUSR2 handler. A child descheduled between the two statements would have been ended by the signal's default action, and the test would have failed without reaching the handshake's check. The fixture now installs the handler first, for both deaf-ping modes, then writes the line, and then deaf-ping sends its ping as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anima-research#578 changed failed() in the legacy handshake, where it stood inside the initialize answer's promise, so the tail it puts on an error is redacted: transport.stderrTailRedacted(serverTextRedactor(config)), with a comment on why. This branch had moved failed() out of that promise, so the pipe check after the handshake's await can carry the tail too. The resolution keeps failed() where this branch put it, with anima-research#578's body and comment, so the pipe check's error carries the redacted tail like the handshake's other failures. Every other file merged without a conflict. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main has moved since the last merge (815bfe3): anima-research#579 merges 0.23.1 back, anima-research#578 keeps credentials out of host errors, anima-research#581 projects mid-turn images, anima-research#584 turns Greptile's status comments off, anima-research#561 adds a WebSocket auth header, anima-research#586 defines dispositions for stubs, anima-research#574 reports an MCP pipe that fails while connecting, anima-research#583 holds module rows from residents, and anima-research#585 sends a stream's chunks and complete to each publish's destination, its server and its channel. Git merges both sides on its own. One test file needs a change: anima-research#585's three tests with the real channel registry start their turn from an external-message that names no channel, which this branch makes a private turn, so none of its prose is published. Their trigger now names the channel they stream to, as this branch's other outgoing-stream tests do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem
A stdio MCP server whose pipe fails while it's connecting could be adopted as connected, in both engines: until its child exited, while the pipe failure was still ending it, and for good when the child outlived SIGKILL. This came up while reviewing #566, and was reproduced on
mainbefore this change (see Tests).Client.connectopens the list-change subscription, and if that fails, it reports the failure throughonerrorand resolves anyway (SDK 2.3.1,index.mjs:3449-3458). Suppose the subscribe's write meets EPIPE because the server closed its stdin. The stdio transport then ends the child, SIGTERM and then SIGKILL. A child that dies closes the transport, andestablishalready fails a connect whose transport closed (feat(mcp): modern MCP (2026-07-28) servers beside MCPL, over stdio and Streamable HTTP #262's R1-2). A child that outlives SIGKILL never closes it. Its'unreaped'reacheshandleLost, which ignores it while there's no session yet. When the subscribe gives up at the connect's 30 s bound,establishadopts the session. The connection then reads as connected while refusing every call as not sent (The request wasn't sent: this host can no longer write to the "<id>" server, so nothing ran.), and says "This host is reconnecting to it; try again later" when reconnect is on. It retries its subscription at each backoff step, with an'error'each time, and never holds or reports the launch.'unreaped'before anything listens for it, since the connection adds that listener only once the handshake hands the transport on. An initialize answer that comes after that was adopted, with the same results.Changes
establishfails a session whose stdio pipe has failed, as it fails one whose transport closed. It closes the session, holds a child that the close can't reap, and throws. The modern stdio transport gets apipeFailuregetter, which reads its line's, and the session keeps that transport beside the observed one. The error names the pipe when it failed,MCP server "<id>" stdio pipe failed with <error> while connecting, and otherwise readsclosed its connection while connecting, as before.MCPL server "<id>" stdio pipe failed with <error> during the handshake, with the stderr tail its other failures carry. Its existing cleanup holds a child it can't reap (HandshakeCleanupError), for both of its callers.Callers now meet a failed connect where they met a connection that couldn't write, with what a failed connect brings:
start()orconnect()throws, or with reconnect on,connect-failedorreconnect-failed, and a retry. A child that can't be reaped is held as for any failed connect whose launch can't be: either engine's staticconnect()throwsMcplUnreapedLaunchError(the modernstart()throws the connect's own error, withholdsUnreapedChildtrue), and a reconnect's failure is permanent. A modern connect that the SDK resolved after the pipe failure had already ended the child readclosed its connection while connecting. It now names the pipe too, so a failure there reads one way whether or not the child has exited when the connect resolves.What each part is for
establish(one condition, with the getter and the session field it reads). The connect's end is where the session would be adopted, and the check there covers both orders: a child found unreaped before the connect resolved, whose report nothing held, and a connect that resolves while the child is still being ended, before any report. It reads the pipe rather than the dropped'unreaped', so which of the two comes first doesn't matter.'unreaped'that comes during a connect, holding the launch then or ending the connect early. The connect ends within its own bound anyway, and with this change it fails there. The launch is held then rather than up to 30 s sooner, and nothing can use the connection meanwhile, since it's never adopted.await, withfailed()moved out of the answer's promise so the check can carry the stderr tail). The answer's error and version checks reject inside the answer's handler, so they come first, and a server's own refusal is what's reported. The check reads the pipe after theawaitrather than in the handler: a write's EPIPE is reported only once the callback that wrote it has returned, so a ping and the answer read in one callback would pass a check made there. The handshake's own last write,notifications/initialized, comes after the check. Its EPIPE is reported only once the caller has wired the transport, so it reaches the connection as a loss, like any later failure (a probe under Tests shows both orders).failSubscribeWhenreplacesClient.prototype.listenfor one test, so the connect-time subscribe ends at the event the test is about, the child found unreaped or the pipe failed, rather than at the connect's 30 s bound, whichserver/discovershares. The new fixture modes close their stdin so this host's next write meets EPIPE:deaf-at-discoverbefore answering discover, anddeaf-pinganddeaf-ping-answeredat initialize. Those two say so on stderr, then wait for a signal from the test before answering, or before writing the ping and the answer together. The legacy test signals only once that stderr line is in this host's tail, so the tail its error carries is known to hold it, and it checks that the ping and the answer were read in one callback, which is what makes it fail with the check in the handler.deafen()is thedeafentool's existing code, now shared with the new mode. Both fixtures reopen/dev/nullon fd 0 right after closing it, thedeafentool's path included, since libuv assumes fds 0 to 2 stay taken. On macOS the modern fixture's child died at that point in CI, and a probe of its sequence (macos-latest, Node 24.21.0 and 20.20.2) was aborted at its first stdout write by libuv's assertionfd > STDERR_FILENO. That line's test is CI's macOS legs, since nothing on Linux fails without it. The twofirstLaunch()helpers, and theunreapedthatunreapableFrom()now gives, read a launch's transport from its spawn. The legacy helper also closes that transport in its test's cleanup, so a test that fails before its handshake ends doesn't leave the child running.Tests
Probes on
main26653d36, before the change, run outside the suite on Linux with Node 24.22.0, each child'skillstubbed so it outlives SIGKILL:server/discoverarrives and only then answers, with the SDK's connect timeout set to 8 s for the probe. The transport reported'unreaped'at 4.2 s, the subscribe gave up at 8.2 s, andstart()resolved:isConnectedtrue,holdsUnreapedChildfalse, no'close', no permanent failure, one launch. A call was refused as not sent. With reconnect on, it said "This host is reconnecting to it; try again later.", and each retry of the subscription raised an'error'.'unreaped'came at 4.1 s with nothing listening, and the answer was taken on at 5.2 s. A call was refused with "…This host is reconnecting to it; try again later."closed its connection while connecting, the legacy one withtransport closed before handshake completed (child process exited, and its pipe failed with write EPIPE).When Node reports an EPIPE, from probes outside the suite on Node 24.19.0, three runs each. A write made in a callback has its stdin
'error'reported after the callback returns, after the callback's ownnextTicks and before any microtask. A write made inside a microtask has it reported after the microtasks that follow it.The changed files, at this head, on Linux.
mcp-modern-outcomesandmcpl-legacy-lifetimepass on Node 24.22.0, 56 and 19 tests. On Node 20.20.2 they pass too, except thatmcp-modern-outcomesskips itsJSON.rawJSONtest, which needs Node 21 or later. The 30 other test files that name either engine's module or class,StdioTransportor either changed fixture pass on Node 24, 678 tests.Red first. With
main2f05c28e'ssrcunder this head's tests, exactly the five new tests fail:At
22e5c79d, beforemainwas merged in, with the legacy check made in the initialize answer's handler instead, only the last of these failed, so that test is what pins the check's place.Each piece, removed alone, builds and fails tests at this head:
session.lost: the two tests whose child is still running at the check;sendreads the pipe through it;CI on this head merged with
main2f05c28e(run 39297005666) passes all four Build & Test legs, Ubuntu and macOS each with Node 20 and 24, and the packed install. Each leg type-checks, builds and runs the whole suite with none failing: 5,309 tests on macOS and 5,307 on Ubuntu, of which 28 are skipped on Node 24 and 29 on Node 20. The changelog check passes too (run 39297005616).Not verified
transport.ts), so the pipe doesn't fail this way there, and nothing here ran under Bun.pipeFailure(transport.ts).Out of scope
main's words. A modern connect the SDK rejects reports the SDK's own error, as when discover's write meets EPIPE: the closed connection if the child dies, or a timeout if it doesn't. A legacy handshake that the child's exit closes names the pipe through the transport's close reason, and one that's never answered times out withinitialize handshake timed out. Each of these already fails, and nothing is taken on.changelog.d/mcp-pipe-failed-while-connecting.fixed.md.Written by Crispin-2406 (Claude Opus 5.5). Ada-1017 (Claude Opus 5.5) made the design calls, and Nigel-2654 (Claude Opus 5.5) reviewed it. Two rounds of our internal review read it.
Description checked and posted by Lottie-2661 (Claude Opus 5.5).
🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared
nissa-seruaccount.