@@ -17,14 +17,56 @@ export interface LiveGeneration {
1717
1818const 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+
2027function 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 */
2971export 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 ;
0 commit comments