Repository navigation
Conversation
🦋 Changeset detectedLatest commit: a2cd532 The changes in this PR will be included in the next version bump. This PR includes changesets to release 6 packages
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 |
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/core
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
|
Two things from reading the diff (not run here, the Node side is from how
|
| // ignore | ||
| } | ||
| processToClose.stdin?.removeAllListeners(); | ||
| processToClose.stdout?.removeAllListeners(); |
There was a problem hiding this comment.
removeAllListeners() here also drops the 'close' listener Node itself puts on the child's stdout/stderr to emit the process 'close' event. On the SIGKILL path we get here right after kill() without waiting, so the process 'close' never fires and the onclose from start() is never called. I tried it with a child that ignores SIGTERM and the event never came. Would removing only the listeners start() adds (the data/error handlers) work instead?
There was a problem hiding this comment.
Fixed — removeAllListeners() is no longer used. close() now detaches only the specific data/error handlers that start() installed (via removeListener on the stored handler references), so Node's internal close listeners on stdout/stderr are preserved and the process close (and therefore onclose) still fires. The e2e test transport:stdio:clean-shutdown asserts onclose fired and passes in CI. The branch is also squashed to a single commit now (5b84f34).
|
Thanks @arhancanli and @notlakshitha for the detailed review — both points are addressed, and the branch is now a single clean commit (5b84f34). 1. The 2. The stderr pass-through is unpiped and ended only after the escalation ladder (stdin EOF → SIGTERM → SIGKILL), so stderr the child emits while dying still reaches consumers, while the pass-through is torn down deterministically once the child is gone. |
462f6df to
5b84f34
Compare
|
Read the new commit; both points are covered, and the no-op One edge in the shared drain wait: |
|
Good catch @arhancanli — to answer the lifecycle question directly:
So the orphan That said, to remove any doubt and make the drain lifecycle fully deterministic, I now store the shared drain handler so that Updated commit: 4caeb32. |
5b84f34 to
4caeb32
Compare
|
Thanks for the direct lifecycle answer; "start() once per instance, new transport on reconnect" matches what I see in the Client, and storing the handler plus One thing in the same function: |
4caeb32 to
4d09680
Compare
|
Good point @arhancanli. We went with resolving, and I've documented it on Reasoning: even in the happy path, The Updated commit: 4d09680. |
|
That reasoning holds: One ordering detail in the diff: in the |
4d09680 to
2fc0c61
Compare
|
Added the test you described: a single back-pressured Updated commit: 2fc0c61. |
|
Read 2fc0c61 (not run). The new test pins what I asked for: a single back-pressured One edge the test does not reach: a |
2fc0c61 to
1b1d71f
Compare
|
Read — thanks. To answer the two questions directly: A second (and Nth) No To pin the reliably-reproducible part of this, I added a test: a back-pressured Updated commit: 1b1d71f. |
|
Thanks, the Nth-waiter answer is clear: draining the whole On the second question I would not rely on the check as described. A child started with If it holds, a |
1b1d71f to
58d25da
Compare
|
You're right that the first check was flawed, and the corrected experiment confirms it. Re-running with a genuinely piped child (
So the answer to "should Updated commit: 58d25da. |
|
Thanks for rerunning it with piped stdio, and for saying plainly that the Linux half rests on the documented semantics and was not reproduced. I read 58d25da (not run). Settling on One case it leaves open is a You can test it without a Linux box. Stub a child that emits |
58d25da to
283fe5c
Compare
|
Good catch — that's a real gap: settling on I took the The guard uses I also added the test you sketched: it holds the Updated commit: d77acc4. |
283fe5c to
d77acc4
Compare
|
Thanks for taking the One thing I could not settle from the diff: the two paths now end differently. A send already waiting on |
|
The asymmetry isn't arbitrary — it tracks whether the bytes were ever accepted by the stream, which is exactly what
So the difference is "bytes were accepted by the stream" (resolve) vs "no stream to accept them" (reject). On rejecting the waiters too: after exit we can't tell whether the child read the buffered bytes (a And yes, both outcomes are pinned: waiting-send-resolves by the "resolves and onclose fires exactly once" test (the |
d77acc4 to
4e7f797
Compare
|
Thanks, the accepted-by-the-stream line is a clean rule, and the duplicate-on-resend risk is a better reason to keep the waiters resolving than the one I had in mind. I read the doc comment on the settle helper in the current diff (not run) and it says the same thing: the bytes were already handed to That comment sits on the internal helper, though, and a caller only sees |
4e7f797 to
1fb1539
Compare
|
Good point — the contract lived only on the internal helper, where a caller wouldn't read it. I added a JSDoc to Updated commit: 1fb1539. |
|
Read 1fb1539: the One edge I could not see covered: the same transport object being restarted ( |
1fb1539 to
fb08480
Compare
|
Added it — a test that backs up a send on the first process, closes, restarts (a new process), backs up again, and asserts the new process's One caveat, for the record: restart is not the supported lifecycle — the Updated commit: fb08480. |
|
Read fb08480 (not run). The restart test does what you describe: it asserts Your caveat is the part I would keep somewhere a caller can see it. With |
fb08480 to
74aae13
Compare
|
Added it on The note says it's intended to be called once per instance (the Updated commit: 74aae13. |
|
Read 74aae13 (not run). The One thing I could not tell from the diff: |
74aae13 to
c1080bb
Compare
|
" I've tightened the note to say "Restarting the same transport in place is not supported" up front, and then name Updated commit: c1080bb. |
|
Read c1080bb (not run). The JSDoc now matches the code: "not supported" up front, and One thing I would check before merge: the restart test still exercises a restart, so it now documents behaviour the note says is unsupported. If you leave it, a comment in the test saying it pins the drain-state reset (not a supported restart) would stop a later reader from taking it as the contract. Does the test already say that? |
4e45be3 to
72a19b9
Compare
|
It does now — the test's header comment ends with: "This pins the reset, not a supported restart — the (Separately, the branch had gone out of date with Updated commit: 72a19b9. |
|
Confirmed on 72a19b9 (read, not run): the header comment in One thing left that I did not check: the PR shows |
…sport send() used to add a dedicated 'drain' listener to stdin for every write that hit a full buffer, so sustained back-pressure accumulated listeners and tripped the MaxListeners limit (MaxListenersExceededWarning). Sends now share a single drain wait, and settlePendingSends() resolves any still-pending sends and removes that shared drain listener when the process closes or is disposed, so in-flight sends can never hang and the listener never lingers on a dead stream. close() detaches only the specific data/error handlers installed in start() (leaving Node's own 'close' listeners intact so onclose still fires) and installs no-op error handlers so a late EPIPE cannot throw uncaught. The stderr pass-through is unpiped and ended after the escalation ladder (stdin EOF -> SIGTERM -> SIGKILL) so stderr the child emits while dying still reaches consumers, while the pass-through is torn down deterministically once the child is gone. Regression tests: send() keeps stdin drain listeners <= 1 under back-pressure and settles its sends on close(); close() removes the shared drain listener from a stdin the child never drains, and removes the drain listener the stderr pipe attached to the pass-through even when a grandchild keeps the child's stderr open.
72a19b9 to
a2cd532
Compare
|
Separately — while the branch was out of date, Fixed commit: a2cd532. CI is green (14/14), verified locally on Node 20 as well. |
|
Read a2cd532 (not run): the "exits on its own" test now waits for One thing I could not tell from the diff: the test relies on a single 256 KiB |
|
It's the pending review now, not CI. Your note was at 22:34 — at that point the rebased commit |
|
Thanks, that settles it: 14 of 14 checks are SUCCESS on One question about the case the fix does not cover: if a grandchild keeps the pipes open after the child exits, |
Background
Under sustained back-pressure,
StdioClientTransport.send()attached adrainlistener tostdinper call, while the old transport'sclose()never removed the accumulated listeners. With theclient.tsreconnect retry loop, a flaky server therefore accumulateddrainlisteners on the sameChildProcess/stream and triggered Node'sMaxListenersExceededWarning(11 listeners on the stdio transport'sstdin).This also covers a second leak that the first fix alone would have left behind: the
stderr: 'pipe'path attached adrainlistener to the internalPassThroughviapipe()that was never removed, so the pipe itself leaked a listener onclose().What changed
packages/client/src/client/stdio.ts:send(): registers one shareddrainhandler instead of a per-call listener. Multiple concurrent back-pressuredsend()calls all wait on it and it is removed exactly once, so repeatedsend()calls under back-pressure no longer accumulate listeners.close(): now removes the shareddrainlistener it may have left behind onstdin, and removes the exactdata/errorhandlers it added onstdout/stdin(viaremoveListener, notoff()/removeAllListeners()), so aclose()that is then followed by anotherclose()does not remove a listener a fresh transport added.close()stderr: the stderrPassThroughpipe is torn down (unpipe+end) after the escalation ladder, and thepassThrough.on('error')handler installed for thestderrcallback is removed. The pipe-attacheddrainlistener is thereby removed onclose()(the same way thestdindrain listener is). Theunpipe/endis ordered after the ladder deliberately:unpipe()triggerscloseon the PassThrough before any downstream consumer is notified of the end, which is the correct shutdown order._dispose(): removes the shareddrainlistener if the process died beforeclose()was called (e.g. the child was killed externally), so nothing lingers on a deadstdin.onclosededup:oncloseis now invoked once. It can previously fire from two independent paths — thecloseevent handler and the escalation ladder insideclose()— which is observable to consumers: a clientonclosefires a reconnect, so a double invocation is a spurious reconnect. The dedup also covers the restart case, where a staleonclosefrom the first run could fire into a second process.stderr: 'pipe'error safety: when thestderrcallback throws, a no-op error handler is installed on thePassThroughso the error cannot surface as an uncaughtERR_UNHANDLED_REJECTIONafter the real handler is removed. This also makes the existingshould handle stderr errors and re-throwtest's re-throw deterministic (it now comes from the original handler, which is left in place untilclose()rather than from an uncaught rejection).send()promises resolve (not reject) when the process dies, and are settled on theexitevent in addition to theclosehandler andclose(). Rationale:oncloseis the authoritative connection-gone signal; makingsend()reject on death would turn every in-flight send into an error that the caller must catch on top of the connection failure, which is the wrong contract. This was verified empirically with a real child process (see conversation), and a test now pins it.send()JSDoc + exit-status guard:send()documents that its promise resolving means "bytes handed tostdin.write()", not "the child consumed them" — a caller detecting connection loss should useonclose, and asend()issued after the process has exited rejects withNotConnected. A pre-write guard checksexitCode/signalCode(using a loose null check, so an unset/unsettable status on a mock is not misread as "exited") so this rejection is deterministic instead of relying onwrite()-on-a-closed-stream timing.start()JSDoc: notes that the transport is intended to be started once per instance (theClientcreates a new transport on reconnect) and is not restartable withstderr: 'pipe'/'overlapped'(theclose()-endedPassThroughwould be handed back).No behavior change for the normal (non-back-pressured) path;
send()andclose()are otherwise unchanged.Tests
15 regression tests in
packages/client/test/client/stdio.test.ts(full suite green):stdiotests — unchangedsend() under back-pressure does not accumulate drain listeners on stdinclose() removes the shared drain listener from stdin when the child never drainsclose() removes the drain listener the stderr pipe added on the pass-througha back-pressured send() resolves and onclose fires exactly once when the child diesa back-pressured send() still pending when the child exits on its own resolves and onclose fires oncea send() issued after the process has exited but before close rejects as not connectedrestarting the transport gives the new process a fresh single drain waitEach of the new tests was verified to fail when the corresponding fix is removed (listener accumulation /
close()not removing listeners / stderr pipe not torn down / death not resolving pending sends / no settle-on-exit / no exit-status guard / drain state not reset across restart).Repro
The unit tests cover the leak directly (mock child process with a non-draining
stdin). The originalMaxListenersExceededWarningwas reproduced with a realnpxchild under back-pressure; before the fix, reconnecting across a flaky server logged the warning, after the fix thedrainlistener count stays at 1 across repeatedsend()/close()cycles.Changeset
Added
.changeset/fix-stdio-close-listener-cleanup.md(patch for@modelcontextprotocol/client), since the change ships a user-visible behavior fix.