Skip to content

Commit 7ce9c5b

Browse files
refactor(client): listen() driver as an explicit opening→open→closed state machine
Every termination path — ack-arrives, ack-timeout, server-cancelled, user-close, transport-close, send-failure — now funnels through a single `settle()` that clears the ack timer, unparks, transitions state, and resolves/rejects the opening promise exactly once. The cancelled-before-ack hang (server sends notifications/cancelled for the listen id BEFORE the ack → 60s misleading RequestTimeout) is impossible by construction: the pre-ack server-cancel rejects the pending listen() promise immediately with a clear "server cancelled before acknowledging" error. Guard order is swapped (NotConnected before the era guard) so a closed instance rejects with NotConnected rather than a misleading MethodNotSupportedByProtocolVersion now that close() clears the negotiated era. Tests cover: cancelled-before-ack rejects fast and leaves no leaked `_responseHandlers` entry; a late duplicate ack after close is a no-op; a synchronous `transport.send` throw does not leak a `_responseHandlers` entry (the `_parkRequest` send-throw guard introduced with the primitive).
1 parent 1d57f04 commit 7ce9c5b

2 files changed

Lines changed: 167 additions & 43 deletions

File tree

packages/client/src/client/client.ts

Lines changed: 90 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ export interface McpSubscription {
276276

277277
/** @internal */
278278
interface ListenStateEntry {
279-
onAck: ((honored: SubscriptionFilter) => void) | undefined;
279+
onAck: (honored: SubscriptionFilter) => void;
280280
onServerCancel: () => void;
281281
}
282282

@@ -1229,6 +1229,14 @@ export class Client extends Protocol<ClientContext> {
12291229
* unsolicited delivery model still applies there); no transparent shim.
12301230
*/
12311231
async listen(filter: SubscriptionFilter, options?: RequestOptions): Promise<McpSubscription> {
1232+
// Connectivity is checked first so a closed instance rejects with
1233+
// NotConnected (no setup or ack timer is started); after close(),
1234+
// `_resetConnectionState` has also cleared the negotiated era, so the
1235+
// era guard alone would surface a misleading
1236+
// MethodNotSupportedByProtocolVersion.
1237+
if (this.transport === undefined) {
1238+
throw new SdkError(SdkErrorCode.NotConnected, 'Not connected');
1239+
}
12321240
const negotiated = this._negotiatedProtocolVersion;
12331241
if (negotiated === undefined || !isModernProtocolVersion(negotiated)) {
12341242
throw new SdkError(
@@ -1239,13 +1247,6 @@ export class Client extends Protocol<ClientContext> {
12391247
{ method: 'subscriptions/listen', protocolVersion: negotiated }
12401248
);
12411249
}
1242-
// Connectivity is checked here so the rejection is delivered as the
1243-
// returned promise (no setup or ack timer is started) — `_parkRequest`
1244-
// would otherwise throw NotConnected from inside the executor below
1245-
// after the timer is armed.
1246-
if (this.transport === undefined) {
1247-
throw new SdkError(SdkErrorCode.NotConnected, 'Not connected');
1248-
}
12491250

12501251
if (this._onParkedNotification === undefined) {
12511252
this._onParkedNotification = raw => this._listenFirstLook(raw);
@@ -1254,30 +1255,78 @@ export class Client extends Protocol<ClientContext> {
12541255
const requestAbort = new AbortController();
12551256
const transportKind = detectProbeTransportKind(this.transport);
12561257

1257-
let closed = false;
1258-
let parked!: ReturnType<typeof this._parkRequest>;
1259-
const close = async (): Promise<void> => {
1260-
if (closed) return;
1261-
closed = true;
1262-
this._listenState.delete(parked.messageId);
1263-
// Per-transport teardown: HTTP closes the request's SSE stream;
1264-
// stdio sends notifications/cancelled referencing the listen id.
1265-
if (transportKind === 'stdio') {
1258+
// Explicit `opening → open → closed` state machine. Every termination
1259+
// path — ack-arrives, ack-timeout, server-cancelled, user-close,
1260+
// transport-close, send-failure — funnels through the single `settle`
1261+
// below, which clears the ack timer, unparks, transitions state, and
1262+
// resolves/rejects the opening promise exactly once. The cancelled-
1263+
// before-ack / close-before-ack hangs are impossible by construction.
1264+
let state: 'opening' | 'open' | 'closed' = 'opening';
1265+
let parked: ReturnType<typeof this._parkRequest> | undefined;
1266+
let ackTimer: ReturnType<typeof setTimeout> | undefined;
1267+
let resolveOpening!: (honored: SubscriptionFilter) => void;
1268+
let rejectOpening!: (error: Error) => void;
1269+
const opening = new Promise<SubscriptionFilter>((resolve, reject) => {
1270+
resolveOpening = resolve;
1271+
rejectOpening = reject;
1272+
});
1273+
1274+
const settle = (outcome: { ack: SubscriptionFilter } | { error: Error } | 'closed'): void => {
1275+
if (state === 'closed') return;
1276+
const wasOpening = state === 'opening';
1277+
if (ackTimer !== undefined) {
1278+
clearTimeout(ackTimer);
1279+
ackTimer = undefined;
1280+
}
1281+
if (outcome !== 'closed' && 'ack' in outcome) {
1282+
// The single `opening → open` transition; an ack after close
1283+
// hits the `closed` guard above and is a no-op.
1284+
state = 'open';
1285+
resolveOpening(outcome.ack);
1286+
return;
1287+
}
1288+
state = 'closed';
1289+
if (parked !== undefined) {
1290+
this._listenState.delete(parked.messageId);
1291+
parked.unpark();
1292+
}
1293+
if (wasOpening) {
1294+
rejectOpening(
1295+
outcome === 'closed'
1296+
? new SdkError(SdkErrorCode.ConnectionClosed, 'subscriptions/listen closed before the server acknowledged')
1297+
: outcome.error
1298+
);
1299+
}
1300+
};
1301+
1302+
// Wire-level teardown for a locally-initiated close (user close or ack
1303+
// timeout): HTTP closes the request's SSE stream; stdio sends
1304+
// notifications/cancelled referencing the listen id. Not called when
1305+
// the server already terminated (error / server-cancelled).
1306+
const wireTeardown = async (): Promise<void> => {
1307+
const id = parked?.messageId;
1308+
if (transportKind === 'stdio' && id !== undefined) {
12661309
await this.transport
1267-
?.send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: parked.messageId } })
1310+
?.send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: id } })
12681311
.catch(() => {});
12691312
} else {
12701313
requestAbort.abort();
12711314
}
1272-
parked.unpark();
12731315
};
12741316

1275-
const honored = await new Promise<SubscriptionFilter>((resolve, reject) => {
1276-
const ackTimeout = options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC;
1277-
const timer = setTimeout(() => {
1278-
void close();
1279-
reject(new SdkError(SdkErrorCode.RequestTimeout, 'subscriptions/listen ack timed out', { timeout: ackTimeout }));
1280-
}, ackTimeout);
1317+
const close = async (): Promise<void> => {
1318+
if (state === 'closed') return;
1319+
settle('closed');
1320+
await wireTeardown();
1321+
};
1322+
1323+
const ackTimeout = options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC;
1324+
ackTimer = setTimeout(() => {
1325+
settle({ error: new SdkError(SdkErrorCode.RequestTimeout, 'subscriptions/listen ack timed out', { timeout: ackTimeout }) });
1326+
void wireTeardown().catch(() => {});
1327+
}, ackTimeout);
1328+
1329+
try {
12811330
// The per-subscription state is registered BEFORE the request is
12821331
// sent (`onBeforeSend`) so a synchronously-delivered ack (an
12831332
// in-process transport) cannot race the registration.
@@ -1296,31 +1345,30 @@ export class Client extends Protocol<ClientContext> {
12961345
{ requestSignal: requestAbort.signal },
12971346
messageId => {
12981347
this._listenState.set(messageId, {
1299-
onAck: honored => {
1300-
clearTimeout(timer);
1301-
resolve(honored);
1302-
},
1303-
onServerCancel: () => void close()
1348+
onAck: honored => settle({ ack: honored }),
1349+
onServerCancel: () => {
1350+
settle({ error: new Error('subscriptions/listen: server cancelled before acknowledging') });
1351+
}
13041352
});
13051353
}
13061354
);
1355+
// A synchronously-delivered termination during `send()` (an
1356+
// in-process transport) ran `settle()` before `parked` was
1357+
// assigned — unpark now so the handler does not leak.
1358+
if (state === 'closed') parked.unpark();
13071359
// Pre-ack capacity / params rejection arrives as a JSON-RPC error
1308-
// for the listen id — surfaced via terminated.
1360+
// for the listen id; transport close is delivered the same way.
13091361
void parked.terminated.then(({ reason, error }) => {
1310-
if (reason === 'error') {
1311-
clearTimeout(timer);
1312-
this._listenState.delete(parked.messageId);
1313-
closed = true;
1314-
reject(error ?? new Error('subscriptions/listen failed'));
1315-
}
1362+
if (reason === 'error') settle({ error: error ?? new Error('subscriptions/listen failed') });
13161363
});
13171364
parked.sent.catch(error => {
1318-
clearTimeout(timer);
1319-
void close();
1320-
reject(error instanceof Error ? error : new Error(String(error)));
1365+
settle({ error: error instanceof Error ? error : new Error(String(error)) });
13211366
});
1322-
});
1367+
} catch (error) {
1368+
settle({ error: error instanceof Error ? error : new Error(String(error)) });
1369+
}
13231370

1371+
const honored = await opening;
13241372
return { honoredFilter: honored, close };
13251373
}
13261374

@@ -1348,10 +1396,9 @@ export class Client extends Protocol<ClientContext> {
13481396
// Tolerant read: subscription id may be string or number; match by
13491397
// String() coercion against this connection's parked listen ids.
13501398
for (const [id, entry] of this._listenState) {
1351-
if (String(id) === String(subscriptionId) && entry.onAck !== undefined) {
1399+
if (String(id) === String(subscriptionId)) {
13521400
const honored = SubscriptionFilterSchema.safeParse(params?.notifications ?? {});
13531401
entry.onAck(honored.success ? honored.data : {});
1354-
entry.onAck = undefined;
13551402
return 'consumed';
13561403
}
13571404
}

packages/client/test/client/listen.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,83 @@ describe('Client.listen()', () => {
166166
await client.close();
167167
});
168168

169+
it('server cancels BEFORE the ack: listen() rejects immediately, no 60s hang', async () => {
170+
const [clientTx, serverTx] = InMemoryTransport.createLinkedPair();
171+
serverTx.onmessage = m => {
172+
const req = m as { id?: number | string; method?: string };
173+
if (req.method === 'server/discover' && req.id !== undefined) {
174+
void serverTx.send({
175+
jsonrpc: '2.0',
176+
id: req.id,
177+
result: {
178+
resultType: 'complete',
179+
supportedVersions: [MODERN],
180+
capabilities: {},
181+
serverInfo: { name: 's', version: '1' }
182+
}
183+
});
184+
}
185+
if (req.method === 'subscriptions/listen' && req.id !== undefined) {
186+
// Server cancels the listen id BEFORE sending the ack.
187+
void serverTx.send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: req.id } });
188+
}
189+
};
190+
await serverTx.start();
191+
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
192+
await client.connect(clientTx);
193+
const t0 = Date.now();
194+
const error = await client.listen({ toolsListChanged: true }).catch(e => e as Error);
195+
expect(error).toBeInstanceOf(Error);
196+
expect((error as Error).message).toContain('server cancelled before acknowledging');
197+
// Rejected promptly (well under the 60s ack timeout).
198+
expect(Date.now() - t0).toBeLessThan(1000);
199+
// No leaked _responseHandlers entry for the listen id.
200+
expect((client as unknown as { _responseHandlers: Map<unknown, unknown> })._responseHandlers.size).toBe(0);
201+
await client.close();
202+
});
203+
204+
it('an ack arriving AFTER the subscription was server-cancelled is a no-op', async () => {
205+
let listenId!: number | string;
206+
let send!: (m: JSONRPCMessage) => void;
207+
const { clientTx } = await scriptedModern((id, _f, s) => {
208+
listenId = id;
209+
send = s;
210+
});
211+
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
212+
await client.connect(clientTx);
213+
const sub = await client.listen({ toolsListChanged: true });
214+
// Server tears the open subscription down.
215+
send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: listenId } } as JSONRPCNotification);
216+
await flush();
217+
// A late duplicate ack must not throw or resurrect state.
218+
send({
219+
jsonrpc: '2.0',
220+
method: 'notifications/subscriptions/acknowledged',
221+
params: { _meta: { [SUBSCRIPTION_ID_META_KEY]: listenId }, notifications: {} }
222+
});
223+
await flush();
224+
await sub.close();
225+
await client.close();
226+
});
227+
228+
it('a synchronous transport.send throw does not leak a _responseHandlers entry', async () => {
229+
const { clientTx } = await scriptedModern();
230+
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
231+
await client.connect(clientTx);
232+
const handlers = (client as unknown as { _responseHandlers: Map<unknown, unknown> })._responseHandlers;
233+
const before = handlers.size;
234+
const realSend = clientTx.send.bind(clientTx);
235+
clientTx.send = () => {
236+
throw new Error('send blew up');
237+
};
238+
const error = await client.listen({ toolsListChanged: true }).catch(e => e as Error);
239+
expect((error as Error).message).toContain('send blew up');
240+
// The park primitive unregistered before rethrowing — no leak.
241+
expect(handlers.size).toBe(before);
242+
clientTx.send = realSend;
243+
await client.close();
244+
});
245+
169246
it('rejects with NotConnected (as a rejected promise, no setup) when no transport is connected', async () => {
170247
const { clientTx } = await scriptedModern();
171248
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });

0 commit comments

Comments
 (0)