fix(mcp): shut down gracefully on stdin EOF instead of orphaning to PID 1 (#751) - #767
Merged
Merged
Conversation
The SDK's StdioServerTransport subscribes to stdin 'data'/'error' only and never notices 'end'/'close'. When the parent MCP client dies, no teardown runs: whenever anything still references the event loop (a warm llama.cpp model's native handles being the common case), the server reparents to PID 1, leaks RAM, and keeps the SQLite index open. Treat stdin EOF as a client disconnect: close the server/transport, give in-flight request handlers a bounded window to settle (a new InflightGate counts running tool/resource handlers so their store/llm dependencies are not torn down underneath them), release the llama.cpp resources, close the store, and set process.exitCode instead of calling process.exit() so beforeExit still fires and node-llama-cpp's auto-dispose runs before libc's static destructors - forced exits during native-addon unload have caused exit-time crashes before (tobi#59, and mirrors the HTTP transport's idempotent stop() including its stderr breadcrumb. The shutdown is a shared promise that never rejects: 'end', 'close', an already-ended stdin at registration time, and manual invocations all collapse into one run. Every step including logging is failure-tolerant (the parent's death may have closed stderr too), and a successful shutdown preserves an earlier nonzero exit code instead of masking it. Related: modelcontextprotocol/typescript-sdk#2003 fixes the same gap at the transport layer; this stays correct alongside it because the teardown is idempotent, and qmd needs its own store/llm cleanup either way. Tests: unit tests drive registerStdioEofShutdown via injected stdin (ordering incl. in-flight drain, idempotency, failure tolerance, exit-code preservation, throwing stderr, already-ended stdin) plus createInflightGate unit tests; an end-to-end test spawns the real server, completes an initialize round-trip, closes stdin, and asserts a prompt code-0 exit through the EOF shutdown path (stderr breadcrumb).
tobi
commented
Aug 12, 2026
Owner
Superseded by #836 after rebase onto main. |
tobiforce-pushed
the
fix/mcp-stdio-eof-shutdown
branch
from
August 12, 2026 23:58
0ad15ec to
d519d09Comparetobi
commented
Aug 12, 2026
Owner
Superseded by #836 after rebase onto main. |
tobi
commented
Aug 12, 2026
Owner
Superseded by #837 (relanded onto current main; CHANGELOG conflict resolved). Closing in favor of the reland. |
3 tasks
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 freeto 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#751.
The SDK's
StdioServerTransportsubscribes to stdindata/erroronly and never noticesend/close. When the parent MCP client dies, no teardown runs: whenever anything still references the event loop (a warm llama.cpp model's native handles being the common case),qmd mcpreparents to PID 1, leaks RAM, and keeps the SQLite index open. Multiple such orphans can pile up across client sessions.What changed
startMcpServer()now registersregisterStdioEofShutdown()afterserver.connect(). On stdin EOF it treats the client as disconnected and tears down gracefully:server.close()— stop the transport, no further requests are dispatchedInflightGatecounts running tool/resource handlers; shutdown waits (bounded, 5 s default) for them to settle so their store/llm dependencies are not torn down underneath themstore.close()— last, and it disposes this store's ownLlamaCppinstance before closing the database. (The shutdown helper takes an optionaldisposeLlmstep for callers that own a separate instance, but the MCP path deliberately does not pass it:store.close()already owns the disposal, and defaulting to the globaldisposeDefaultLlamaCppwould risk tearing down an unrelated instance in an embedded process.)process.exitCode = 0(or 1 if a step failed; an earlier nonzero code is preserved, not masked) and let the loop drainDesign decisions, mirroring existing house patterns:
process.exit(). Settingprocess.exitCodekeepsbeforeExitalive so node-llama-cpp's auto-dispose runs before libc's static destructors — forced exits during native-addon unload have crashed before (SIGABRT crash on exit during Metal cleanup (macOS ARM64) #59, Bun segfaults on Windows after vsearch/query due to process.exit() during native addon unload #129). Same rationale asfinishSuccessfulCliCommandon the CLI side.server.close()runs before the drain, the transport stops dispatching new requests first, so the gate only ever waits on already-dispatched work (its count is not a closed admission barrier, and does not need to be under this ordering).stop()):end,close, an already-ended stdin at registration time, and manual invocations all collapse into one run; listeners are removed on entry.QMD Warning: … continuing shutdown.) and does not stop the remaining steps.Shutting down (stdin closed)..., matching the HTTP transport's SIGTERM/SIGINT messages, and giving tests an observable signal that the EOF path (not a coincidental event-loop drain) produced the exit. All logging is best-effort: the parent's death may have closed stderr too, so writes can never take the teardown down with them.Related: modelcontextprotocol/typescript-sdk#2003 (open, not yet merged) proposes fixing the same gap at the transport layer. This change stays correct alongside it once it lands (the teardown is idempotent), and qmd needs its own store/llm cleanup either way.
Note: the changelog entry lands in the same
[Unreleased] › Fixedblock as #766 (the atomic-cleanup PR), so a trivial changelog conflict is expected if both merge — happy to rebase whichever lands second.Verification
test/mcp-stdio-lifecycle.test.ts:registerStdioEofShutdownvia injected stdin (mirroring the DI style of thefinishSuccessfulCliCommandtests): teardown order incl. the in-flight drain,end/close/manual idempotency, failure tolerance incl. all-steps-failing and a throwing stderr, drain-deadline miss, exit-code preservation, already-ended/destroyed stdin at registrationcreateInflightGateunit tests: immediate idle, settle-wait, missed deadline, rejecting handler releases the gateinitializeround-trip, closes stdin, and asserts a prompt code-0 exit through the EOF shutdown path (stderr breadcrumb; without it a modelless server can exit 0 by coincidence)bun run test:typesclean,bun run buildpasses, suites green under vitest via Node andbun test[Unreleased]