Guard cleanup of currentStreamController after abort
This commit is contained in:
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user