Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/listen-close-bounded-teardown.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/client': patch
---

Bound the wait in `McpSubscription.close()`. It serially awaited the `notifications/cancelled` teardown send, so a transport send that never settles (e.g. a stdio write parked on `'drain'`, which ignores `requestSignal`) left `await sub.close()` hanging forever. `close()` still waits for the notification so it is on the wire when it resolves on healthy transports, but the wait is now capped (5s); the subscription's state machine settles immediately either way.
34 changes: 32 additions & 2 deletions packages/client/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,15 @@ const LIST_CHANGED_EVICTIONS: Readonly<Record<string, readonly string[]>> = {

const DEFAULT_LIST_MAX_PAGES = 64;

/**
* Upper bound on how long `McpSubscription.close()` waits for the
* `notifications/cancelled` teardown send before resolving anyway. A healthy
* transport settles the send in well under this; the bound exists so a send
* that never settles (e.g. a stdio write parked on `'drain'`, which ignores
* `requestSignal`) cannot park `close()` forever. See #2641/#2643.
*/
const LISTEN_CLOSE_TEARDOWN_WAIT_MSEC = 5000;

/**
* A handle to an open `subscriptions/listen` stream (protocol revision
* 2026-07-28). Change notifications delivered on the stream dispatch to the
Expand All @@ -450,7 +459,10 @@ export interface McpSubscription {
* Tears the subscription down. Idempotent. Aborts the listen request's
* stream (where the transport supports it) AND sends
* `notifications/cancelled` referencing the listen request id — both,
* always, so close works on any transport.
* always, so close works on any transport. The wait for the
* cancelled-notification send is capped (~5s); on a transport whose send
* never settles, `close()` resolves with the notification still in
* flight.
*/
close(): Promise<void>;
/**
Expand Down Expand Up @@ -2060,7 +2072,25 @@ export class Client extends Protocol<ClientContext> {
const close = async (): Promise<void> => {
if (state === 'closed') return;
settle({ cause: 'local' });
await wireTeardown();
// Bounded wait: close() waits for the cancelled notification so
// it is on the wire when close() resolves (transports settle a
// healthy send quickly), but a send that never settles — a stdio
// write parked on 'drain' ignores `requestSignal`, so the
// `requestAbort.abort()` above cannot reach it — must not park
// close() forever. The bound cuts only the WAIT; the notification
// stays in flight, and the state machine settled above regardless.
await new Promise<void>(resolve => {
const timer = setTimeout(resolve, LISTEN_CLOSE_TEARDOWN_WAIT_MSEC);
// In the parked-send case clearTimeout below is unreachable,
// and the armed timer would hold an idle Node process open
// for the full bound. Guarded: this module is runtime-neutral
// and browser timers have no unref.
(timer as { unref?: () => void }).unref?.();
void wireTeardown().finally(() => {
clearTimeout(timer);
resolve();
});
});
Comment thread
claude[bot] marked this conversation as resolved.
};
Comment thread
claude[bot] marked this conversation as resolved.

// The per-subscription state is registered BEFORE the request is sent
Comment on lines 2072 to 2096

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 Pre-existing (not introduced by this PR): the sibling await this.transport.send(...) at line 2137 in this same listen() body has the exact unbounded-wait-on-a-parked-send shape this PR bounds in close() — and worse, because the opening promise (line 2007) has no rejection handler until line 2150, the ack timer firing during a parked send turns into an unhandledRejection (process crash under Node's default --unhandled-rejections=throw). Since this PR already migrates the same pattern in the same function, consider racing the send against opening (with a pre-attached handler) here too, mirroring what this diff did for close().

Extended reasoning...

What the bug is. This PR bounds one await-on-a-parked-send inside listen() — the wireTeardown() wait in close() (the diff hunk at client.ts:2069-2091). A sibling await in the same function body survives with the same bug class: await this.transport.send(jsonrpcRequest, ...) at client.ts:2137 has no bound, and under the exact parked-send premise this PR series is built on (a stdio write parked on 'drain'; stdio ignores TransportSendOptions.requestSignal, so requestAbort.abort() cannot reach it — the PR's own comment at 2074-2076 states this), that await never settles. The catch at 2141-2148 only handles a send that rejects; a send that never settles is precisely the case the PR description distinguishes ("a backpressured write that never fails still parks").\n\nWhy it's worse than a hang. The opening promise is created at line 2007, and the only handler ever attached to it is const honored = await opening at line 2150 — which execution never reaches while suspended at 2137. Every termination path funnels through settle(), which (state still 'opening') calls rejectOpening() at line 2047. Rejecting a promise with no handler attached emits unhandledRejection, which under Node's default --unhandled-rejections=throw crashes the host process. So in the degraded scenario this PR hardens close() against, opening a subscription escalates from "hangs forever" to "kills the process at ack-timeout".\n\nStep-by-step proof.\n1. A stdio pipe backs up; the subscriptions/listen write at line 2137 parks on 'drain' (stdio's send resolves only on the drain event and never reads requestSignal).\n2. listen() is now suspended at 2137. The opening promise (2007) has zero handlers — line 2150 is unreached.\n3. The ack timer (armed at 2099, default 60s) fires: settle({cause:'remote', error: SdkError(RequestTimeout, ...)}) clears the timer, transitions to 'closed', and — since state was 'opening' — calls rejectOpening(error) at 2047.\n4. requestAbort.abort() inside settle() is a no-op on stdio, so the send at 2137 stays parked; execution never advances to 2150.\n5. Node observes a rejected promise with no handler → unhandledRejection → process crash under default flags. Meanwhile the promise listen() returned to the caller never settles, so await client.listen(...) hangs forever regardless — despite the state machine's own "hangs are impossible by construction" comment (~line 1999).\n\nWhy existing safeguards don't cover it. The ack timer, the caller-signal abort (2115-2120), _resetConnectionState's settle loop, and the transport _onclose path all funnel through settle(), which only rejects opening — none of them can unpark the awaited send itself, and none attaches a handler to opening before line 2150. Each of them is therefore an unhandled-rejection trigger in this state, not a rescue. The auto-open path is also affected: connect() with ClientOptions.listChanged awaits this.listen() at line 1261; its try/catch wraps the outer listen() promise (which never settles), so connect() hangs and the inner opening rejection is still the unhandled one.\n\nImpact and fix. Impact: in the same degraded-transport scenario this PR series targets, listen() (and connect() with auto-open) hangs past its own documented ack timeout, and the timeout's arrival crashes the process instead of surfacing an error. Fix shape: attach a handler to opening up front and let settle() unpark listen — e.g. race the send against opening:\n\nts\nconst sendP = this.transport.send(jsonrpcRequest, { ... }).catch(error => {\n settle({ cause: 'remote', error: error instanceof Error ? error : new Error(String(error)) });\n});\nconst honored = await Promise.race([sendP.then(() => opening), opening]);\n\n\n(or simply void opening.catch(() => {}) before the send plus the race). This both bounds listen() by the ack timer and closes the unhandled-rejection window — mirroring what this diff did for close().\n\nWhy this is filed pre-existing. Lines 2007/2137-2150 are untouched by this diff; the bug exists verbatim on main, and merging this PR neither causes nor worsens it — so it should not block merge. It is flagged per the repo's Completeness convention ("partial migrations leave sibling code paths with the very bug the PR claims to fix — flag every leftover site"): this PR modifies the same function for the same root scenario, making it the natural opportunity to fix the surviving site. Note the PR's stated follow-up (stdio requestSignal support) would incidentally address the parked send here, but would not by itself remove the handlerless-opening window on other never-settling transports.

Expand Down
68 changes: 68 additions & 0 deletions packages/client/test/client/listen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -953,6 +953,74 @@ describe('_resetConnectionState() clears connection-scoped debounce timers (fake
});
});

describe('McpSubscription.close() — bounded teardown wait (fake timers)', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());

it('close() resolves within the bound when the cancelled-notification send never settles', async () => {
const { clientTx } = await scriptedModern();
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
const connecting = client.connect(clientTx);
await vi.runAllTimersAsync();
await connecting;

const subPromise = client.listen({ toolsListChanged: true });
await vi.advanceTimersByTimeAsync(0);
const sub = await subPromise;

// Every subsequent send parks forever — models a stdio pipe backed up
// on 'drain' (stdio send() ignores requestSignal, so the teardown's
// requestAbort cannot reach it).
clientTx.send = () => new Promise<void>(() => {});

let closeSettled = false;
const closing = sub.close().then(() => {
closeSettled = true;
});
// The state machine settles immediately even though the wire teardown
// is parked.
await expect(sub.closed).resolves.toBe('local');
await vi.advanceTimersByTimeAsync(0);
expect(closeSettled).toBe(false); // still waiting on the parked send
// …but the wait is bounded: close() resolves once the bound elapses.
await vi.advanceTimersByTimeAsync(5000);
await closing;
expect(closeSettled).toBe(true);

vi.useRealTimers();
await client.close();
});

it('a healthy close() still resolves with the cancelled notification already on the wire (no added latency)', async () => {
const { clientTx, written } = await scriptedModern();
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
const connecting = client.connect(clientTx);
await vi.runAllTimersAsync();
await connecting;

const subPromise = client.listen({ toolsListChanged: true });
await vi.advanceTimersByTimeAsync(0);
const sub = await subPromise;
written.length = 0;

let closeSettled = false;
const closing = sub.close().then(() => {
closeSettled = true;
});
// No timer advance beyond microtask draining: a settling send must
// resolve close() without waiting out any part of the bound.
await vi.advanceTimersByTimeAsync(0);
await closing;
expect(closeSettled).toBe(true);
expect(written.some(m => (m as { method?: string }).method === 'notifications/cancelled')).toBe(true);
// The bound's timer was cleared — nothing left armed.
expect(vi.getTimerCount()).toBe(0);

vi.useRealTimers();
await client.close();
});
});

describe('Client.listen() — ack timeout (fake timers)', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
Expand Down
Loading