From 66cbdca29e55e52dc985bb9c1437ffc503543272 Mon Sep 17 00:00:00 2001 From: Fran Dias Date: Fri, 13 Mar 2026 22:51:53 -0400 Subject: [PATCH 1/3] capture abort signal before await to prevent race condition --- packages/typescript/ai-client/src/chat-client.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/typescript/ai-client/src/chat-client.ts b/packages/typescript/ai-client/src/chat-client.ts index 3cb0f4f7..b80acfd0 100644 --- a/packages/typescript/ai-client/src/chat-client.ts +++ b/packages/typescript/ai-client/src/chat-client.ts @@ -430,6 +430,10 @@ export class ChatClient { this.setStatus('submitted') this.setError(undefined) this.abortController = new AbortController() + // Capture the signal immediately so that a concurrent stop() or + // sendMessage() that reassigns this.abortController cannot cause + // connect() to receive a stale or null signal. + const signal = this.abortController.signal // Reset pending tool executions for the new stream this.pendingToolExecutions.clear() let streamCompletedSuccessfully = false @@ -456,7 +460,7 @@ export class ChatClient { const stream = this.connection.connect( messages, mergedBody, - this.abortController.signal, + signal, ) await this.processStream(stream) From b5b776e1a5d8e3a47ed0d4ff01a7f6d4bc5c8ae9 Mon Sep 17 00:00:00 2001 From: Fran Dias Date: Fri, 13 Mar 2026 23:24:42 -0400 Subject: [PATCH 2/3] add unit tests for abort signal race condition fix Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ai-client/tests/chat-client-abort.test.ts | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/packages/typescript/ai-client/tests/chat-client-abort.test.ts b/packages/typescript/ai-client/tests/chat-client-abort.test.ts index 2adffb1c..c9f3eb36 100644 --- a/packages/typescript/ai-client/tests/chat-client-abort.test.ts +++ b/packages/typescript/ai-client/tests/chat-client-abort.test.ts @@ -306,4 +306,100 @@ describe('ChatClient - Abort Signal Handling', () => { // Each should be a different signal instance expect(abortSignals[0]).not.toBe(abortSignals[1]) }) + + it('should pass the original signal to connect() even if stop() is called during onResponse', async () => { + let signalPassedToConnect: AbortSignal | undefined + + const adapter: ConnectionAdapter = { + // eslint-disable-next-line @typescript-eslint/require-await + async *connect(_messages, _data, abortSignal) { + signalPassedToConnect = abortSignal + yield { + type: 'RUN_FINISHED', + runId: 'run-1', + model: 'test', + timestamp: Date.now(), + finishReason: 'stop', + } + }, + } + + const client = new ChatClient({ + connection: adapter, + onResponse: () => { + // Simulate a concurrent stop() during the onResponse callback, + // which sets this.abortController to null. Without the fix, + // the code would dereference this.abortController.signal after + // this point and crash with a null reference. + client.stop() + }, + }) + + await client.append({ + id: 'user-1', + role: 'user', + parts: [{ type: 'text', content: 'Hello' }], + createdAt: new Date(), + }) + + // The signal should still be a valid AbortSignal instance + // (captured before the await), not undefined/null + expect(signalPassedToConnect).toBeInstanceOf(AbortSignal) + }) + + it('should pass the original signal to connect() even if sendMessage() reassigns abortController during onResponse', async () => { + const signalsPassedToConnect: Array = [] + + const adapter: ConnectionAdapter = { + // eslint-disable-next-line @typescript-eslint/require-await + async *connect(_messages, _data, abortSignal) { + if (abortSignal) { + signalsPassedToConnect.push(abortSignal) + } + yield { + type: 'RUN_FINISHED', + runId: 'run-1', + model: 'test', + timestamp: Date.now(), + finishReason: 'stop', + } + }, + } + + let firstCall = true + const client = new ChatClient({ + connection: adapter, + onResponse: () => { + if (firstCall) { + firstCall = false + // Trigger a second message during onResponse callback. + // This queues a new streamResponse that would create a new + // AbortController, potentially overwriting this.abortController + // before the first connect() call reads the signal. + client.append({ + id: 'user-2', + role: 'user', + parts: [{ type: 'text', content: 'Second message' }], + createdAt: new Date(), + }) + } + }, + }) + + await client.append({ + id: 'user-1', + role: 'user', + parts: [{ type: 'text', content: 'Hello' }], + createdAt: new Date(), + }) + + // Wait for the queued second stream to complete + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Both calls should have received valid, distinct AbortSignal instances + expect(signalsPassedToConnect.length).toBe(2) + expect(signalsPassedToConnect[0]).toBeInstanceOf(AbortSignal) + expect(signalsPassedToConnect[1]).toBeInstanceOf(AbortSignal) + expect(signalsPassedToConnect[0]).not.toBe(signalsPassedToConnect[1]) + }) }) From 2a4fbebc393252f249b910a2bbf28effe4bbe83b Mon Sep 17 00:00:00 2001 From: Fran Dias Date: Fri, 13 Mar 2026 23:37:25 -0400 Subject: [PATCH 3/3] replace setTimeout with deterministic promise await in test Capture the nested append() promise and await it directly instead of relying on a fixed 50ms setTimeout. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../typescript/ai-client/tests/chat-client-abort.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/typescript/ai-client/tests/chat-client-abort.test.ts b/packages/typescript/ai-client/tests/chat-client-abort.test.ts index c9f3eb36..48b694b4 100644 --- a/packages/typescript/ai-client/tests/chat-client-abort.test.ts +++ b/packages/typescript/ai-client/tests/chat-client-abort.test.ts @@ -349,6 +349,7 @@ describe('ChatClient - Abort Signal Handling', () => { it('should pass the original signal to connect() even if sendMessage() reassigns abortController during onResponse', async () => { const signalsPassedToConnect: Array = [] + let secondAppendPromise: Promise | undefined const adapter: ConnectionAdapter = { // eslint-disable-next-line @typescript-eslint/require-await @@ -376,7 +377,7 @@ describe('ChatClient - Abort Signal Handling', () => { // This queues a new streamResponse that would create a new // AbortController, potentially overwriting this.abortController // before the first connect() call reads the signal. - client.append({ + secondAppendPromise = client.append({ id: 'user-2', role: 'user', parts: [{ type: 'text', content: 'Second message' }], @@ -393,8 +394,8 @@ describe('ChatClient - Abort Signal Handling', () => { createdAt: new Date(), }) - // Wait for the queued second stream to complete - await new Promise((resolve) => setTimeout(resolve, 50)) + // Deterministically wait for the queued second stream + await secondAppendPromise // Both calls should have received valid, distinct AbortSignal instances expect(signalsPassedToConnect.length).toBe(2)