Skip to content

fix(stdio): add stdin close/end listeners to prevent zombie/orphan processes - #265

Merged
punkpeye merged 20 commits into
punkpeye:mainfrom
ElliotDrel:fix/stdin-close-zombie-process
Jul 23, 2026
Merged

fix(stdio): add stdin close/end listeners to prevent zombie/orphan processes#265
punkpeye merged 20 commits into
punkpeye:mainfrom
ElliotDrel:fix/stdin-close-zombie-process

Conversation

@ElliotDrel

Copy link
Copy Markdown
Contributor

Summary

Fixes #264.

When an MCP client closes its end of the stdin pipe (restart, crash, or normal shutdown), StdioServerTransport never fires a close event because the underlying @modelcontextprotocol/sdk only registers data and error listeners on process.stdin — not close or end. The FastMCP server process therefore lingers indefinitely as a zombie/orphan.

This PR adds belt-and-suspenders process.stdin listeners immediately after session.connect(transport) for the stdio transport path. When either close or end fires, transport.close() is called and the process exits cleanly.

What changed

src/FastMCP.ts — inside the if (config.transportType === "stdio") block, after await session.connect(transport):

const onStdinClose = () => { transport.close().catch(() => {}); };
process.stdin.on("close", onStdinClose);
process.stdin.on("end", onStdinClose);

Why here and not just rely on the SDK fix?

References

Test plan

  • Start a FastMCP stdio server as a child process
  • Destroy the child's stdin (child.stdin.destroy()) to simulate a client disconnect
  • Confirm the server process exits (previously it would hang)
  • Confirm existing tests still pass (npm test)

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR addresses stdio-mode process leaks by ensuring FastMCP reacts when the MCP client closes its end of the stdin pipe, triggering transport shutdown so the server can terminate instead of lingering.

Changes:

  • Add process.stdin close and end listeners after session.connect(transport) in the stdio transport path.
  • On stdin shutdown, call transport.close() to trigger session/transport teardown.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/FastMCP.ts Outdated
Comment thread src/FastMCP.ts
Comment thread src/FastMCP.ts
@ElliotDrel

Copy link
Copy Markdown
Contributor Author

Hi @punkpeye — just checking in on this. The PR has been CI-green and merge-ready since May 3 (15 days). It's a minimal 3-line fix that adds close and end listeners to stdin alongside the existing data/error listeners, preventing zombie processes when an MCP client exits. All review threads resolved. Happy to address any questions!

ElliotDrel and others added 19 commits May 18, 2026 10:26
When an MCP client (e.g. Claude Code) closes its end of the stdin pipe,
StdioServerTransport does not detect the disconnect and the server
process lingers indefinitely as a zombie/orphan.

This adds belt-and-suspenders `process.stdin` close/end listeners
immediately after `session.connect(transport)` for the stdio transport
path. When either event fires, `transport.close()` is called so the
process exits cleanly.

The upstream SDK is tracking the same root cause in
modelcontextprotocol/typescript-sdk#2003. Adding the fix here means
FastMCP users are protected regardless of which SDK version they have
installed.
…o test

- Add stdinClosed guard to prevent double-close race when both close
  and end fire for the same shutdown
- Remove stdin listeners inside the handler (on first fire) and also in
  transport.onclose to prevent listener accumulation across start() calls
- Add src/FastMCP.stdio.test.ts with vitest tests verifying registration,
  single-fire idempotency, and listener removal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
vi.mock() is statically hoisted by Vitest, so factory closures cannot
reference test-body locals. Move fakeTransport to module scope and
assign it in beforeEach so the mock factory captures the right value.
session.connect() retries getClientCapabilities() up to 10x100ms before
resolving. Without fake timers, the test's single Promise.resolve() tick
was not enough for the stdin listeners to be registered.
…port() works

Arrow functions cannot be used as constructors. Switching to a regular
function in the vi.mock factory lets `new StdioServerTransport()` in
FastMCP.start() return fakeTransport correctly.

Also removes fake timer usage — the 10×100ms capability-retry loop in
session.connect() runs in real time (~1s), and vi.waitFor with a 3s
timeout waits it out cleanly.
Spawns a real FastMCP stdio server child process, destroys stdin to
simulate client disconnect, and asserts the child exits cleanly.
Regression test for punkpeye#264.
Three ESLint perfectionist violations:
- node:child_process import before vitest
- env before stdio in spawn options object
- null before number in union type

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Unit tests in FastMCP.stdio.test.ts already cover the fix.
The integration test consistently failed due to tsx --eval ESM incompatibility.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@ElliotDrel
ElliotDrel force-pushed the fix/stdin-close-zombie-process branch from 582d869 to 21bcb71 Compare May 18, 2026 14:26
@dlecan

dlecan commented May 25, 2026

Copy link
Copy Markdown

@punkpeye it would be great to fix that issue quickly
Thanks

@punkpeye
punkpeye merged commit a87f791 into punkpeye:main Jul 23, 2026
1 check passed
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 4.8.1 🎉

The release is available on:

Your semantic-release bot 📦🚀

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

stdio transport: server becomes zombie/orphan process when MCP client closes stdin

4 participants