@@ -523,6 +523,7 @@ export async function createStreamingResponse(
523523 const maxThinkingChars = DEFAULT_MAX_THINKING_CHARS
524524
525525 let requestAborted = false
526+ let streamCancelled = false
526527 const onRequestAbort = ( ) => {
527528 requestAborted = true
528529 timeoutController . abort ( )
@@ -549,8 +550,13 @@ export async function createStreamingResponse(
549550
550551 return new ReadableStream ( {
551552 async start ( controller ) {
553+ /** After `cancel()` the controller is closed and enqueue throws; the client is gone. */
554+ const enqueue = ( bytes : Uint8Array ) => {
555+ if ( ! streamCancelled ) controller . enqueue ( bytes )
556+ }
557+
552558 /** Flush headers promptly and keep silent blocks alive through idle-limited proxies. */
553- controller . enqueue ( encodeSSEComment ( 'keepalive' ) )
559+ enqueue ( encodeSSEComment ( 'keepalive' ) )
554560 keepaliveId = setInterval ( ( ) => {
555561 try {
556562 controller . enqueue ( encodeSSEComment ( 'keepalive' ) )
@@ -586,7 +592,7 @@ export async function createStreamingResponse(
586592 state . streamedSelectedOutputKeys . add ( options . selectedOutputKey )
587593 }
588594 const frame : ChatStreamChunkFrame = { blockId, chunk }
589- controller . enqueue ( encodeSSE ( frame ) )
595+ enqueue ( encodeSSE ( frame ) )
590596 state . processedOutputs . add ( blockId )
591597 }
592598
@@ -601,7 +607,7 @@ export async function createStreamingResponse(
601607 event : 'thinking' ,
602608 data : forwarded ,
603609 }
604- controller . enqueue ( encodeSSE ( frame ) )
610+ enqueue ( encodeSSE ( frame ) )
605611 }
606612
607613 const sendTool = (
@@ -619,7 +625,7 @@ export async function createStreamingResponse(
619625 name,
620626 ...( phase === 'end' && status ? { status } : { } ) ,
621627 }
622- controller . enqueue ( encodeSSE ( frame ) )
628+ enqueue ( encodeSSE ( frame ) )
623629 }
624630
625631 /**
@@ -667,7 +673,7 @@ export async function createStreamingResponse(
667673 emittedSinceReset = true
668674 } else {
669675 const frame : ChatStreamChunkFrame = { blockId, chunk : text }
670- controller . enqueue ( encodeSSE ( frame ) )
676+ enqueue ( encodeSSE ( frame ) )
671677 }
672678 }
673679
@@ -688,7 +694,7 @@ export async function createStreamingResponse(
688694 } else if ( sinkAnswerText && event . type === 'turn_end' ) {
689695 if ( event . turn === 'intermediate' && emittedSinceReset ) {
690696 const frame : ChatStreamChunkResetFrame = { blockId, event : 'chunk_reset' }
691- controller . enqueue ( encodeSSE ( frame ) )
697+ enqueue ( encodeSSE ( frame ) )
692698 // Re-arm separator bookkeeping so re-streamed text starts clean.
693699 emittedSinceReset = false
694700 state . processedOutputs . delete ( blockId )
@@ -729,7 +735,7 @@ export async function createStreamingResponse(
729735 blockId,
730736 error : getErrorMessage ( error , 'Stream reading error' ) ,
731737 }
732- controller . enqueue ( encodeSSE ( frame ) )
738+ enqueue ( encodeSSE ( frame ) )
733739 } finally {
734740 unsubscribe ?.( )
735741 }
@@ -746,7 +752,7 @@ export async function createStreamingResponse(
746752 const selectedOutputBlockId = outputBlockId ?? blockId
747753 state . completedBlockIds . add ( selectedOutputBlockId )
748754
749- if ( ! streamConfig . selectedOutputs ?. length ) {
755+ if ( streamCancelled || ! streamConfig . selectedOutputs ?. length ) {
750756 return
751757 }
752758
@@ -776,7 +782,7 @@ export async function createStreamingResponse(
776782 : output
777783
778784 for ( const descriptor of matchingOutputs ) {
779- if ( state . selectedOutputError ) {
785+ if ( streamCancelled || state . selectedOutputError ) {
780786 break
781787 }
782788 try {
@@ -837,7 +843,7 @@ export async function createStreamingResponse(
837843 event : 'output' ,
838844 data : hydratedOutput ,
839845 }
840- controller . enqueue ( encodeSSE ( frame ) )
846+ enqueue ( encodeSSE ( frame ) )
841847 state . selectedOutputBytes = nextSelectedOutputBytes
842848 state . streamedSelectedOutputKeys . add ( descriptor . key )
843849 state . processedOutputs . add ( selectedOutputBlockId )
@@ -869,7 +875,7 @@ export async function createStreamingResponse(
869875 blockId : selectedOutputBlockId ,
870876 error : errorMessage ,
871877 }
872- controller . enqueue ( encodeSSE ( frame ) )
878+ enqueue ( encodeSSE ( frame ) )
873879 break
874880 }
875881 }
@@ -908,7 +914,7 @@ export async function createStreamingResponse(
908914 await result . _streamingMetadata . loggingSession . markAsFailed ( timeoutErrorMessage )
909915 }
910916 const frame : ChatStreamErrorFrame = { event : 'error' , error : timeoutErrorMessage }
911- controller . enqueue ( encodeSSE ( frame ) )
917+ enqueue ( encodeSSE ( frame ) )
912918 } else if ( result . status === 'cancelled' && requestAborted ) {
913919 logger . info ( `[${ requestId } ] Streaming execution aborted by client disconnect` )
914920 if ( result . _streamingMetadata ?. loggingSession ) {
@@ -917,7 +923,7 @@ export async function createStreamingResponse(
917923 }
918924 // No `final` after abort; clients that already disconnected ignore these.
919925 const frame : ChatStreamErrorFrame = { event : 'error' , error : 'Client cancelled request' }
920- controller . enqueue ( encodeSSE ( frame ) )
926+ enqueue ( encodeSSE ( frame ) )
921927 } else {
922928 await completeLoggingSession ( result )
923929
@@ -953,18 +959,18 @@ export async function createStreamingResponse(
953959 ...( result . status === 'paused' && { status : 'paused' } ) ,
954960 } ,
955961 }
956- controller . enqueue ( encodeSSE ( frame ) )
962+ enqueue ( encodeSSE ( frame ) )
957963 }
958964 }
959965
960966 // Terminal marker: always follows success `final` or a single terminal `error`.
961- controller . enqueue ( encodeSSE ( '[DONE]' ) )
967+ enqueue ( encodeSSE ( '[DONE]' ) )
962968
963969 if ( executionId ) {
964970 await cleanupExecutionBase64Cache ( executionId )
965971 }
966972
967- controller . close ( )
973+ if ( ! streamCancelled ) controller . close ( )
968974 } catch ( error ) {
969975 logger . error (
970976 `[${ requestId } ] Stream error` ,
@@ -975,22 +981,23 @@ export async function createStreamingResponse(
975981 ? SELECTED_OUTPUT_TOO_LARGE_MESSAGE
976982 : getErrorMessage ( error , 'Stream processing error' )
977983 const frame : ChatStreamErrorFrame = { event : 'error' , error : errorMessage }
978- controller . enqueue ( encodeSSE ( frame ) )
984+ enqueue ( encodeSSE ( frame ) )
979985 // Same terminal rule as timeout/abort: one error, then [DONE], never `final`.
980- controller . enqueue ( encodeSSE ( '[DONE]' ) )
986+ enqueue ( encodeSSE ( '[DONE]' ) )
981987
982988 if ( executionId ) {
983989 await cleanupExecutionBase64Cache ( executionId )
984990 }
985991
986- controller . close ( )
992+ if ( ! streamCancelled ) controller . close ( )
987993 } finally {
988994 stopKeepalive ( )
989995 cleanupRequestAbort ( )
990996 timeoutController . cleanup ( )
991997 }
992998 } ,
993999 async cancel ( reason ) {
1000+ streamCancelled = true
9941001 logger . info (
9951002 `[${ requestId } ] Streaming response cancelled` ,
9961003 projectResolvedSecretDiagnosticError ( reason , undefined )
0 commit comments