Skip to content

Commit 7ade3d6

Browse files
fix(client): settle live listen state on connection reset; reset even when transport close rejects
A fresh connect (or close()) clears _listenState via _resetConnectionState without settling the per-listen machines: when the prior transport never fired onclose, an in-flight listen() promise from the old connection hangs forever. Settle every live entry with a clear ConnectionClosed error before clearing the map. Also wrap super.close() in try/finally so a rejecting transport close still resets per-connection state — a stale negotiated era / live listen state must not survive a failed close.
1 parent 7a5f2db commit 7ade3d6

1 file changed

Lines changed: 26 additions & 3 deletions

File tree

packages/client/src/client/client.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,8 @@ export interface McpSubscription {
279279
interface ListenStateEntry {
280280
onAck: (honored: SubscriptionFilter) => void;
281281
onServerCancel: () => void;
282+
/** Settle the per-listen machine with an explicit error (used by `_resetConnectionState`). */
283+
onConnectionReset: (error: Error) => void;
282284
}
283285

284286
/**
@@ -349,13 +351,33 @@ export class Client extends Protocol<ClientContext> {
349351
this._serverVersion = undefined;
350352
this._instructions = undefined;
351353
this._autoOpenedSubscription = undefined;
354+
// Settle every live per-listen state machine before clearing the map:
355+
// a fresh connect (or close) on a connection whose prior transport
356+
// never fired onclose would otherwise leave an in-flight listen()
357+
// promise hanging forever. Each entry's settle() deletes itself from
358+
// the map, so iterate over a snapshot.
359+
if (this._listenState.size > 0) {
360+
const reason = new SdkError(
361+
SdkErrorCode.ConnectionClosed,
362+
'subscriptions/listen: client reconnected or closed; subscription state from the previous connection was reset'
363+
);
364+
for (const entry of [...this._listenState.values()]) {
365+
entry.onConnectionReset(reason);
366+
}
367+
}
352368
this._listenState.clear();
353369
this._cachedToolOutputValidators.clear();
354370
}
355371

356372
override async close(): Promise<void> {
357-
await super.close();
358-
this._resetConnectionState();
373+
try {
374+
await super.close();
375+
} finally {
376+
// Per-connection state is cleared even when the transport's close
377+
// rejects, so a stale negotiated era / live listen state cannot
378+
// survive a failed close.
379+
this._resetConnectionState();
380+
}
359381
}
360382

361383
/**
@@ -1401,7 +1423,8 @@ export class Client extends Protocol<ClientContext> {
14011423
// listen() promise; once open, settle just
14021424
// transitions to closed and the message is unused.
14031425
settle({ error: new Error('subscriptions/listen: server cancelled the subscription') });
1404-
}
1426+
},
1427+
onConnectionReset: error => settle({ error })
14051428
});
14061429
}
14071430
);

0 commit comments

Comments
 (0)