Skip to content

fix(mcp): shut down gracefully on stdin EOF instead of orphaning to PID 1 (#751) - #837

Merged
tobi merged 1 commit into
mainfrom
reland/767-mcp-stdio-eof
Aug 12, 2026
Merged

fix(mcp): shut down gracefully on stdin EOF instead of orphaning to PID 1 (#751)#837
tobi merged 1 commit into
mainfrom
reland/767-mcp-stdio-eof

Conversation

@tobi

@tobi tobi commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Summary

Relands #767 onto current main (CHANGELOG conflict only). Fixes #751.

qmd mcp (stdio) now shuts down gracefully when stdin reaches EOF instead of orphaning to PID 1 when the parent MCP client dies: close transport, bounded in-flight drain, close store (disposes llama.cpp), set process.exitCode (no forced process.exit()).

Test plan

  • CI=true node ./node_modules/vitest/vitest.mjs run test/mcp-stdio-lifecycle.test.ts — 17/17 pass
  • CI=true bun test --preload ./src/test-preload.ts test/mcp-stdio-lifecycle.test.ts — 17/17 pass

Supersedes #767.

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 (#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
tobi merged commit 97ad6ea into main Aug 12, 2026
9 of 11 checks passed
@tobi
tobi deleted the reland/767-mcp-stdio-eof branch August 12, 2026 23:58
@PowderAddicts

PowderAddicts commented Aug 12, 2026 via email

Copy link
Copy Markdown

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

qmd mcp (stdio) doesn't exit on stdin EOF — orphans to PID 1 and leaks RAM after parent dies

3 participants