Skip to content

[v1.x] fix(server): close StdioServerTransport when stdin ends or closes - #2911

Open
maxymlyskov wants to merge 4 commits into
modelcontextprotocol:v1.xfrom
maxymlyskov:fix/v1-stdio-server-close-on-stdin-end
Open

maxymlyskov wants to merge 4 commits into
modelcontextprotocol:v1.xfrom
maxymlyskov:fix/v1-stdio-server-close-on-stdin-end

Conversation

@maxymlyskov

Copy link
Copy Markdown

Backport of #2494 for #2002 on v1.x.

On v1.x, StdioServerTransport.start() listens for stdin data and error and nothing else (src/server/stdio.ts#L53-L63). So when the client hangs up the pipe, the transport never closes and onclose never fires. A server that frees a timer or pool in onclose keeps running with no parent. #2002 has 6 of them piling up in ~17 hours (all PPID=1) and another report of 7 in 5 days on 1.29.0, and its triage comment reproduced the same code on v1.x.

Driver: a real child server holding a setInterval it clears in onclose. It completes initialize, then child.stdin.end(), no signal:

### v1.x c2ce0031 (Windows 11, Node v22.12.0)
RESULT: server pid 22588 STILL RUNNING 10009 ms after stdin EOF, onclose never fired -> SIGKILL
### this branch (Windows 11, Node v22.12.0)
  [child stderr] server: onclose fired (#1), keep-alive released
RESULT: server exited code=0 10 ms after stdin EOF
### v1.x c2ce0031 (WSL2 Linux, Node v22.22.0)
RESULT: server pid 496 STILL RUNNING 10011 ms after stdin EOF, onclose never fired -> SIGKILL
### this branch (WSL2 Linux, Node v22.22.0)
  [child stderr] server: onclose fired (#1), keep-alive released
RESULT: server exited code=0 7 ms after stdin EOF

The fix is the stdin half of #2494: end and close listeners that close the transport, removed again in close(), a _closed guard (today a second close() fires onclose twice), and a setImmediate close when stdin already ended or was destroyed before start().

One change a v1 user can notice: requests still in flight at stdin EOF are aborted and their responses aren't written. Same as main, and the changeset says so.

Left out: stdout error handling, send() rejecting after close and the swallow-listener sweep. On v1.x that's open #2579, which edits this file and adds its own _closed guard, so whichever lands second gets a small rebase.

Tests

On untouched v1.x, 8 of the 9 fail and the listener test passes on both:

     × server process should exit on its own when the client closes stdin 8497ms
   × should close and fire onclose when stdin ends (client hung up) 5011ms
   × should close and fire onclose when stdin closes 5010ms
   × should fire onclose when stdin was already destroyed before start() 5012ms
   × should fire onclose when stdin had already ended before start() 5014ms
   × should fire onclose assigned after start() when stdin was already dead 5005ms
   × should not fire onclose twice when close() is called after stdin ends 5014ms
   ✓ should remove its stdin end and close listeners on close 1ms
   × should still deliver messages that arrived before stdin ended 5009ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 8 ⎯⎯⎯⎯⎯⎯⎯
AssertionError: expected 'zombie' to be +0 // Object.is equality
Error: Test timed out in 5000ms.
 Test Files  2 failed (2)
      Tests  8 failed | 1 passed | 8 skipped (17)
  • npm test: 1818 passed (Windows 11 / Node 22.12)
  • npm run test:e2e: 1114 passed, 1 failed. transport:stdio:shutdown-escalation fails the same way on untouched v1.x (Windows delivers no catchable SIGTERM). On WSL2 Linux / Node 22.22: 1115 passed.
  • npm run typecheck, npm run lint: clean

Changeset included.

StdioServerTransport listened only for 'data' and 'error' on stdin, so a
server whose client hung up its end of the pipe never closed: onclose never
fired, and a server holding a keep-alive handle kept running with no parent
(modelcontextprotocol#2002).

Close the transport on stdin 'end' and 'close', also when the stream had
already ended or been destroyed before start(), and fire onclose once however
many of those arrive. This ports the stdin part of modelcontextprotocol#2494 (6a05402) and its
tests; stdout error handling stays with modelcontextprotocol#2579.
@maxymlyskov
maxymlyskov requested a review from a team as a code owner September 30, 2026 21:57
@changeset-bot

changeset-bot Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c83162f

The changes in this PR will be included in the next version bump.

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@modelcontextprotocol/sdk@2911

commit: c83162f

@claude claude Bot added the v1 Issues / PRs related to v1.x label Oct 1, 2026
@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

Closing on stdin 'end' removed the transport's only 'error' listener from the stream. With one socket for both directions (new StdioServerTransport(socket, socket)) a write that was still pending could then fail with EPIPE and, with no listener left, end the process. The listener now stays until the stream closes, so the error reaches onerror as before.

Also documents on the class that requests in flight when stdin ends are not answered, as on main.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread src/server/stdio.ts
Comment on lines +50 to +53
_onstdinclose = () => {
// stdin EOF means the client hung up and nothing more can arrive.
this.close().catch(() => {});
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Scripted callers that pipe requests in and close stdin (half-close, e.g. printf '…' | node server.js) now get no response for any handler still running at EOF. src/server/stdio.ts:52 closes the transport on 'end', and Protocol._onclose (src/shared/protocol.ts:657) aborts every in-flight handler, so its response is skipped at src/shared/protocol.ts:788. The changeset calls this accepted; in fact it hits every one-shot pipe whose tool handler awaits anything, since the writer closes the pipe immediately. Fix: on stdin 'end' (as opposed to 'close'/'error'), let request handlers already received before EOF finish and write their responses to stdout before onclose fires and handlers are aborted; keep immediate close on destroy. A Protocol-level drain is the durable fix.

Why this was flagged

A client or shell script writes one or more requests to the server's stdin and then closes its write end while still reading stdout, e.g. printf '…' | node server.js with a tool handler that awaits file or network I/O. The data chunk arrives, Protocol._onrequest schedules the handler, then the EOF read callback fires 'end' and src/server/stdio.ts:73 invokes _onstdinclose, which calls close() and onclose synchronously (src/server/stdio.ts:114). Protocol._onclose aborts every controller in _requestHandlerAbortControllers (src/shared/protocol.ts:657). When the handler resolves, src/shared/protocol.ts:788 sees signal.aborted and returns without sending the JSON-RPC response. On the base branch the transport ignored 'end', the handler completed and the response was written to stdout; after this change the caller reads nothing on stdout and the request silently vanishes. The new test 'should still deliver messages that arrived before stdin ended' only asserts onmessage was called, not that a response is written, so it does not catch this.

Verification: src/server/stdio.ts:73 registers this._stdin.on('end', this._onstdinclose), and close() invokes this.onclose?.() at line 114. Protocol's _onclose at src/shared/protocol.ts:657-660 aborts every in-flight handler; lines 788-791 then return on abortController.signal.aborted, so no response is written to stdout. On c2ce003 start() only listened for 'data' and 'error', so the response was written.

Comment thread test/server/stdio.test.ts
await closed;
await server.close();
// Allow any late 'close' event from the stream teardown to be delivered
await new Promise(resolve => setTimeout(resolve, 10));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 nit (optional): this new test waits on a real 10ms setTimeout, which CLAUDE.md asks tests to avoid in favour of vi.useFakeTimers(). Fix: replace the real delay in test/server/stdio.test.ts:306 with fake timers (vi.useFakeTimers() plus await vi.runAllTimersAsync()) or a tick-based flush such as awaiting setImmediate, since the late 'close' event is delivered via nextTick, not a timer. [also at: test/server/stdio.test.ts:306 - nit: test/server/stdio.test.ts — CLAUDE.md asks tests to use vi.useFakeTimers() instead of real setTimeout/await delays: the new 'should not fire onclose twice when close() is called after stdin ends' test waits on a real await new Promise(resolve => setTimeout(resolve, 10)) to let a late… Nothing fails at runtime. The instruction guards against…]

Why this was flagged

The test 'should not fire onclose twice when close() is called after stdin ends' at test/server/stdio.test.ts:306 does await new Promise(resolve => setTimeout(resolve, 10)) to let a late stream 'close' event arrive. CLAUDE.md line 25 asks for vi.useFakeTimers() instead of real setTimeout/await delays in tests. Nothing breaks at runtime; the instruction guards against slow and timing-flaky suites. Two pre-existing tests in the same file (lines 121 and 146) use the same pattern, which is precedent but not what the instruction permits.

Verification: nit. CLAUDE.md line 25 reads "- Testing: Co-locate tests with source files, use descriptive test names. Use vi.useFakeTimers() instead of real setTimeout/await delays in tests". Line 306 of test/server/stdio.test.ts, in a test the diff adds, is await new Promise(resolve => setTimeout(resolve, 10));. Nothing breaks at runtime; the cost is a real wait and timing-based flakiness.

Comment thread src/server/stdio.ts
// A stream that already ended or was destroyed will never emit 'end'/'close' again.
// Deferred so an onclose assigned right after connect() still sees it.
if (this._stdin.readableEnded || this._stdin.destroyed) {
setImmediate(this._onstdinclose);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 (optional) Servers that reconnect a fresh transport from onclose now spin forever at 100% CPU once the client hangs up, where the base left them idle. src/server/stdio.ts:68-69 sees process.stdin already readableEnded on every new start() and schedules _onstdinclose via setImmediate, which closes, fires onclose, and the handler connects again, with no yield between iterations. Fix: do not re-arm a dead stdin on each start(); either throw from start() when stdin is already ended/destroyed, or fire onclose once per stream and refuse further starts on the same ended stream.

Why this was flagged

Trigger: user code that resets the server on close, e.g. server.onclose = () => server.connect(new StdioServerTransport()). Entry point is onclose fired from close() at src/server/stdio.ts:114 after stdin EOF. On the second connect, start() at src/server/stdio.ts:68 finds process.stdin.readableEnded true and calls setImmediate(this._onstdinclose) at line 69; that runs close() (line 92), which fires onclose (line 114), which connects again, and so on. setImmediate callbacks run every event-loop turn with no delay, so the process busy-loops and never exits. On the base branch the second transport only attached 'data'/'error' listeners to an ended stream and nothing fired, so the process sat idle and exited when its other handles were released. Remedy: make start() on an already-ended or destroyed stdin reject (or fire onclose only once per Readable, tracked on the stream), so a reconnect loop terminates with an error instead of spinning.

Verification: src/server/stdio.ts:68-69 fires setImmediate(this._onstdinclose) on every start() against an already-ended stdin; _onstdinclose calls close(), which calls this.onclose?.() at line 114. Protocol's wrapper sets this._transport = undefined at src/shared/protocol.ts:664 before invoking the user's onclose at 665, so the reconnect passes the already-connected guard and reaches start() again, with no terminating condition.

…d stream

start() defers its own close by one turn when stdin had already ended. If close() ran in between, that deferred handler attached the error listener again on a transport that was already closed. It now does so only when it is the one that closes.

The changeset also names two more effects of closing on stdin end: a notification sent afterwards rejects with 'Not connected', and an onclose that calls process.exit() can cut off output that is still being written.
@maxymlyskov

Copy link
Copy Markdown
Author

The three findings apply to main as well. This PR is a straight backport of #2494, and main's StdioServerTransport closes on stdin 'end' the same way, with the same readableEnded check (packages/server/src/server/stdio.ts:129-135) and the same 10 ms wait in the new test (packages/server/test/server/stdio.test.ts:295). I kept v1.x identical to main on purpose.

If letting in-flight handlers finish after a half-close is the behavior you want, I think that change belongs on main first, then here.

This branch has not been deployed

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

Labels

v1 Issues / PRs related to v1.x

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants