Cancel the JS source when a ReadableStream pump is dropped mid-stream#6833
Cancel the JS source when a ReadableStream pump is dropped mid-stream#6833cnluzhang wants to merge 1 commit into
Conversation
|
All contributors have signed the CLA ✍️ ✅ |
|
I have read the CLA Document and I hereby sign the CLA |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: abd06069ce
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| } | ||
| } | ||
| KJ_CATCH(exception) { | ||
| pumpSettled = true; |
There was a problem hiding this comment.
Keep teardown cancellation enabled until error cancel runs
If the sink write/end path throws and the pump promise is then dropped while this catch block is suspended waiting for ioContext.run() to reacquire the isolate, pumpSettled has already disabled the new KJ_DEFER cleanup. Since ioContext.run() is cancellable before its lambda executes, reader->cancel() may never be called in that disconnect/error window, leaving the JS source running despite the pump being gone. Set this only after the cancellation has actually been run or use a separate flag that still lets teardown schedule cancellation until then.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Good catch — fixed in 019da73 by moving pumpSettled = true to after the error-path co_await ioContext.run(...), so it is set only once the cancel has actually run. If the coroutine is dropped during that window, pumpSettled stays false and the KJ_DEFER schedules the cancellation instead. cancel() is idempotent, so any overlap is harmless.
After the draining-read pump path replaced PumpToReader (commit c329332), dropping the pump coroutine while the JS ReadableStream source was suspended awaiting more data no longer cancelled the source. This regressed client-disconnect handling: when the HTTP layer drops the response-body pump, the underlying source's cancel() algorithm never ran and the stream kept producing data until natural completion. Restore the behavior by cancelling the source from pumpToImpl's teardown when the coroutine is dropped before settling. Because the isolate lock is unavailable during coroutine teardown, the cancellation is scheduled as a task, mirroring ~WritableStreamJsRpcAdapter. A pumpSettled flag suppresses the cancel on the normal-completion and error paths, which already finalize the stream themselves. Fixes cloudflare#6832
abd0606 to
019da73
Compare
Problem
Fixes #6832.
When a client disconnects from a streaming response whose body is an async
JavaScript
ReadableStream, the stream'scancel()algorithm is no longerinvoked. Because
cancel()never runs, the worker cannot observe thedisconnect: the source keeps producing data (enqueuing chunks until natural
completion) long after the client is gone.
This is a regression between
1.20260617.1and1.20260619.1, introduced bycommit c329332 ("Remove draining read standard streams autogate"):
1.20260617.1: stream source receivescancelon disconnect1.20260619.1: stream source runs to natural completion, nocancelRoot cause
c32933263removed the legacyPumpToReaderpath and leftReadableStreamJsController::pumpTo()with only theDrainingReader+pumpToImplcoroutine.When the client disconnects, the HTTP layer proactively drops the
response-body pump promise. The old
PumpToReadereffectively preservedcancellation on drop: tearing it down left a pending read continuation that,
on running, observed the reader was gone and canceled the controller.
The new
pumpToImplcoroutine instead just tears down its frame, whichdestroys the
DrainingReader.~DrainingReadercallsreleaseReader(maybeJs = kj::none)— with no isolate lock available it onlyclears the lock refs and never cancels the JS source. The coroutine only
canceled from its
KJ_CATCH(exception) path, which a quiet disconnect neverreaches.
Fix
Cancel the source from
pumpToImpl's teardown when the coroutine is droppedbefore it settles. Since the isolate lock is unavailable during coroutine
teardown, the cancellation is scheduled as a task — the same approach
~WritableStreamJsRpcAdapteralready uses for "object dropped without a cleanshutdown → schedule a JS cleanup under lock". A
pumpSettledflag suppressesthe deferred cancel on the normal-completion and error paths, which already
finalize the stream themselves (the error path cancels the reader directly).
Testing
New regression test
ReadableStream pumpTo cancels the JS source when dropped mid-stream: starts a JSReadableStreamwhose source suspends on its nextread, drops the pump, and asserts the source's
cancel()runs. Verified ittimes out with the fix reverted and passes with it, so it genuinely guards the
fixed behavior.
Also reproduced the original issue end-to-end against the reporter's repro:
the patched build reports
stream=cancelwhere1.20260619.1reportedended-normally.