Guard cleanup of currentStreamController after abort

This commit is contained in:
2026-05-07 14:16:53 +02:00
parent 9823761e03
commit 140198177b
20 changed files with 101 additions and 7964 deletions
+96
View File
@@ -569,6 +569,102 @@ describe('OllamaClient', () => {
});
});
describe('cancelStream race condition', () => {
it('should not clear new stream controller when old stream finally block executes', async () => {
// Race condition scenario:
// 1. Stream A starts → controllerA assigned to currentStreamController
// 2. Stream B starts → controllerB replaces controllerA
// 3. Stream A's fetch rejects (abort) → finally block runs
// 4. Guard (this.currentStreamController === controllerA) is false
// 5. controllerB survives
// First fetch call: pending promise with manually triggerable abort
let fireFirstAbort: (() => void) | undefined;
mockFetch.mockImplementationOnce((_url, options?: any) => {
const signal = options?.signal;
return new Promise<never>((_, reject) => {
if (signal?.aborted) {
reject(new DOMException('The operation was aborted.', 'AbortError'));
return;
}
signal?.addEventListener(
'abort',
() => {
reject(new DOMException('The operation was aborted.', 'AbortError'));
},
{ once: true }
);
// Capture abort trigger for manual control
fireFirstAbort = () => {
signal?.dispatchEvent(new CustomEvent('abort'));
};
});
});
// Second fetch call: also pending, so controller stays assigned
// We don't need it to complete - just need to verify controller survives abort
mockFetch.mockImplementationOnce((_url, options?: any) => {
const signal = options?.signal;
return new Promise<never>((_, reject) => {
if (signal?.aborted) {
reject(new DOMException('The operation was aborted.', 'AbortError'));
return;
}
signal?.addEventListener(
'abort',
() => {
reject(new DOMException('The operation was aborted.', 'AbortError'));
},
{ once: true }
);
});
});
// Start first stream and consume it (triggers fetch + controller assignment)
const stream1 = await client.streamChat(mockMessages, mockTools);
const consumeFirst = async () => {
for await (const _ of stream1) {
/* consume */
}
};
const firstPromise = consumeFirst();
// Wait for fetch to be triggered (controller should be set)
await new Promise((resolve) => setTimeout(resolve, 10));
const firstController = client['currentStreamController'];
expect(firstController).not.toBeNull();
// Start second stream and consume it (triggers fetch + replaces controller)
const stream2 = await client.streamChat(mockMessages, mockTools);
const consumeSecond = async () => {
for await (const _ of stream2) {
/* consume */
}
};
const secondPromise = consumeSecond();
// Wait for second stream fetch to trigger and assign its controller
await new Promise((resolve) => setTimeout(resolve, 10));
const secondController = client['currentStreamController'];
expect(secondController).not.toBeNull();
expect(secondController).not.toBe(firstController);
// Defer abort to next microtask so Jest associates rejection with expectation
// Then immediately await the rejection
queueMicrotask(() => fireFirstAbort?.());
await expect(firstPromise).rejects.toThrow('The operation was aborted.');
// Second stream's controller should still be intact
// (not cleared by first stream's finally block due to guard)
expect(client['currentStreamController']).toBe(secondController);
// Clean up second stream so it doesn't leak
secondController?.abort();
await secondPromise.catch(() => {});
});
});
describe('streamChat final buffer parsing', () => {
it('should parse final buffer content when stream ends with partial line', async () => {
const streamData = [