豆豆友情提示:这是一个非官方 GitHub 代理镜像,主要用于网络测试或访问加速。请勿在此进行登录、注册或处理任何敏感信息。进行这些操作请务必访问官方网站 github.com。 Raw 内容也通过此代理提供。
Skip to content

Commit 398dc70

Browse files
fix: clear _timeoutInfo in _onclose() and scope .finally() abort controller cleanup (#1462)
Co-authored-by: Felix Weinberger <fweinberger@anthropic.com>
1 parent 93640d3 commit 398dc70

File tree

3 files changed

+170
-2
lines changed

3 files changed

+170
-2
lines changed

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ npm run typecheck # Type-check without emitting
2222
- **Files**: Lowercase with hyphens, test files with `.test.ts` suffix
2323
- **Imports**: ES module style, include `.js` extension, group imports logically
2424
- **Formatting**: 2-space indentation, semicolons required, single quotes preferred
25-
- **Testing**: Co-locate tests with source files, use descriptive test names
25+
- **Testing**: Co-locate tests with source files, use descriptive test names. Use `vi.useFakeTimers()` instead of real `setTimeout`/`await` delays in tests
2626
- **Comments**: JSDoc for public APIs, inline comments for complex logic
2727

2828
## Architecture Overview

src/shared/protocol.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,11 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
648648
this._taskProgressTokens.clear();
649649
this._pendingDebouncedNotifications.clear();
650650

651+
for (const info of this._timeoutInfo.values()) {
652+
clearTimeout(info.timeoutId);
653+
}
654+
this._timeoutInfo.clear();
655+
651656
// Abort all in-flight request handlers so they stop sending messages
652657
for (const controller of this._requestHandlerAbortControllers.values()) {
653658
controller.abort();
@@ -840,7 +845,11 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
840845
)
841846
.catch(error => this._onerror(new Error(`Failed to send response: ${error}`)))
842847
.finally(() => {
843-
this._requestHandlerAbortControllers.delete(request.id);
848+
// Only delete if the stored controller is still ours; after close()+connect(),
849+
// a new connection may have reused the same request ID with a different controller.
850+
if (this._requestHandlerAbortControllers.get(request.id) === abortController) {
851+
this._requestHandlerAbortControllers.delete(request.id);
852+
}
844853
});
845854
}
846855

test/shared/protocol.test.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5556,3 +5556,162 @@ describe('Error handling for missing resolvers', () => {
55565556
});
55575557
});
55585558
});
5559+
5560+
describe('_onclose cleanup', () => {
5561+
let protocol: Protocol<Request, Notification, Result>;
5562+
let transport: MockTransport;
5563+
let sendSpy: MockInstance;
5564+
5565+
beforeEach(() => {
5566+
vi.useFakeTimers();
5567+
transport = new MockTransport();
5568+
sendSpy = vi.spyOn(transport, 'send');
5569+
protocol = new (class extends Protocol<Request, Notification, Result> {
5570+
protected assertCapabilityForMethod(): void {}
5571+
protected assertNotificationCapability(): void {}
5572+
protected assertRequestHandlerCapability(): void {}
5573+
protected assertTaskCapability(): void {}
5574+
protected assertTaskHandlerCapability(): void {}
5575+
})();
5576+
});
5577+
5578+
afterEach(() => {
5579+
vi.useRealTimers();
5580+
});
5581+
5582+
test('should clear pending timeouts in _onclose to prevent spurious cancellation after reconnect', async () => {
5583+
await protocol.connect(transport);
5584+
5585+
// Start a request with a long timeout
5586+
const request = { method: 'example', params: {} };
5587+
const mockSchema = z.object({ result: z.string() });
5588+
5589+
const requestPromise = protocol
5590+
.request(request, mockSchema, {
5591+
timeout: 60000
5592+
})
5593+
.catch(() => {
5594+
/* expected ConnectionClosed error */
5595+
});
5596+
5597+
// Verify the request was sent
5598+
expect(sendSpy).toHaveBeenCalled();
5599+
5600+
// Spy on clearTimeout to verify it gets called during close
5601+
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
5602+
5603+
// Close the transport - this should clear timeouts
5604+
await transport.close();
5605+
5606+
// Verify clearTimeout was called (at least once for our timeout)
5607+
expect(clearTimeoutSpy).toHaveBeenCalled();
5608+
5609+
// Now reconnect with a new transport
5610+
const transport2 = new MockTransport();
5611+
const sendSpy2 = vi.spyOn(transport2, 'send');
5612+
await protocol.connect(transport2);
5613+
5614+
// Advance past the original timeout - if not cleared, this would fire the callback
5615+
await vi.advanceTimersByTimeAsync(60000);
5616+
5617+
// Verify no spurious cancellation notification was sent to the new transport
5618+
const cancellationCalls = sendSpy2.mock.calls.filter(call => {
5619+
const msg = call[0] as Record<string, unknown>;
5620+
return msg.method === 'notifications/cancelled';
5621+
});
5622+
expect(cancellationCalls).toHaveLength(0);
5623+
5624+
await transport2.close();
5625+
await requestPromise;
5626+
clearTimeoutSpy.mockRestore();
5627+
});
5628+
5629+
test('should not let stale .finally() delete a new connections abort controller after reconnect', async () => {
5630+
await protocol.connect(transport);
5631+
5632+
const TestRequestSchema = z.object({
5633+
method: z.literal('test/longRunning'),
5634+
params: z.optional(z.record(z.unknown()))
5635+
});
5636+
5637+
// Set up a handler with a deferred resolution we control
5638+
let resolveHandler!: () => void;
5639+
const handlerStarted = new Promise<void>(resolve => {
5640+
protocol.setRequestHandler(TestRequestSchema, async () => {
5641+
resolve(); // signal that handler has started
5642+
// Wait for explicit resolution
5643+
await new Promise<void>(r => {
5644+
resolveHandler = r;
5645+
});
5646+
return { _meta: {} } as Result;
5647+
});
5648+
});
5649+
5650+
// Simulate an incoming request with id=1 on the first connection
5651+
const requestId = 1;
5652+
transport.onmessage!({
5653+
jsonrpc: '2.0',
5654+
id: requestId,
5655+
method: 'test/longRunning',
5656+
params: {}
5657+
});
5658+
5659+
// Wait for handler to start
5660+
await handlerStarted;
5661+
5662+
// Close the connection (aborts the controller and clears the map)
5663+
await transport.close();
5664+
5665+
// Reconnect with a new transport
5666+
const transport2 = new MockTransport();
5667+
await protocol.connect(transport2);
5668+
5669+
// Set up a new handler for the second connection that we can verify cancellation on
5670+
let wasAborted = false;
5671+
let resolveHandler2!: () => void;
5672+
const handler2Started = new Promise<void>(resolve => {
5673+
protocol.setRequestHandler(TestRequestSchema, async (_request, extra) => {
5674+
resolve();
5675+
await new Promise<void>(r => {
5676+
resolveHandler2 = r;
5677+
});
5678+
wasAborted = extra.signal.aborted;
5679+
return { _meta: {} } as Result;
5680+
});
5681+
});
5682+
5683+
// Simulate same request id=1 on the new connection
5684+
transport2.onmessage!({
5685+
jsonrpc: '2.0',
5686+
id: requestId,
5687+
method: 'test/longRunning',
5688+
params: {}
5689+
});
5690+
5691+
await handler2Started;
5692+
5693+
// Resolve the OLD handler so its .finally() runs
5694+
resolveHandler();
5695+
// Flush microtasks so .finally() executes
5696+
await vi.advanceTimersByTimeAsync(0);
5697+
5698+
// Send cancellation for request id=1 on the new connection.
5699+
// If the old .finally() incorrectly deleted the new controller, this won't work.
5700+
transport2.onmessage!({
5701+
jsonrpc: '2.0',
5702+
method: 'notifications/cancelled',
5703+
params: {
5704+
requestId: requestId,
5705+
reason: 'test cancel'
5706+
}
5707+
});
5708+
5709+
// Resolve handler2 so it can check the abort signal
5710+
resolveHandler2();
5711+
await vi.advanceTimersByTimeAsync(0);
5712+
5713+
expect(wasAborted).toBe(true);
5714+
5715+
await transport2.close();
5716+
});
5717+
});

0 commit comments

Comments
 (0)