fix: exit cleanly on stdin EOF in StdioServerTransport#182
Open
geremyturcotte wants to merge 1 commit intoQuantGeekDev:mainfrom
Open
fix: exit cleanly on stdin EOF in StdioServerTransport#182geremyturcotte wants to merge 1 commit intoQuantGeekDev:mainfrom
geremyturcotte wants to merge 1 commit intoQuantGeekDev:mainfrom
Conversation
Without explicit listeners on process.stdin's "end" / "close" events, the underlying SDK readline poll spins on null reads after the parent process disconnects. The server is reparented to init at ~99% CPU until killed manually. Observed daily on macOS Darwin 25.x when the parent (Claude CLI or Claude Desktop) is hard-killed. Install both listeners in start() and remove them symmetrically in close(). On either event, best-effort close the SDK transport then process.exit(0). The stdio transport has no other input source, so exiting is correct. Adds 3 regression tests in tests/transports/stdio/server.test.ts: - "close" event triggers process.exit(0) - "end" event triggers process.exit(0) - listeners removed on close() (re-instantiation leak-free) Fixes QuantGeekDev#181.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes #181.
StdioServerTransport.start()now installsend/closelisteners onprocess.stdinso the server exits cleanly when its parent disconnects, instead of busy-looping at ~99% CPU as aPPID=1zombie until killed manually.The bug is observed daily on macOS Darwin 25.x whenever the parent (Claude CLI / Claude Desktop) is hard-killed. Source-code analysis is in the linked issue. Quick recap:
StdioServerTransportdelegates everything to@modelcontextprotocol/sdk'sStdioServerTransport, which uses readline under the hood — when the parent dies on Darwin, the EOF doesn't propagate to a clean stream-end event, the readline poll spins on null reads, and nothing in either layer callsprocess.exit(). This PR adds the missing exit guard at the consumer layer where the lifecycle is owned.Changes
src/transports/stdio/server.ts(+17 LOC) — installend/closelisteners onprocess.stdininstart(), remove them inclose()(symmetric, leak-free), callprocess.exit(0)after best-efforttransport.close()when either fires.tests/transports/stdio/server.test.ts(+65 LOC, new file) — 3 tests:closeevent →process.exit(0)called.endevent →process.exit(0)called.close()(re-instantiation leak-free).Why this layer (not the SDK)
The SDK can't safely call
process.exit()because some consumers might want to intercept stdin EOF and do graceful shutdown. But a stdio MCP server has no other input source — once stdin is gone, it has nothing to do. The right place for the exit guard is inmcp-framework's wrapper, where the process lifecycle is owned. (Cross-filing atmodelcontextprotocol/typescript-sdkis also possible but seems unnecessary ifmcp-frameworkhandles it.)Test plan
Real-world repro verified: with the patch installed, killing the parent process of a default
mcp create test-serverno longer leaves aPPID=1zombie. Without the patch, the same operation leaves a process spinning at 99% CPU indefinitely.Open questions / alternatives
Happy to redo in a different shape if you'd prefer:
exitOnStdinClose: boolean(defaulttrue) on the constructor for consumers who want different shutdown semantics. Adds one constructor arg and one boolean check; minor surface increase.onclosechain — instead ofprocess.exit(0), fire the existingonclosehandler if set, then exit. Slightly more invasive; lets consumers run cleanup before exit.MCPServer.start()— install the guard at the higher level so non-stdio transports don't pay for it (they don't anyway, since they don't read stdin, but it's slightly more explicit).Default chosen: minimal patch at the transport layer, hard
process.exit(0)after best-effort SDK cleanup. Happy to iterate.Workaround we're using until this lands
We ship a launchd watchdog (~50 LOC reaper script) on dev machines that detects the orphan signature (
PPID=1+ MCP allowlist match +%CPU > 50+etime < 10 min) andSIGKILLs on a 120s probe. Effective at zero false positives but obviously a band-aid. Shipping this PR upstream lets us delete that mitigation.Thanks for maintaining
mcp-framework!