Skip to content

Cancel the JS source when a ReadableStream pump is dropped mid-stream#6833

Open
cnluzhang wants to merge 1 commit into
cloudflare:mainfrom
cnluzhang:fix/6832-pump-cancel-on-drop
Open

Cancel the JS source when a ReadableStream pump is dropped mid-stream#6833
cnluzhang wants to merge 1 commit into
cloudflare:mainfrom
cnluzhang:fix/6832-pump-cancel-on-drop

Conversation

@cnluzhang

Copy link
Copy Markdown

Problem

Fixes #6832.

When a client disconnects from a streaming response whose body is an async
JavaScript ReadableStream, the stream's cancel() algorithm is no longer
invoked. Because cancel() never runs, the worker cannot observe the
disconnect: the source keeps producing data (enqueuing chunks until natural
completion) long after the client is gone.

This is a regression between 1.20260617.1 and 1.20260619.1, introduced by
commit c329332 ("Remove draining read standard streams autogate"):

  • 1.20260617.1: stream source receives cancel on disconnect
  • 1.20260619.1: stream source runs to natural completion, no cancel

Root cause

c32933263 removed the legacy PumpToReader path and left
ReadableStreamJsController::pumpTo() with only the DrainingReader +
pumpToImpl coroutine.

When the client disconnects, the HTTP layer proactively drops the
response-body pump promise. The old PumpToReader effectively preserved
cancellation on drop: tearing it down left a pending read continuation that,
on running, observed the reader was gone and canceled the controller.

The new pumpToImpl coroutine instead just tears down its frame, which
destroys the DrainingReader. ~DrainingReader calls
releaseReader(maybeJs = kj::none) — with no isolate lock available it only
clears the lock refs and never cancels the JS source. The coroutine only
canceled from its KJ_CATCH (exception) path, which a quiet disconnect never
reaches.

Fix

Cancel the source from pumpToImpl's teardown when the coroutine is dropped
before it settles. Since the isolate lock is unavailable during coroutine
teardown, the cancellation is scheduled as a task — the same approach
~WritableStreamJsRpcAdapter already uses for "object dropped without a clean
shutdown → schedule a JS cleanup under lock". A pumpSettled flag suppresses
the 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 JS ReadableStream whose source suspends on its next
read, drops the pump, and asserts the source's cancel() runs. Verified it
times out with the fix reverted and passes with it, so it genuinely guards the
fixed behavior.

# Streams unit tests, default and @all-autogates variants (all pass):
bazel test //src/workerd/api:streams-test@ \
  //src/workerd/api:streams/standard-test@ \
  //src/workerd/api:streams/draining-read-uaf-test@ \
  //src/workerd/api:streams/queue-test@ \
  //src/workerd/api:streams/internal-test@ \
  //src/workerd/api:system-streams-test@

bazel test //src/workerd/api:streams-test@all-autogates \
  //src/workerd/api:streams/standard-test@all-autogates \
  //src/workerd/api:streams/draining-read-uaf-test@all-autogates \
  //src/workerd/api:streams/queue-test@all-autogates \
  //src/workerd/api:streams/internal-test@all-autogates \
  //src/workerd/api:system-streams-test@all-autogates

# JS-level streams test under the newest compatibility date:
bazel test //src/workerd/api:streams/streams-test@all-compat-flags

# Clean under AddressSanitizer (no use-after-free across the
# drop -> scheduled-task -> cancel path):
bazel test //src/workerd/api:streams-test@ --config=asan

Also reproduced the original issue end-to-end against the reporter's repro:
the patched build reports stream=cancel where 1.20260619.1 reported
ended-normally.

@cnluzhang cnluzhang requested review from a team as code owners June 20, 2026 11:42
@github-actions

github-actions Bot commented Jun 20, 2026

Copy link
Copy Markdown

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@cnluzhang

Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

github-actions Bot added a commit that referenced this pull request Jun 20, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/workerd/api/streams/standard.c++ Outdated
}
}
KJ_CATCH(exception) {
pumpSettled = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@cnluzhang cnluzhang force-pushed the fix/6832-pump-cancel-on-drop branch from abd0606 to 019da73 Compare June 20, 2026 19:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

🐛 Bug Report — Runtime APIs: Regression (1.20260617.1 → 1.20260619.1): client disconnect no longer cancels async ReadableStream response body

1 participant