Skip to content

[v2] Client.listen() rejections escape as process-level unhandledRejection while the send is in flight; a parked send hangs listen() past its ack timeout #2641

Description

@claude

What happened?

Client.listen() builds an explicit opening → open → closed state machine, but the promise it suspends on is attached too late. In packages/client/src/client/client.ts the flow is:

  1. opening is created (new Promise(...)) with resolveOpening/rejectOpening captured.
  2. The ack timer is armed and the caller-signal listener installed.
  3. await this.transport.send(jsonrpcRequest, ...)serially awaited.
  4. Only then const honored = await opening; attaches the rejection handler.

Every settle path that fires while step 3 is still pending — ack timeout, transport close, inbound server cancel, caller-signal abort, _resetConnectionState — calls rejectOpening(...) on a promise that has no handler attached, so the rejection surfaces as a process-level unhandledRejection that caller-side handling cannot prevent (a .catch() on the listen() promise does not help, because listen() itself is still suspended inside the send). In production this shows up as crashes under --unhandled-rejections=strict and noisy unhandledRejection events that no application code can own.

Two failure shapes compound it:

  • Slow send: any transport send that outlives the ack timeout (default 60s, or options.timeout) produces the escaped REQUEST_TIMEOUT rejection.
  • Parked send: StdioClientTransport.send() waits indefinitely on 'drain' when the child's stdin is backed up and ignores TransportSendOptions.requestSignal entirely (see the overlapping fix(client): reject stdio send() when the write fails instead of waiting for 'drain' #2552), so the send never settles. Then listen() also hangs forever, even though its own ack timer already fired and rejected the (unobserved) opening promise.

What did you expect?

Every termination path should reject the promise listen() returns — a caller who wrote await client.listen(...) inside try/catch should observe the timeout/close error there, and nothing should reach process.on('unhandledRejection').

Code to reproduce

Self-contained against the published packages (@modelcontextprotocol/client@2.0.0); observed output below. The scripted server answers server/discover (modern era) and never acks the listen; the client transport's subscriptions/listen send parks forever, modelling the backpressured-stdio case.

// node repro.mjs — @modelcontextprotocol/client@2.0.0
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";

const unhandled = [];
process.on("unhandledRejection", (reason) => unhandled.push(reason));

const [clientTx, serverTx] = InMemoryTransport.createLinkedPair();
serverTx.onmessage = (message) => {
  if (message.method === "server/discover" && message.id !== undefined) {
    void serverTx.send({
      jsonrpc: "2.0",
      id: message.id,
      result: {
        resultType: "complete",
        supportedVersions: ["2026-07-28"],
        capabilities: { tools: { listChanged: true } },
        _meta: { "io.modelcontextprotocol/serverInfo": { name: "scripted", version: "1" } },
      },
    });
  }
};
await serverTx.start();

const client = new Client({ name: "c", version: "0.0.0" }, { versionNegotiation: { mode: "auto" } });
const originalSend = clientTx.send.bind(clientTx);
clientTx.send = (message, options) =>
  message?.method === "subscriptions/listen" ? new Promise(() => {}) : originalSend(message, options);
await client.connect(clientTx);

const listenOutcome = client.listen({ toolsListChanged: true }, { timeout: 200 }).then(
  () => "resolved (unexpected)",
  (err) => `rejected, caught by caller: ${err?.code ?? err?.message}`,
);
const outcome = await Promise.race([
  listenOutcome,
  new Promise((r) => setTimeout(() => r("still pending after 1s — listen() HUNG"), 1000)),
]);
console.log("listen():", outcome);
console.log("unhandledRejection count:", unhandled.length);
for (const u of unhandled) console.log("  escaped:", u?.code ?? String(u));

Output on 2.0.0 (same code paths present on main @ cc4b416):

listen(): still pending after 1s — listen() HUNG
unhandledRejection count: 1
  escaped: REQUEST_TIMEOUT

Both symptoms at once: the ack-timeout rejection escaped to the process level, and listen() never settled.

Suggested fix

Make opening the promise listen() suspends on, and stop serially awaiting the send: fire transport.send(...) with a .catch that routes the failure into the existing settle({ cause: 'remote', ... }) funnel (plus a try/catch for a synchronous throw). Then every settle path rejects a promise whose handler is already attached, and a send that never settles no longer blocks the ack timer from surfacing through listen() itself.

SDK version

@modelcontextprotocol/client@2.0.0 (repro above); same code on main @ cc4b416.

Area

Client


Generated by Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions