Skip to content

fix(server): exit stdio server when the client closes stdin - #61

Open
majkelooo wants to merge 2 commits into
codefuturist:mainfrom
majkelooo:fix/stdio-exit-on-stdin-close
Open

fix(server): exit stdio server when the client closes stdin#61
majkelooo wants to merge 2 commits into
codefuturist:mainfrom
majkelooo:fix/stdio-exit-on-stdin-close

Conversation

@majkelooo

@majkelooo majkelooo commented Aug 6, 2026

Copy link
Copy Markdown

Description

Fixes #60 — the stdio server never exited when the client closed its stdin, so every client death that skipped SIGTERM (closed terminal, SIGKILL, crash, MCP reconnect) left an immortal process reparented to launchd/init. I found 232 of them on one workstation after 7 days of uptime, holding 5.2 GB RSS and pushing the machine into 13.3 GB of swap.

The spec is explicit about whose job this is. From basic/lifecycle, Shutdown:

For the stdio transport, the client SHOULD initiate shutdown by:

  1. First, closing the input stream to the child process (the server)
  2. Waiting for the server to exit, or sending SIGTERM if the server does not exit within a reasonable time
  3. Sending SIGKILL if the server does not exit within a reasonable time after SIGTERM

"Waiting for the server to exit" is the part this package could not honour. Three things had to coincide, and all three did:

  1. StdioServerTransport subscribes to 'data' and 'error' only, so EOF on stdin raises no event and transport.onclose never fires in stdio mode.
  2. Nothing in runServer listened for it either — transport.onclose was wired on the HTTP path only.
  3. The scheduler tick, the hooks rate-limit timer and IMAP IDLE sockets are all ref'd handles, so the event loop never drained by itself.

This PR shuts down on stdin 'end'/'close' and on transport.onclose, adds SIGHUP beside the existing signals, and makes shutdown idempotent because those triggers routinely arrive together. A grace timer forces the exit if a goodbye round trip hangs, so a stuck QUIT/LOGOUT cannot resurrect the orphan.

It also guards the post-handshake block, at both of its await boundaries. That block sits behind two awaits, so a client disconnecting mid-startup could arm the scheduler tick after shutdown had already run — a ref'd handle nothing clears. The new test caught this; without the guard it fails with exit code 1 from the grace timer instead of hanging. It is pre-existing (a SIGTERM during startup did the same) but only becomes observable once the server can exit at all.

A second review pass found the same shape one await earlier, at watcherService.start(): a watcher finishing its start after shutdown has run holds IMAP IDLE sockets the completed stop() never saw, so the loop stays pinned and the grace timer forces exit 1 rather than a clean stop. With the watcher enabled that is not an edge case — reconnect storms hit precisely that window. stop() is idempotent, so the guard re-runs it and bails.

Reproduction

Minimal, no account needed — this isolates the mechanism:

// a.mjs — the listener shape StdioServerTransport installs
process.stdin.on('data', () => {}); process.stdin.on('error', () => {});
// b.mjs — same, plus the scheduler interval
process.stdin.on('data', () => {}); process.stdin.on('error', () => {}); setInterval(() => {}, 60_000);

printf 'x\n' | node a.mjs exits on its own; printf 'x\n' | node b.mjs never does.

For a control: chrome-devtools-mcp runs through the same npx launcher and the same SDK on the same machine and left zero orphans over those 7 days. The launcher and the SDK are not the differentiator; the event-loop-pinning handle is.

Decisions worth a second opinion

  • Detection rather than .unref(). Unref'ing the scheduler interval alone fixes nothing, because hooks.service.ts (rateResetTimer) and IMAP IDLE sockets still pin the loop. Unref'ing all of them would touch three services and still leave the loop pinned by any handle added later. shutdown() already stops every one of them, so the missing piece was only ever the trigger. Happy to add .unref() as belt-and-braces if you prefer it.
  • process.exit in the grace timer needs an n/no-process-exit disable. process.exitCode is the house style, but it only takes effect once the loop drains — and a hung shutdown is exactly the case where it will not. The reasoning is in a comment at the call site.
  • No 'data' listener on stdin, deliberately: it would switch the stream to flowing mode and consume protocol bytes out from under the SDK. 'end'/'close' leave the mode untouched.
  • transport.onclose is redundant today and wired anyway, so this becomes the idiomatic path if the SDK ever emits it. Worth knowing: Bug: StdioServerTransport doesn't handle stdin close/end — causes zombie process accumulation modelcontextprotocol/typescript-sdk#2002 reports the SDK-side gap and has had three competing PRs open since May with none merged. Fixing the SDK alone would not have fixed this package anyway, given point 2 above.

Test

src/main.lifecycle.test.ts spawns the server as a real child process — the defect is the event loop failing to drain, which is unobservable in-process. It completes the handshake, closes stdin, and asserts a clean exit. Everything is confined to a temp dir via XDG_*, so it cannot see a developer's real config or touch a real scheduled-mail queue, and afterEach kills the child — a test about leaked processes should not leak one.

Verified red before / green after by stashing the src/main.ts change:

without the fix: × exits when the client closes stdin
                 Caused by: Error: process still running 20000ms after stdin closed — orphan regression
with the fix:    ✓ exits when the client closes stdin  4211ms

Type of change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Refactor (no functional changes)

Checklist

  • My code follows the project's code style (Biome formatter + ESLint linter)
  • I have run pnpm check and it passes
  • I have run pnpm typecheck and it passes
  • I have added tests that prove my fix/feature works — pnpm test: 151 passed / 16 files
  • I have updated documentation — no user-facing behaviour change to document; say the word if you want a CHANGELOG entry
  • My changes generate no new warnings

One behavioural consequence worth stating explicitly: a server spawned with stdio: 'ignore' gets /dev/null as stdin and therefore an immediate EOF, so it now exits at once. For a stdio transport that seems correct — such a server has no client and can never receive a request — but it is a change from today's silent hang.

The MCP stdio lifecycle has the client shut the server down by closing our
stdin, but nothing observed that. StdioServerTransport subscribes only to
'data' and 'error', so EOF raised no event, and the scheduler tick, the hooks
rate-limit timer and IMAP IDLE sockets kept the event loop from draining. Any
client death that skipped SIGTERM — closed terminal, SIGKILL, crash, MCP
reconnect — left an immortal process reparented to launchd/init. 232 such
orphans accumulated on one workstation in 7 days, holding 5.2 GB RSS and
pushing the machine into 13.3 GB of swap.

Shut down on stdin 'end'/'close' and on transport.onclose, add SIGHUP beside
the existing signals, and make shutdown idempotent because those triggers
routinely fire together. A grace timer forces the exit if a goodbye round trip
hangs, so a stuck QUIT/LOGOUT cannot resurrect the orphan.

Also guard the post-handshake block: it sits behind two awaits, so a client
disconnecting mid-startup could arm the scheduler tick after shutdown had
already run, leaving a ref'd handle nothing clears.

The regression test spawns a real child process, since the defect is the event
loop failing to drain and that is unobservable in-process.

Refs codefuturist#60

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@majkelooo
majkelooo requested a review from codefuturist as a code owner August 6, 2026 07:10
Self-review of the previous commit surfaced a second await boundary. A watcher
that finishes starting after shutdown has already run holds IMAP IDLE sockets
the completed stop() never saw, so the loop stayed pinned and the grace timer
forced exit 1 instead of a clean stop. With the watcher enabled that is not an
edge case: reconnect storms hit exactly that window. stop() is idempotent, so
the guard re-runs it and bails.

Also replace the paraphrase of the shutdown contract with the specification's
own wording, which is stronger than the paraphrase implied — the client SHOULD
initiate shutdown by "first, closing the input stream to the child process (the
server)", then by "waiting for the server to exit" before escalating to SIGTERM.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

stdio server never exits when the client closes stdin — orphaned processes accumulate (232 in 7 days, 5.2 GB RSS)

2 participants