Skip to content

fix(client): reject stdio send() when the write fails instead of waiting for 'drain'#2552

Open
ondraulehla wants to merge 1 commit into
modelcontextprotocol:mainfrom
ondraulehla:fix/stdio-client-send-write-callback
Open

fix(client): reject stdio send() when the write fails instead of waiting for 'drain'#2552
ondraulehla wants to merge 1 commit into
modelcontextprotocol:mainfrom
ondraulehla:fix/stdio-client-send-write-callback

Conversation

@ondraulehla

Copy link
Copy Markdown

StdioClientTransport.send() now settles from the write() callback, so a backpressured send can't stay pending forever when the pipe to the server dies.

Motivation and Context

I noticed this while reading the two stdio transports side by side. The server one rejects a failed write, the client one has no error path at all: its promise executor only takes resolve, and a backpressured write resolves on 'drain'. If the server process then exits, or close() escalates to SIGTERM and SIGKILL, the stream is destroyed, and a destroyed stream never drains. The promise just stays pending, and the 'drain' listener is never removed.

The symptom is that await client.notification(...) never comes back. The notification path awaits the send with no timeout, and the connection-closed teardown settles pending responses but not pending sends, so nothing rescues it. I tried it against a server that answers initialize, stops reading stdin and exits, with 8 MB in flight: the await is still pending after 8 seconds, and with this change it rejects in 0.4 seconds. Requests were already covered by the teardown, so for them the only difference is that they now report the actual EPIPE instead of a generic connection-closed error.

You need a write the pipe won't take in one go to get there, which on Linux is between 160 KB and 224 KB depending on the Node version. Base64 image payloads and file contents in tool arguments reach that routinely.

The server transport has rejected on a write failure since #1568, with a test pinning its listener cleanup, so this brings the client side in line. The fast path stays as it was, since a write() that returns true still resolves right away and only the failure path differs.

How Has This Been Tested?

  • New test in packages/client/test/client/stdio.test.ts, using a server that never reads stdin and then exits with 8 MB of params in flight. Without the change the send never settles and the test reports hung; with it the send rejects and no 'drain' listener is left behind, which is the same leak check the server transport test already makes. I bounded the wait so the old behaviour fails fast instead of timing out the suite, and I don't assert the rejection reason because it is platform specific.
  • On Ubuntu the client suite is green on Node 20, 22 and 24, and pnpm test:all is green on Node 24. I also ran the new test ten times to make sure it isn't flaky. Typecheck, eslint and prettier are clean, changeset included.

Breaking Changes

None. send() already returns Promise<void> and the sibling stdio transport rejects it on a write failure, so callers that awaited it keep working.

v1.x has the same pattern in src/client/stdio.ts, and in src/server/stdio.ts too since #1568 only landed on main. Happy to send that separately if you want it.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

A backpressured `StdioClientTransport.send()` waited for a `'drain'` event, but
a pipe destroyed by the server process exiting never drains, so the promise
stayed pending for the lifetime of the process and the `'drain'` listener
leaked. Settle from the `write()` callback instead, which Node invokes on flush
or on failure, so the send rejects with the underlying write error. This is what
`StdioServerTransport.send()` already does for its own stdout.
@ondraulehla
ondraulehla requested a review from a team as a code owner July 26, 2026 19:47
@changeset-bot

changeset-bot Bot commented Jul 26, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 25eab69

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

This PR includes changesets to release 6 packages
Name Type
@modelcontextprotocol/client Patch
@modelcontextprotocol/core Patch
@modelcontextprotocol/server Patch
@modelcontextprotocol/server-legacy Patch
@modelcontextprotocol/codemod Patch
@modelcontextprotocol/core-internal Patch

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 Jul 26, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2552

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@2552

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@2552

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2552

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@2552

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2552

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2552

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2552

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2552

commit: 25eab69

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant