Skip to content

Commit 45788cf

Browse files
Drain live AI streams until the body closes (#204)
readLiveResponse stopped at the result event and always cancelled the reader. The edge function closes the body a moment later, so that cancel could abort the fetch and drop the response. Keep reading until the stream ends, or for two seconds, and cancel only if it is still open. The answer is returned as soon as result arrives. Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Gáborjáni-Szabó Ádám <Programcode@users.noreply.github.com>
1 parent 508f243 commit 45788cf

2 files changed

Lines changed: 143 additions & 6 deletions

File tree

‎src/lib/ai/live.ts‎

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,56 @@ export interface LiveGeneration {
1717

1818
const DRAFT_INTERVAL_MS = 50;
1919

20+
/**
21+
* After `result`, keep reading only this long. The function closes the body
22+
* a moment later; waiting for that close avoids cancelling the fetch, and
23+
* the cap stops a body that never closes from holding the reader open.
24+
*/
25+
export const LIVE_STREAM_DRAIN_MS = 2_000;
26+
2027
function isPhase(value: unknown): value is LivePhase {
2128
return value === 'research' || value === 'writing' || value === 'checking';
2229
}
2330

31+
/**
32+
* Reads until the body ends, the caller aborts, the read fails, or `capMs`
33+
* elapses. True means the body ended, so the caller must not cancel it.
34+
*/
35+
async function drainUntilEnd(
36+
reader: ReadableStreamDefaultReader<Uint8Array>,
37+
signal: AbortSignal | undefined,
38+
capMs: number,
39+
): Promise<boolean> {
40+
let timer: ReturnType<typeof setTimeout> | undefined;
41+
const cap = new Promise<'cap'>((resolve) => {
42+
timer = setTimeout(() => resolve('cap'), capMs);
43+
});
44+
try {
45+
while (!signal?.aborted) {
46+
const read = reader.read().then(
47+
(chunk) => ({ kind: 'chunk' as const, chunk }),
48+
(): { kind: 'error' } => ({ kind: 'error' }),
49+
);
50+
const winner = await Promise.race([
51+
read,
52+
cap.then((): { kind: 'cap' } => ({ kind: 'cap' })),
53+
]);
54+
if (winner.kind !== 'chunk') return false;
55+
if (winner.chunk.done) return true;
56+
}
57+
return false;
58+
} finally {
59+
if (timer) clearTimeout(timer);
60+
}
61+
}
62+
2463
/**
2564
* Reads a successful `ai` response. A JSON body (a server from before live
2665
* generation, or a cached reply) is returned as is; an event stream is
2766
* followed to its `result`, whose status decides success or failure.
67+
* The answer is returned as soon as `result` arrives. Reading continues
68+
* until the body ends so the fetch is not cancelled in the gap before the
69+
* server closes it.
2870
*/
2971
export async function readLiveResponse<T>(
3072
response: Response,
@@ -82,21 +124,47 @@ export async function readLiveResponse<T>(
82124
reader.cancel(signal?.reason).catch((): undefined => undefined);
83125
};
84126
signal?.addEventListener('abort', stopReading, { once: true });
127+
128+
// Cancel only while the body is still open. Cancelling after `result`,
129+
// before the server closes, aborts the fetch and drops the body.
130+
let streamEnded = false;
131+
let draining = false;
132+
const release = () => {
133+
signal?.removeEventListener('abort', stopReading);
134+
if (timer) clearTimeout(timer);
135+
timer = null;
136+
if (!streamEnded) reader.cancel().catch((): undefined => undefined);
137+
};
138+
85139
try {
86140
while (!result) {
87141
if (signal?.aborted) throw new DOMException('The request was canceled.', 'AbortError');
88142
const { done, value } = await reader.read();
89143
if (signal?.aborted) throw new DOMException('The request was canceled.', 'AbortError');
90-
if (done) break;
144+
if (done) {
145+
streamEnded = true;
146+
break;
147+
}
91148
const parsed = consumeSSEChunk(buffer, decoder.decode(value, { stream: true }));
92149
buffer = parsed.buffer;
93150
handle(parsed.events);
94151
}
95152
if (!result) handle(consumeSSEChunk('', `${buffer}${decoder.decode()}\n\n`).events);
153+
154+
// Hand the answer back before the tail, so a slow close cannot delay the UI.
155+
if (result && !streamEnded) {
156+
if (timer) clearTimeout(timer);
157+
timer = null;
158+
draining = true;
159+
void drainUntilEnd(reader, signal, LIVE_STREAM_DRAIN_MS)
160+
.then((ended) => {
161+
streamEnded = ended;
162+
})
163+
.catch((): undefined => undefined)
164+
.finally(release);
165+
}
96166
} finally {
97-
signal?.removeEventListener('abort', stopReading);
98-
if (timer) clearTimeout(timer);
99-
reader.cancel().catch((): undefined => undefined);
167+
if (!draining) release();
100168
}
101169

102170
const final = result as { status: number; body: unknown } | null;

‎src/test/ai/live.test.ts‎

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { describe, expect, it, vi } from 'vitest';
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
22
import { FunctionsFetchError, FunctionsHttpError } from '@supabase/supabase-js';
3-
import { readLiveResponse } from '@/lib/ai/live';
3+
import { LIVE_STREAM_DRAIN_MS, readLiveResponse } from '@/lib/ai/live';
44

55
function eventStream(chunks: string[]): Response {
66
const encoder = new TextEncoder();
@@ -102,4 +102,73 @@ describe('readLiveResponse', () => {
102102
const error = await readLiveResponse(eventStream([event('delta', { text: '{"a": "b' })]), { onDraft: () => {} });
103103
expect(error).toBeInstanceOf(FunctionsFetchError);
104104
});
105+
106+
it('cancels immediately when the caller aborts', async () => {
107+
let cancelCount = 0;
108+
const encoder = new TextEncoder();
109+
const response = new Response(new ReadableStream({
110+
start(controller) {
111+
controller.enqueue(encoder.encode(event('delta', { text: 'partial' })));
112+
},
113+
// The body stays open, as a fetch can after the request signal aborts.
114+
pull: () => new Promise<void>(() => {}),
115+
cancel() { cancelCount += 1; },
116+
}), { headers: { 'Content-Type': 'text/event-stream' } });
117+
118+
const controller = new AbortController();
119+
const reading = readLiveResponse(response, { onDraft: () => {} }, controller.signal);
120+
await new Promise((resolve) => setTimeout(resolve, 20));
121+
expect(cancelCount).toBe(0);
122+
controller.abort();
123+
// The abort listener cancels before the read loop resumes, not after the drain cap.
124+
expect(cancelCount).toBe(1);
125+
await expect(reading).rejects.toMatchObject({ name: 'AbortError' });
126+
expect(cancelCount).toBe(1);
127+
});
128+
});
129+
130+
describe('draining the body after result', () => {
131+
afterEach(() => {
132+
vi.useRealTimers();
133+
});
134+
135+
function openResultStream(closeAfterMs: number | null, onCancel: () => void): Response {
136+
const encoder = new TextEncoder();
137+
return new Response(new ReadableStream({
138+
start(controller) {
139+
controller.enqueue(encoder.encode(event('result', { status: 200, body: { output: { ok: true } } })));
140+
if (closeAfterMs !== null) setTimeout(() => controller.close(), closeAfterMs);
141+
},
142+
cancel: onCancel,
143+
}), { headers: { 'Content-Type': 'text/event-stream' } });
144+
}
145+
146+
it('returns the result before the body closes and does not cancel once it ends', async () => {
147+
vi.useFakeTimers();
148+
let cancelled = false;
149+
const pending = readLiveResponse(openResultStream(400, () => { cancelled = true; }), { onDraft: () => {} });
150+
151+
await expect(pending).resolves.toEqual({ output: { ok: true } });
152+
expect(cancelled).toBe(false);
153+
154+
await vi.advanceTimersByTimeAsync(400);
155+
expect(cancelled).toBe(false);
156+
// The drain cap must not cancel a body that already ended.
157+
await vi.advanceTimersByTimeAsync(LIVE_STREAM_DRAIN_MS);
158+
expect(cancelled).toBe(false);
159+
});
160+
161+
it('cancels when the body stays open through the drain cap', async () => {
162+
vi.useFakeTimers();
163+
let cancelled = false;
164+
const pending = readLiveResponse(openResultStream(null, () => { cancelled = true; }), { onDraft: () => {} });
165+
166+
await expect(pending).resolves.toEqual({ output: { ok: true } });
167+
expect(cancelled).toBe(false);
168+
169+
await vi.advanceTimersByTimeAsync(LIVE_STREAM_DRAIN_MS - 1);
170+
expect(cancelled).toBe(false);
171+
await vi.advanceTimersByTimeAsync(1);
172+
expect(cancelled).toBe(true);
173+
});
105174
});

0 commit comments

Comments
 (0)