modelcontextprotocol / modelcontextprotocol/typescript-sdk
[v2] Client.listen() rejections escape as process-level unhandledRejection while the send is in flight; a parked send hangs listen() past its ack timeout
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 13.4k
- Forks
- 2.2k
- Avg merge
- 3d 15h
- Merged PRs (30d)
- 4
Description
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:
openingis created (new Promise(...)) withresolveOpening/rejectOpeningcaptured.- The ack timer is armed and the caller-signal listener installed.
await this.transport.send(jsonrpcRequest, ...)— serially awaited.- 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 escapedREQUEST_TIMEOUTrejection. - Parked send:
StdioClientTransport.send()waits indefinitely on'drain'when the child's stdin is backed up and ignoresTransportSendOptions.requestSignalentirely (see the overlapping #2552), so the send never settles. Thenlisten()also hangs forever, even though its own ack timer already fired and rejected the (unobserved)openingpromise.
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
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in packages/client/src/client/client.ts at Client.listen(), tracing the opening promise, ack timer, settle paths, and transport.send() call. Run the supplied reproduction against the parked-send scenario; done means listen() rejects with the timeout or close error and no rejection reaches process-level unhandledRejection.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- api
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 58/100