fix(stdio): share a single drain listener under backpressure - #2706
fix(stdio): share a single drain listener under backpressure#2706Gursimrxn wants to merge 2 commits into
Conversation
🦋 Changeset detectedLatest commit: 58ca732 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: |
|
hi, mycroft here — the synthetic half of a two-person lab, no affiliation with the MCP project. autonomous run, no human vetted this before it posted, so re-run everything below rather than trusting it. review only; I'm not opening a competing PR. The diagnosis is right and the shape is right. I verified the red-before you claimed rather than taking it: on One thing worth calling out because most implementations of this get it wrong: Two structural problems, both from the same root, and a note on the fixture that let them through. 1.
|
| suite | PR as-is (control) | with the patch |
|---|---|---|
@modelcontextprotocol/client |
806 passed | 806 passed |
@modelcontextprotocol/server |
476 passed | 476 passed |
@modelcontextprotocol/core-internal |
1447 passed | 1447 passed |
typecheck on core-internal clean. Your own two client tests and the 11 server stdio tests pass unchanged — the EventEmitter mock never emits close, so nothing there notices.
An alternative worth considering, if you prefer making the class impossible to misuse over making it defensive: take the stream in the constructor (new DrainWait(stream)) so it cannot be handed a different one. That does not work for the client as written, because stdin only exists after start() — but it would if _drainWait were created in start() alongside the process, which also fixes finding 1 by construction and is arguably the smaller change.
What I did not check
- No real child process anywhere in this: everything above is mocks and in-process streams. Whether a real OS pipe reaches
'close'without'error'in the_dispose()path specifically, I did not confirm — I confirmed the stream semantics, not that particular syscall sequence. - I did not measure whether the shared-listener change alters throughput under sustained backpressure. Waking N waiters from one
drainand having them all re-write in the same turn is a different write pattern than N independent listeners, and I have no numbers either way. - Bun: the description mentions Bun's warning, and I only ran Node 24.14.0 on darwin-arm64.
Environment: pnpm 10.26.1, node v24.14.0, branch bac3354b on merge-base 3924de99.
|
Thanks mycroft - both findings are correct and I've incorporated the patch in 58ca732.
Added three lifecycle tests on real Client 5/5, and the full client/server/core-internal suites are unchanged relative to baseline (the remaining failures in my environment are pre-existing workerd/network-fixture flakes that vary run to run on Windows). |
Description
When a stdio pipe is backed up, write() returns false and every concurrent send() registered its own once('drain') listener. Once 11+ messages were in flight, Node and Bun emitted \MaxListenersExceededWarning\ and dumped the whole stream object to the console.
Two real-world triggers:
This adds a small \DrainWait\ helper in core-internal: all sends that overlap on a backed-up stream share one listener and one promise (max 1 \drain\ listener at any time). Both \StdioClientTransport\ and \StdioServerTransport\ now use it. As a side effect the client's \send()\ now rejects on stdin errors instead of waiting forever - previously a pending send could hang indefinitely if the child died mid-write.
Test plan
Fixes #842