Skip to content

Commit 37276ea

Browse files
fix: cancel pending reconnection timeout on close()
Track the reconnection timeout ID and clear it when close() is called. This prevents reconnection attempts after the transport is closed, which was causing "Failed to reconnect SSE stream" errors on disconnect. Adds test case to verify reconnection is cancelled after close().
1 parent 52fb100 commit 37276ea

File tree

2 files changed

+57
-3
lines changed

2 files changed

+57
-3
lines changed

src/client/streamableHttp.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -921,6 +921,57 @@ describe('StreamableHTTPClientTransport', () => {
921921
expect(fetchMock.mock.calls[0][1]?.method).toBe('POST');
922922
});
923923

924+
it('should not attempt reconnection after close() is called', async () => {
925+
// ARRANGE
926+
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
927+
reconnectionOptions: {
928+
initialReconnectionDelay: 100,
929+
maxRetries: 3,
930+
maxReconnectionDelay: 1000,
931+
reconnectionDelayGrowFactor: 1
932+
}
933+
});
934+
935+
// Stream with priming event + notification (no response) that closes
936+
// This triggers reconnection scheduling
937+
const streamWithPriming = new ReadableStream({
938+
start(controller) {
939+
controller.enqueue(
940+
new TextEncoder().encode('id: event-123\ndata: {"jsonrpc":"2.0","method":"notifications/test","params":{}}\n\n')
941+
);
942+
controller.close();
943+
}
944+
});
945+
946+
const fetchMock = global.fetch as Mock;
947+
948+
// POST request returns streaming response
949+
fetchMock.mockResolvedValueOnce({
950+
ok: true,
951+
status: 200,
952+
headers: new Headers({ 'content-type': 'text/event-stream' }),
953+
body: streamWithPriming
954+
});
955+
956+
// ACT
957+
await transport.start();
958+
await transport.send({ jsonrpc: '2.0', method: 'test', id: '1', params: {} });
959+
960+
// Wait a tick to let stream processing complete and schedule reconnection
961+
await vi.advanceTimersByTimeAsync(10);
962+
963+
// Now close() - reconnection timeout is pending (scheduled for 100ms)
964+
await transport.close();
965+
966+
// Advance past reconnection delay
967+
await vi.advanceTimersByTimeAsync(200);
968+
969+
// ASSERT
970+
// Only 1 call: the initial POST. No reconnection attempts after close().
971+
expect(fetchMock).toHaveBeenCalledTimes(1);
972+
expect(fetchMock.mock.calls[0][1]?.method).toBe('POST');
973+
});
974+
924975
it('should not throw JSON parse error on priming events with empty data', async () => {
925976
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'));
926977

src/client/streamableHttp.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ export class StreamableHTTPClientTransport implements Transport {
136136
private _hasCompletedAuthFlow = false; // Circuit breaker: detect auth success followed by immediate 401
137137
private _lastUpscopingHeader?: string; // Track last upscoping header to prevent infinite upscoping.
138138
private _serverRetryMs?: number; // Server-provided retry delay from SSE retry field
139+
private _reconnectionTimeout?: ReturnType<typeof setTimeout>;
139140

140141
onclose?: () => void;
141142
onerror?: (error: Error) => void;
@@ -287,7 +288,7 @@ export class StreamableHTTPClientTransport implements Transport {
287288
const delay = this._getNextReconnectionDelay(attemptCount);
288289

289290
// Schedule the reconnection
290-
setTimeout(() => {
291+
this._reconnectionTimeout = setTimeout(() => {
291292
// Use the last event ID to resume where we left off
292293
this._startOrAuthSse(options).catch(error => {
293294
this.onerror?.(new Error(`Failed to reconnect SSE stream: ${error instanceof Error ? error.message : String(error)}`));
@@ -439,9 +440,11 @@ export class StreamableHTTPClientTransport implements Transport {
439440
}
440441

441442
async close(): Promise<void> {
442-
// Abort any pending requests
443+
if (this._reconnectionTimeout) {
444+
clearTimeout(this._reconnectionTimeout);
445+
this._reconnectionTimeout = undefined;
446+
}
443447
this._abortController?.abort();
444-
445448
this.onclose?.();
446449
}
447450

0 commit comments

Comments
 (0)