@@ -14,6 +14,8 @@ const MOCK_OPTIONS: CloudflareOptions = {
1414 dsn : 'https://public@dsn.ingest.sentry.io/1337' ,
1515} ;
1616
17+ const NODE_MAJOR_VERSION = parseInt ( process . versions . node . split ( '.' ) [ 0 ] ! ) ;
18+
1719function addDelayedWaitUntil ( context : ExecutionContext ) {
1820 context . waitUntil ( new Promise < void > ( resolve => setTimeout ( ( ) => resolve ( ) ) ) ) ;
1921}
@@ -44,7 +46,7 @@ describe('withSentry', () => {
4446 await wrapRequestHandler (
4547 { options : MOCK_OPTIONS , request : new Request ( 'https://example.com' ) , context } ,
4648 ( ) => new Response ( 'test' ) ,
47- ) ;
49+ ) . then ( response => response . text ( ) ) ;
4850
4951 expect ( waitUntilSpy ) . toHaveBeenCalledTimes ( 1 ) ;
5052 expect ( waitUntilSpy ) . toHaveBeenLastCalledWith ( expect . any ( Promise ) ) ;
@@ -111,11 +113,8 @@ describe('withSentry', () => {
111113
112114 await wrapRequestHandler ( { options : MOCK_OPTIONS , request : new Request ( 'https://example.com' ) , context } , ( ) => {
113115 addDelayedWaitUntil ( context ) ;
114- const response = new Response ( 'test' ) ;
115- // Add Content-Length to skip probing
116- response . headers . set ( 'content-length' , '4' ) ;
117- return response ;
118- } ) ;
116+ return new Response ( 'test' ) ;
117+ } ) . then ( response => response . text ( ) ) ;
119118 expect ( waitUntil ) . toBeCalled ( ) ;
120119 vi . advanceTimersToNextTimer ( ) . runAllTimers ( ) ;
121120 await Promise . all ( waits ) ;
@@ -336,7 +335,7 @@ describe('withSentry', () => {
336335 SentryCore . captureMessage ( 'sentry-trace' ) ;
337336 return new Response ( 'test' ) ;
338337 } ,
339- ) ;
338+ ) . then ( response => response . text ( ) ) ;
340339
341340 // Wait for async span end and transaction capture
342341 await new Promise ( resolve => setTimeout ( resolve , 50 ) ) ;
@@ -389,10 +388,8 @@ describe('flushAndDispose', () => {
389388 const flushSpy = vi . spyOn ( SentryCore . Client . prototype , 'flush' ) . mockResolvedValue ( true ) ;
390389
391390 await wrapRequestHandler ( { options : MOCK_OPTIONS , request : new Request ( 'https://example.com' ) , context } , ( ) => {
392- const response = new Response ( 'test' ) ;
393- response . headers . set ( 'content-length' , '4' ) ;
394- return response ;
395- } ) ;
391+ return new Response ( 'test' ) ;
392+ } ) . then ( response => response . text ( ) ) ;
396393
397394 // Wait for all waitUntil promises to resolve
398395 await Promise . all ( waits ) ;
@@ -518,6 +515,171 @@ describe('flushAndDispose', () => {
518515 disposeSpy . mockRestore ( ) ;
519516 } ) ;
520517
518+ // Regression tests for https://github.com/getsentry/sentry-javascript/issues/20409
519+ //
520+ // Pre-fix: streaming responses were observed via `body.tee()` + a long-running
521+ // `waitUntil(streamMonitor)`. Cloudflare caps `waitUntil` at ~30s after the
522+ // handler returns, so any stream taking longer than 30s to fully emit had the
523+ // monitor cancelled before `span.end()` / `flushAndDispose()` ran — silently
524+ // dropping the root `http.server` span.
525+ //
526+ // Post-fix: the body is piped through a passthrough `TransformStream`; the
527+ // `flush` (normal completion) and `cancel` (client disconnect) callbacks fire
528+ // while the response stream is still active (no waitUntil cap), so they can
529+ // safely end the span and register `flushAndDispose` via a fresh `waitUntil`
530+ // window. The contract guaranteed below: `waitUntil` is NOT called with any
531+ // long-running stream-observation promise — only with `flushAndDispose`, and
532+ // only after the response stream has finished (either by completion or cancel).
533+ describe ( 'regression #20409: streaming responses do not park stream observation in waitUntil' , ( ) => {
534+ test ( 'waitUntil is not called until streaming response is fully delivered' , async ( ) => {
535+ const waits : Promise < unknown > [ ] = [ ] ;
536+ const waitUntil = vi . fn ( ( promise : Promise < unknown > ) => waits . push ( promise ) ) ;
537+ const context = { waitUntil } as unknown as ExecutionContext ;
538+
539+ const flushSpy = vi . spyOn ( SentryCore . Client . prototype , 'flush' ) . mockResolvedValue ( true ) ;
540+ const disposeSpy = vi . spyOn ( CloudflareClient . prototype , 'dispose' ) ;
541+
542+ // Stream emits chunk1, then waits indefinitely until we open the gate
543+ // before emitting chunk2 + closing. Models a long-running upstream
544+ // (e.g. SSE / LLM streaming) whose body takes longer than the
545+ // handler-return time to fully drain.
546+ let releaseLastChunk ! : ( ) => void ;
547+ const lastChunkGate = new Promise < void > ( resolve => {
548+ releaseLastChunk = resolve ;
549+ } ) ;
550+
551+ const stream = new ReadableStream ( {
552+ async start ( controller ) {
553+ controller . enqueue ( new TextEncoder ( ) . encode ( 'chunk1' ) ) ;
554+ await lastChunkGate ;
555+ controller . enqueue ( new TextEncoder ( ) . encode ( 'chunk2' ) ) ;
556+ controller . close ( ) ;
557+ } ,
558+ } ) ;
559+
560+ const result = await wrapRequestHandler (
561+ { options : MOCK_OPTIONS , request : new Request ( 'https://example.com' ) , context } ,
562+ ( ) => new Response ( stream , { headers : { 'content-type' : 'text/event-stream' } } ) ,
563+ ) ;
564+
565+ // Handler has returned, but the source stream has NOT closed yet.
566+ // The pre-fix code would have already enqueued a long-running
567+ // `waitUntil(streamMonitor)` task at this point. The post-fix code
568+ // must not call waitUntil at all here.
569+ expect ( waitUntil ) . not . toHaveBeenCalled ( ) ;
570+
571+ // Drain the response — Cloudflare would do this when forwarding to the client.
572+ const reader = result . body ! . getReader ( ) ;
573+ await reader . read ( ) ; // chunk1
574+ // Source still hasn't closed — still no waitUntil.
575+ expect ( waitUntil ) . not . toHaveBeenCalled ( ) ;
576+
577+ releaseLastChunk ( ) ;
578+ await reader . read ( ) ; // chunk2
579+ await reader . read ( ) ; // done
580+ reader . releaseLock ( ) ;
581+
582+ // Stream completed → TransformStream `flush` fired → span ended →
583+ // `flushAndDispose(client)` queued via waitUntil exactly once.
584+ await Promise . all ( waits ) ;
585+ expect ( waitUntil ) . toHaveBeenCalledTimes ( 1 ) ;
586+ expect ( waitUntil ) . toHaveBeenLastCalledWith ( expect . any ( Promise ) ) ;
587+ expect ( flushSpy ) . toHaveBeenCalled ( ) ;
588+ expect ( disposeSpy ) . toHaveBeenCalled ( ) ;
589+
590+ flushSpy . mockRestore ( ) ;
591+ disposeSpy . mockRestore ( ) ;
592+ } ) ;
593+
594+ // Node 18's TransformStream does not invoke the transformer's `cancel` hook
595+ // when the downstream consumer cancels (WHATWG spec addition landed in Node 20).
596+ // Cloudflare Workers run modern V8 where this works, so we only skip the
597+ // test under Node 18.
598+ test . skipIf ( NODE_MAJOR_VERSION < 20 ) (
599+ 'waitUntil is called once and dispose runs when client cancels mid-stream' ,
600+ async ( ) => {
601+ const waits : Promise < unknown > [ ] = [ ] ;
602+ const waitUntil = vi . fn ( ( promise : Promise < unknown > ) => waits . push ( promise ) ) ;
603+ const context = { waitUntil } as unknown as ExecutionContext ;
604+
605+ const flushSpy = vi . spyOn ( SentryCore . Client . prototype , 'flush' ) . mockResolvedValue ( true ) ;
606+ const disposeSpy = vi . spyOn ( CloudflareClient . prototype , 'dispose' ) ;
607+
608+ // Stream emits one chunk and then never closes — models an upstream
609+ // that keeps emitting indefinitely. We then cancel the response from
610+ // the consumer side to model a client disconnect.
611+ let sourceCancelled = false ;
612+ const stream = new ReadableStream ( {
613+ start ( controller ) {
614+ controller . enqueue ( new TextEncoder ( ) . encode ( 'chunk1' ) ) ;
615+ // intentionally don't close
616+ } ,
617+ cancel ( ) {
618+ sourceCancelled = true ;
619+ } ,
620+ } ) ;
621+
622+ const result = await wrapRequestHandler (
623+ { options : MOCK_OPTIONS , request : new Request ( 'https://example.com' ) , context } ,
624+ ( ) => new Response ( stream , { headers : { 'content-type' : 'text/event-stream' } } ) ,
625+ ) ;
626+
627+ // Handler returned, source still open — no waitUntil yet.
628+ expect ( waitUntil ) . not . toHaveBeenCalled ( ) ;
629+
630+ const reader = result . body ! . getReader ( ) ;
631+ await reader . read ( ) ; // chunk1
632+ await reader . cancel ( 'client disconnected' ) ; // simulates client disconnect
633+ reader . releaseLock ( ) ;
634+
635+ // TransformStream `cancel` fired → span ended → flushAndDispose queued.
636+ await Promise . all ( waits ) ;
637+ expect ( waitUntil ) . toHaveBeenCalledTimes ( 1 ) ;
638+ expect ( waitUntil ) . toHaveBeenLastCalledWith ( expect . any ( Promise ) ) ;
639+ expect ( flushSpy ) . toHaveBeenCalled ( ) ;
640+ expect ( disposeSpy ) . toHaveBeenCalled ( ) ;
641+ // pipeThrough should also propagate the cancel upstream to the source.
642+ expect ( sourceCancelled ) . toBe ( true ) ;
643+
644+ flushSpy . mockRestore ( ) ;
645+ disposeSpy . mockRestore ( ) ;
646+ } ,
647+ ) ;
648+
649+ test ( 'waitUntil is called exactly once even if the response is consumed multiple times' , async ( ) => {
650+ // Sanity: no matter how the response is drained, the TransformStream's
651+ // flush callback must only end the span (and queue flushAndDispose) once.
652+ const waits : Promise < unknown > [ ] = [ ] ;
653+ const waitUntil = vi . fn ( ( promise : Promise < unknown > ) => waits . push ( promise ) ) ;
654+ const context = { waitUntil } as unknown as ExecutionContext ;
655+
656+ const flushSpy = vi . spyOn ( SentryCore . Client . prototype , 'flush' ) . mockResolvedValue ( true ) ;
657+ const disposeSpy = vi . spyOn ( CloudflareClient . prototype , 'dispose' ) ;
658+
659+ const stream = new ReadableStream ( {
660+ start ( controller ) {
661+ controller . enqueue ( new TextEncoder ( ) . encode ( 'a' ) ) ;
662+ controller . enqueue ( new TextEncoder ( ) . encode ( 'b' ) ) ;
663+ controller . close ( ) ;
664+ } ,
665+ } ) ;
666+
667+ const result = await wrapRequestHandler (
668+ { options : MOCK_OPTIONS , request : new Request ( 'https://example.com' ) , context } ,
669+ ( ) => new Response ( stream , { headers : { 'content-type' : 'text/event-stream' } } ) ,
670+ ) ;
671+
672+ const text = await result . text ( ) ;
673+ expect ( text ) . toBe ( 'ab' ) ;
674+
675+ await Promise . all ( waits ) ;
676+ expect ( waitUntil ) . toHaveBeenCalledTimes ( 1 ) ;
677+
678+ flushSpy . mockRestore ( ) ;
679+ disposeSpy . mockRestore ( ) ;
680+ } ) ;
681+ } ) ;
682+
521683 test ( 'dispose is NOT called for protocol upgrade responses (status 101)' , async ( ) => {
522684 const context = createMockExecutionContext ( ) ;
523685 const waits : Promise < unknown > [ ] = [ ] ;
0 commit comments