Skip to content

Commit b0d2ee7

Browse files
committed
review: guard close() cancels per entry, register reconnection entry before scheduler runs
- close() wraps each user-supplied cancel in try/catch, routing throws to onerror so shutdown neither skips remaining cancels nor rejects (the existing throwing-cancel test now pins the contained behavior). - The pending-reconnection entry is registered before the scheduler is invoked, so a synchronously-firing custom scheduler deregisters it instead of leaving a stale entry until close(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Eqc26ABfbhTimyUUszsUxL
1 parent abcf521 commit b0d2ee7

2 files changed

Lines changed: 32 additions & 19 deletions

File tree

packages/client/src/client/streamableHttp.ts

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -697,7 +697,13 @@ export class StreamableHTTPClientTransport implements Transport {
697697
// Calculate next delay based on current attempt count
698698
const delay = this._getNextReconnectionDelay(attemptCount);
699699

700-
let cancelEntry: () => void;
700+
// The chain's registry entry. Registered before the scheduler is
701+
// invoked so a synchronously-firing custom scheduler still
702+
// deregisters it (otherwise the entry would be added after the fact
703+
// and linger until close()); the real cancel behavior is filled in
704+
// once the scheduler returns.
705+
let cancelImpl: (() => void) | undefined;
706+
const cancelEntry = (): void => cancelImpl?.();
701707
const reconnect = (): void => {
702708
this._pendingReconnections.delete(cancelEntry);
703709
// Honour BOTH the transport-wide abort and the per-request abort
@@ -721,32 +727,26 @@ export class StreamableHTTPClientTransport implements Transport {
721727
});
722728
};
723729

730+
// Track every pending reconnection — concurrent chains (the standby
731+
// GET stream plus resumed per-request streams) each park a timer
732+
// here, and close() must cancel all of them, not just the latest.
733+
this._pendingReconnections.add(cancelEntry);
724734
try {
725735
if (this._reconnectionScheduler) {
726736
const cancel = this._reconnectionScheduler(reconnect, delay, attemptCount);
727-
cancelEntry =
728-
typeof cancel === 'function'
729-
? cancel
730-
: () => {
731-
// No-op: the custom scheduler provided no cancel
732-
// function; tracked so `reconnect` can still
733-
// deregister the chain's pending entry.
734-
};
737+
cancelImpl = typeof cancel === 'function' ? cancel : undefined;
735738
} else {
736739
const handle = setTimeout(reconnect, delay);
737-
cancelEntry = () => clearTimeout(handle);
740+
cancelImpl = () => clearTimeout(handle);
738741
}
739742
} catch (error) {
740743
// A throwing custom scheduler means no reconnection is pending —
741-
// the stream is definitively gone. Settle the caller here (the
742-
// only route that can still do it), then rethrow for reporting.
744+
// deregister the entry and settle the caller here (the only route
745+
// that can still do it), then rethrow for reporting.
746+
this._pendingReconnections.delete(cancelEntry);
743747
options.onRequestStreamEnd?.();
744748
throw error;
745749
}
746-
// Track every pending reconnection — concurrent chains (the standby
747-
// GET stream plus resumed per-request streams) each park a timer
748-
// here, and close() must cancel all of them, not just the latest.
749-
this._pendingReconnections.add(cancelEntry);
750750
}
751751

752752
/**
@@ -1007,8 +1007,15 @@ export class StreamableHTTPClientTransport implements Transport {
10071007
try {
10081008
// Cancel EVERY pending reconnection — concurrent chains (standby
10091009
// GET + resumed per-request streams) can each have a parked timer.
1010+
// Per-entry guard: a throwing user-supplied cancel (from a custom
1011+
// ReconnectionScheduler) must not skip the remaining cancels or
1012+
// escape the shutdown path.
10101013
for (const cancel of this._pendingReconnections) {
1011-
cancel();
1014+
try {
1015+
cancel();
1016+
} catch (error) {
1017+
this.onerror?.(error instanceof Error ? error : new Error(String(error)));
1018+
}
10121019
}
10131020
} finally {
10141021
this._pendingReconnections.clear();

packages/client/test/client/streamableHttp.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3085,7 +3085,10 @@ describe('StreamableHTTPClientTransport', () => {
30853085
expect(onerror).not.toHaveBeenCalled();
30863086
});
30873087

3088-
it('still aborts and fires onclose if the cancel function throws', async () => {
3088+
it('contains a throwing cancel function: close() completes, aborts, and fires onclose', async () => {
3089+
// A user-supplied cancel that throws must not escape the shutdown
3090+
// path or skip the remaining pending cancels — it is surfaced via
3091+
// onerror instead.
30893092
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
30903093
reconnectionOptions,
30913094
reconnectionScheduler: () => () => {
@@ -3094,12 +3097,15 @@ describe('StreamableHTTPClientTransport', () => {
30943097
});
30953098
const onclose = vi.fn();
30963099
transport.onclose = onclose;
3100+
const onerror = vi.fn();
3101+
transport.onerror = onerror;
30973102

30983103
await transport.start();
30993104
triggerReconnection(transport);
31003105
const abortController = transport['_abortController'];
31013106

3102-
await expect(transport.close()).rejects.toThrow('cancel failed');
3107+
await expect(transport.close()).resolves.toBeUndefined();
3108+
expect(onerror).toHaveBeenCalledWith(expect.objectContaining({ message: 'cancel failed' }));
31033109
expect(abortController?.signal.aborted).toBe(true);
31043110
expect(onclose).toHaveBeenCalledTimes(1);
31053111
});

0 commit comments

Comments
 (0)