Skip to content

Commit 4d6e540

Browse files
test(client): cover the listen() state machine's termination paths
Adds direct assertions for the paths the adversarial review traced by reading: transport-close before ack (rejects fast), transport-close while open (subscription closes; later sub.close() is a no-op), concurrent listens (independent ids/state), fake-timer ack timeout (RequestTimeout + wireTeardown), nothing dispatched into the per-listen machine after close() (late ack/cancel pass through unconsumed), unmatched ack reaches fallbackNotificationHandler, fresh-connect-without-close settles in-flight listen() with a clear ConnectionClosed error, and close() resets state even when transport.close() rejects.
1 parent 7ade3d6 commit 4d6e540

2 files changed

Lines changed: 208 additions & 6 deletions

File tree

packages/client/src/client/client.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -354,14 +354,14 @@ export class Client extends Protocol<ClientContext> {
354354
// Settle every live per-listen state machine before clearing the map:
355355
// a fresh connect (or close) on a connection whose prior transport
356356
// 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.
357+
// promise hanging forever. Each entry's settle() deletes only itself
358+
// (Map self-delete during iteration is well-defined).
359359
if (this._listenState.size > 0) {
360360
const reason = new SdkError(
361361
SdkErrorCode.ConnectionClosed,
362362
'subscriptions/listen: client reconnected or closed; subscription state from the previous connection was reset'
363363
);
364-
for (const entry of [...this._listenState.values()]) {
364+
for (const entry of this._listenState.values()) {
365365
entry.onConnectionReset(reason);
366366
}
367367
}
@@ -1485,8 +1485,8 @@ export class Client extends Protocol<ClientContext> {
14851485
}
14861486
// An ack referencing no parked listen on this connection is NOT
14871487
// consumed: pass it through so a stray/foreign ack reaches
1488-
// setNotificationHandler / fallthroughNotificationHandler instead
1489-
// of being silently swallowed.
1488+
// setNotificationHandler / fallbackNotificationHandler instead of
1489+
// being silently swallowed.
14901490
return undefined;
14911491
}
14921492
if (raw.method === 'notifications/cancelled') {

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

Lines changed: 203 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
*/
99
import type { JSONRPCMessage, JSONRPCNotification } from '@modelcontextprotocol/core';
1010
import { InMemoryTransport, LATEST_PROTOCOL_VERSION, SdkError, SdkErrorCode, SUBSCRIPTION_ID_META_KEY } from '@modelcontextprotocol/core';
11-
import { describe, expect, it } from 'vitest';
11+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
1212

1313
import { Client } from '../../src/client/client.js';
1414

@@ -48,6 +48,33 @@ async function scriptedModern(onListen?: (id: number | string, filter: unknown,
4848
return { clientTx, serverTx, written };
4949
}
5050

51+
/**
52+
* Like `scriptedModern` but does NOT auto-ack `subscriptions/listen`: the
53+
* test drives ack / cancel / transport-close itself.
54+
*/
55+
async function scriptedModernNoAck() {
56+
const [clientTx, serverTx] = InMemoryTransport.createLinkedPair();
57+
const written: JSONRPCMessage[] = [];
58+
serverTx.onmessage = message => {
59+
written.push(message);
60+
const req = message as { id?: number | string; method?: string };
61+
if (req.method === 'server/discover' && req.id !== undefined) {
62+
void serverTx.send({
63+
jsonrpc: '2.0',
64+
id: req.id,
65+
result: {
66+
resultType: 'complete',
67+
supportedVersions: [MODERN],
68+
capabilities: { tools: { listChanged: true }, prompts: { listChanged: true } },
69+
serverInfo: { name: 'scripted', version: '1' }
70+
}
71+
});
72+
}
73+
};
74+
await serverTx.start();
75+
return { clientTx, serverTx, written };
76+
}
77+
5178
describe('Client.listen()', () => {
5279
it('throws a typed steer on a legacy-era connection (no wire write)', async () => {
5380
const [clientTx, serverTx] = InMemoryTransport.createLinkedPair();
@@ -455,4 +482,179 @@ describe('Client.listen()', () => {
455482
expect((errors[0] as { code?: number }).code).toBe(-32_603);
456483
await client.close();
457484
});
485+
486+
it('transport closes BEFORE the ack: listen() rejects fast', async () => {
487+
const { clientTx, serverTx } = await scriptedModernNoAck();
488+
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
489+
await client.connect(clientTx);
490+
const t0 = Date.now();
491+
const pending = client.listen({ toolsListChanged: true });
492+
await flush();
493+
// Server-side transport closes before ever acking → Protocol._onclose
494+
// errors every parked _responseHandlers entry → settle({error}).
495+
await serverTx.close();
496+
const error = await pending.catch(e => e as Error);
497+
expect(error).toBeInstanceOf(Error);
498+
expect(Date.now() - t0).toBeLessThan(1000);
499+
expect((client as unknown as { _listenState: Map<unknown, unknown> })._listenState.size).toBe(0);
500+
expect((client as unknown as { _responseHandlers: Map<unknown, unknown> })._responseHandlers.size).toBe(0);
501+
});
502+
503+
it('transport closes WHILE the subscription is open: subscription transitions to closed; close() is a no-op', async () => {
504+
const { clientTx, serverTx, written } = await scriptedModern();
505+
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
506+
await client.connect(clientTx);
507+
const sub = await client.listen({ toolsListChanged: true });
508+
expect((client as unknown as { _listenState: Map<unknown, unknown> })._listenState.size).toBe(1);
509+
await serverTx.close();
510+
await flush();
511+
// Transport-close settled the per-listen machine; nothing leaks.
512+
expect((client as unknown as { _listenState: Map<unknown, unknown> })._listenState.size).toBe(0);
513+
// sub.close() after transport-close is a no-op (state already 'closed'):
514+
// no notifications/cancelled lands on a future connection.
515+
written.length = 0;
516+
await sub.close();
517+
expect(written.some(m => (m as { method?: string }).method === 'notifications/cancelled')).toBe(false);
518+
});
519+
520+
it('concurrent listens are independent (each ack resolves its own promise; closing one leaves the other open)', async () => {
521+
const ids: (number | string)[] = [];
522+
const { clientTx, written } = await scriptedModern(id => ids.push(id));
523+
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
524+
await client.connect(clientTx);
525+
const [a, b] = await Promise.all([client.listen({ toolsListChanged: true }), client.listen({ promptsListChanged: true })]);
526+
expect(a.honoredFilter).toEqual({ toolsListChanged: true });
527+
expect(b.honoredFilter).toEqual({ promptsListChanged: true });
528+
expect(ids).toHaveLength(2);
529+
expect(ids[0]).not.toBe(ids[1]);
530+
const listenState = (client as unknown as { _listenState: Map<unknown, unknown> })._listenState;
531+
expect(listenState.size).toBe(2);
532+
written.length = 0;
533+
await a.close();
534+
// Only `a`'s id is cancelled; `b` stays open.
535+
expect(written).toEqual([{ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: ids[0] } }]);
536+
expect(listenState.size).toBe(1);
537+
await b.close();
538+
expect(listenState.size).toBe(0);
539+
await client.close();
540+
});
541+
542+
it('after close(): nothing further dispatched into the per-listen machine; late ack passes through unconsumed', async () => {
543+
let listenId!: number | string;
544+
let send!: (m: JSONRPCMessage) => void;
545+
const { clientTx } = await scriptedModern((id, _f, s) => {
546+
listenId = id;
547+
send = s;
548+
});
549+
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
550+
await client.connect(clientTx);
551+
const sub = await client.listen({ toolsListChanged: true });
552+
await sub.close();
553+
// The per-listen entry is gone; a late server-side ack and a late
554+
// server-side cancel for this id are NOT consumed by the first-look
555+
// hook (no parked entry matches) and reach the fallback handler.
556+
const fallback: string[] = [];
557+
client.fallbackNotificationHandler = async n => {
558+
fallback.push(n.method);
559+
};
560+
send({
561+
jsonrpc: '2.0',
562+
method: 'notifications/subscriptions/acknowledged',
563+
params: { _meta: { [SUBSCRIPTION_ID_META_KEY]: listenId }, notifications: {} }
564+
});
565+
send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: listenId } } as JSONRPCNotification);
566+
await flush();
567+
expect(fallback).toContain('notifications/subscriptions/acknowledged');
568+
// The state machine stayed closed throughout (no leak, no resurrection).
569+
expect((client as unknown as { _listenState: Map<unknown, unknown> })._listenState.size).toBe(0);
570+
await client.close();
571+
});
572+
573+
it('an unmatched ack passes through to fallbackNotificationHandler (not silently swallowed)', async () => {
574+
const { clientTx, serverTx } = await scriptedModern();
575+
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
576+
const fallback: string[] = [];
577+
client.fallbackNotificationHandler = async n => {
578+
fallback.push(n.method);
579+
};
580+
await client.connect(clientTx);
581+
// One listen is active; a stray ack referencing a FOREIGN id must
582+
// reach the fallback handler instead of being silently swallowed.
583+
const sub = await client.listen({ toolsListChanged: true });
584+
await serverTx.send({
585+
jsonrpc: '2.0',
586+
method: 'notifications/subscriptions/acknowledged',
587+
params: { _meta: { [SUBSCRIPTION_ID_META_KEY]: 'foreign-id' }, notifications: {} }
588+
});
589+
await flush();
590+
expect(fallback).toEqual(['notifications/subscriptions/acknowledged']);
591+
await sub.close();
592+
await client.close();
593+
});
594+
595+
it('a fresh connect without an intervening close settles in-flight listen() from the prior connection', async () => {
596+
// Edge: prior transport never fires onclose; consumer calls connect()
597+
// again. The in-flight listen() promise from the old connection must
598+
// reject with a clear "client reconnected/closed" error rather than
599+
// hang on the (now-discarded) ack timer.
600+
const { clientTx } = await scriptedModernNoAck();
601+
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
602+
await client.connect(clientTx);
603+
const pending = client.listen({ toolsListChanged: true });
604+
await flush();
605+
const handlers = (client as unknown as { _responseHandlers: Map<unknown, unknown> })._responseHandlers;
606+
expect((client as unknown as { _listenState: Map<unknown, unknown> })._listenState.size).toBe(1);
607+
// Fresh connect on a new transport — _resetConnectionState runs.
608+
const { clientTx: clientTx2 } = await scriptedModern();
609+
await client.connect(clientTx2);
610+
const error = await pending.catch(e => e as Error);
611+
expect(error).toBeInstanceOf(SdkError);
612+
expect((error as SdkError).code).toBe(SdkErrorCode.ConnectionClosed);
613+
expect((error as SdkError).message).toContain('reconnected or closed');
614+
// No leaked parked handler from the old connection.
615+
expect(handlers.size).toBe(0);
616+
await client.close();
617+
});
618+
619+
it('close() resets per-connection state even when transport.close() rejects', async () => {
620+
const { clientTx } = await scriptedModern();
621+
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
622+
await client.connect(clientTx);
623+
expect(client.getNegotiatedProtocolVersion()).toBe(MODERN);
624+
clientTx.close = () => Promise.reject(new Error('close blew up'));
625+
await expect(client.close()).rejects.toThrow('close blew up');
626+
// Per-connection state was cleared regardless.
627+
expect(client.getNegotiatedProtocolVersion()).toBeUndefined();
628+
expect((client as unknown as { _listenState: Map<unknown, unknown> })._listenState.size).toBe(0);
629+
});
630+
});
631+
632+
describe('Client.listen() — ack timeout (fake timers)', () => {
633+
beforeEach(() => vi.useFakeTimers());
634+
afterEach(() => vi.useRealTimers());
635+
636+
it('ack timer firing rejects with RequestTimeout and tears the wire down', async () => {
637+
const { clientTx, written } = await scriptedModernNoAck();
638+
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
639+
const connecting = client.connect(clientTx);
640+
await vi.runAllTimersAsync();
641+
await connecting;
642+
const pending = client.listen({ toolsListChanged: true }, { timeout: 1000 });
643+
// Capture rejection to avoid an unhandled-rejection on the timer tick.
644+
const settled = pending.catch(e => e as SdkError);
645+
await vi.advanceTimersByTimeAsync(1000);
646+
const error = await settled;
647+
expect(error).toBeInstanceOf(SdkError);
648+
expect((error as SdkError).code).toBe(SdkErrorCode.RequestTimeout);
649+
// wireTeardown sent notifications/cancelled referencing the listen id.
650+
const listenId = (written.find(m => (m as { method?: string }).method === 'subscriptions/listen') as { id: number | string }).id;
651+
const cancelled = written.find(m => (m as JSONRPCNotification).method === 'notifications/cancelled');
652+
expect(cancelled).toMatchObject({ params: { requestId: listenId } });
653+
// No leaked state.
654+
expect((client as unknown as { _listenState: Map<unknown, unknown> })._listenState.size).toBe(0);
655+
expect((client as unknown as { _responseHandlers: Map<unknown, unknown> })._responseHandlers.size).toBe(0);
656+
// Restore real timers before close to avoid hanging on transport timers.
657+
vi.useRealTimers();
658+
await client.close();
659+
});
458660
});

0 commit comments

Comments
 (0)