Skip to content

fix: exit on stdin EOF so a dead client cannot orphan the server - #83

Open
lzrong0203 wants to merge 1 commit into
PaSympa:mainfrom
lzrong0203:fix/exit-on-stdin-eof
Open

lzrong0203 wants to merge 1 commit into
PaSympa:mainfrom
lzrong0203:fix/exit-on-stdin-eof

Conversation

@lzrong0203

Copy link
Copy Markdown
Contributor

Problem

When the MCP client that spawned discord-mcp exits, the server process can keep running indefinitely: StdioServerTransport subscribes only to stdin's data/error events, so EOF is never surfaced — and once any active handle exists (in practice the Discord gateway WebSocket, once a tool call has logged in), the event loop never drains.

Field report: I found 12 orphaned discord-mcp processes on one machine, accumulated over ~3 weeks, each spawned by an MCP client that had long since exited, holding 0.8–2.1 GB RSS between them. (Those long-lived orphans also showed sustained high CPU and did not respond to SIGTERM. I could not reproduce those two symptoms in a controlled setting, so this PR claims to fix only the lingering-process defect — the CPU/RSS/SIGTERM observations are reported for context, not explained by this patch.)

Repro

DISCORD_TOKEN=placeholder node --import ./test/fixtures/keepalive.mjs --import tsx src/index.ts < /dev/null

< /dev/null delivers immediate stdin EOF; the preload stands in for the gateway handle. On master the process stays alive until killed; with this patch it exits 0 immediately.

Fix

src/index.ts: listen for stdin end/close and shut down cleanly. shutdown() is guarded since EOF delivers end followed by close.

Tests

test/lifecycle.test.ts boots the real entry point with the keepalive preload (a referenced timer — login is lazy, so no real gateway opens in tests, and a timer needs no network permissions):

  • exits cleanly when stdin reaches EOF, even with an active handle open — fails on master (child still alive at deadline), passes with the fix; asserts code === 0 and no signal kill
  • exits cleanly on SIGTERM — guards the existing handler (passes on master too)

Full suite (56 tests), tsc, eslint, and prettier --check all pass.

Note on the SDK

Arguably the root defect lives in @modelcontextprotocol/typescript-sdk: the Transport contract documents onclose as firing when the connection closes "for any reason", yet StdioServerTransport never surfaces stdin EOF (checked against current main). This entry-point guard is worth having regardless of an eventual SDK fix; happy to file the SDK issue separately if useful.

StdioServerTransport only subscribes to stdin's 'data'/'error' events, so
EOF (the client exiting) is never surfaced, and the Discord gateway
WebSocket keeps the event loop alive: the server outlives its client
indefinitely. Listen for stdin 'end'/'close' at the entry point and shut
down cleanly; shutdown() is guarded so the 'end'+'close' pair (or a
concurrent signal) cannot double-run it.
@PaSympa

PaSympa commented Jul 21, 2026 •

Copy link
Copy Markdown
Owner

Thanks for the careful work on this. I checked everything myself: the orphan reproduces on main, and your regression test really does catch it.

Your SDK finding is right too. StdioServerTransport only listens for stdin data and error. Please open that upstream issue and link it here.

I did hit one problem with the hard process.exit on EOF. Details in the review below.

@PaSympa PaSympa left a comment •

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great PR. Real bug, honest scope, and a test that actually catches it. Two things before merging.

1. The hard exit drops responses when input is piped.

Pipe initialize, initialized, tools/list, tools/call, then EOF, with stdout on a pipe:

bytes valid responses
main 118,745 3
this branch 65,685 1

The tools/call reply is never written, and tools/list is cut mid-JSON at the 64 KB pipe buffer. Both exit 0, so nothing warns the caller.

One direction: on EOF stop reading, close the transport, call discord.destroy(), and let the event loop finish pending work. Keep a short unref'd timer as a hard-exit backstop so your orphan fix still holds.

2. The tests cannot run on Windows.

  • --import with a raw absolute path throws ERR_UNSUPPORTED_ESM_URL_SCHEME. Use pathToFileURL(KEEPALIVE).href.
  • The SIGTERM test cannot pass: kill is TerminateProcess emulation, so the handler never runs. Needs { skip: process.platform === "win32" }.

The runtime fix itself is fine cross-platform.

Nit, not blocking: startServer() discards stderr and has no 'exit' listener, so a boot crash just looks like a 5 second hang.

Happy to merge once 1 and 2 are in. Thanks again.

@PaSympa PaSympa added bug Something isn't working needs-changes Changes requested; awaiting an updated revision labels Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working needs-changes Changes requested; awaiting an updated revision

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants