Skip to content

Commit 4ee8087

Browse files
matantsachclaude
andcommitted
fix: allow transport restart after close()
close() aborts _abortController but never resets it to undefined, so start() sees a truthy guard and throws "already started." Same issue in SSEClientTransport with _eventSource. Also reset _endpoint in SSE to avoid stale endpoint URLs after restart. Fixes #1641 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4a7cdf4 commit 4ee8087

4 files changed

Lines changed: 69 additions & 0 deletions

File tree

packages/client/src/client/sse.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,10 @@ export class SSEClientTransport implements Transport {
239239

240240
async close(): Promise<void> {
241241
this._abortController?.abort();
242+
this._abortController = undefined;
242243
this._eventSource?.close();
244+
this._eventSource = undefined;
245+
this._endpoint = undefined;
243246
this.onclose?.();
244247
}
245248

packages/client/src/client/streamableHttp.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,7 @@ export class StreamableHTTPClientTransport implements Transport {
452452
this._reconnectionTimeout = undefined;
453453
}
454454
this._abortController?.abort();
455+
this._abortController = undefined;
455456
this.onclose?.();
456457
}
457458

packages/client/test/client/sse.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1528,4 +1528,32 @@ describe('SSEClientTransport', () => {
15281528
expect(globalFetchSpy).not.toHaveBeenCalled();
15291529
});
15301530
});
1531+
1532+
describe('Transport lifecycle', () => {
1533+
it('should allow start() after close() for transport reuse', async () => {
1534+
transport = new SSEClientTransport(resourceBaseUrl);
1535+
await transport.start();
1536+
1537+
// close() the transport
1538+
await transport.close();
1539+
1540+
// Second start() should succeed — not throw "already started"
1541+
await transport.start();
1542+
1543+
// Verify transport is functional by sending a message
1544+
const message: JSONRPCMessage = {
1545+
jsonrpc: '2.0',
1546+
method: 'test',
1547+
params: {},
1548+
id: 'test-1'
1549+
};
1550+
1551+
await transport.send(message);
1552+
1553+
// Wait for request processing
1554+
await new Promise(resolve => setTimeout(resolve, 50));
1555+
1556+
expect(lastServerRequest.method).toBe('POST');
1557+
});
1558+
});
15311559
});

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1659,4 +1659,41 @@ describe('StreamableHTTPClientTransport', () => {
16591659
});
16601660
});
16611661
});
1662+
1663+
describe('Transport lifecycle', () => {
1664+
it('should allow start() after close() for transport reuse', async () => {
1665+
const fetchMock = globalThis.fetch as Mock;
1666+
1667+
// First start()
1668+
await transport.start();
1669+
1670+
// close() the transport
1671+
await transport.close();
1672+
1673+
// Second start() should succeed — not throw "already started"
1674+
await transport.start();
1675+
1676+
// Verify transport is functional by sending a message
1677+
fetchMock.mockResolvedValueOnce({
1678+
ok: true,
1679+
status: 200,
1680+
headers: new Headers({ 'content-type': 'application/json' }),
1681+
json: async () => ({
1682+
jsonrpc: '2.0',
1683+
result: {},
1684+
id: 'test-1'
1685+
})
1686+
});
1687+
1688+
const message: JSONRPCMessage = {
1689+
jsonrpc: '2.0',
1690+
method: 'test',
1691+
params: {},
1692+
id: 'test-1'
1693+
};
1694+
1695+
await transport.send(message);
1696+
expect(fetchMock).toHaveBeenCalledTimes(1);
1697+
});
1698+
});
16621699
});

0 commit comments

Comments
 (0)