Skip to content

Commit a09fa56

Browse files
committed
fix(streaming): stop writing workflow stream frames after the client cancels
1 parent 3aad201 commit a09fa56

2 files changed

Lines changed: 57 additions & 19 deletions

File tree

‎apps/sim/lib/workflows/streaming/streaming.test.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1417,6 +1417,37 @@ describe('createStreamingResponse agent-events-v1', () => {
14171417
expect(events).toContainEqual({ event: 'error', error: 'Client cancelled request' })
14181418
})
14191419

1420+
it('stops writing frames once the client cancels the response stream', async () => {
1421+
let finishExecution!: (result: ExecutionResult) => void
1422+
let completeBlock!: (blockId: string, output: unknown) => Promise<void>
1423+
const stream = await createStreamingResponse({
1424+
requestId: 'request-client-cancel',
1425+
streamConfig: { selectedOutputs: ['block_answer'], includeFileBase64: false },
1426+
executeFn: async ({ onBlockComplete }) => {
1427+
completeBlock = onBlockComplete
1428+
return await new Promise<ExecutionResult>((resolve) => {
1429+
finishExecution = resolve
1430+
})
1431+
},
1432+
})
1433+
const reader = stream.getReader()
1434+
await reader.read()
1435+
await reader.cancel()
1436+
1437+
await expect(completeBlock('block', { answer: 'late answer' })).resolves.toBeUndefined()
1438+
finishExecution({ success: false, status: 'cancelled', output: {}, logs: [] })
1439+
1440+
await vi.waitFor(() =>
1441+
expect(workflowStreamingLogger.info).toHaveBeenCalledWith(
1442+
'[request-client-cancel] Streaming execution aborted by client disconnect'
1443+
)
1444+
)
1445+
expect(workflowStreamingLogger.error).not.toHaveBeenCalledWith(
1446+
'[request-client-cancel] Stream error',
1447+
expect.anything()
1448+
)
1449+
})
1450+
14201451
it('thinking never enters streamedChunks / log content rewrite', async () => {
14211452
const headers = new Headers({
14221453
'x-sim-stream-protocol': 'agent-events-v1',

‎apps/sim/lib/workflows/streaming/streaming.ts‎

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)