Skip to content

πŸ› Bug Report β€” Runtime APIs: Regression (1.20260617.1 β†’ 1.20260619.1): client disconnect no longer cancels async ReadableStream response bodyΒ #6832

Description

@cnluzhang

workerd@1.20260619.1 appears to regress client-disconnect handling for async JavaScript ReadableStream response bodies.

The most important part: in 1.20260619.1, Worker code does not appear to have any visible disconnect hook for this streaming response case. The JS ReadableStream source is not canceled, and request.signal is not aborted either.

Is this intended? If so, what is the supported way for a Worker to detect client disconnect on a streaming response in 1.20260619.1? If request.signal is meant to be that hook, it does not fire in this repro either.

Minimal repro:

https://github.com/cnluzhang/workerd-stream-cancel-repro

Run:

git clone git@github.com:cnluzhang/workerd-stream-cancel-repro.git
cd workerd-stream-cancel-repro
npm install
npm test

Observed output:

workerd@1.20260617.1: marker={"stream":"cancel","signal":"pending"} PASS
workerd@1.20260619.1: marker={"stream":"ended-normally","signal":"pending"} FAIL

Summary:
- workerd@1.20260617.1: stream=cancel, signal=pending
- workerd@1.20260619.1: stream=ended-normally, signal=pending
Error: expected stream cancel/enqueue-threw after disconnect; failed versions: workerd@1.20260619.1

The non-zero exit code is the failing regression assertion for 1.20260619.1; the Summary lines are the version comparison.

The repro worker returns an async stream:

return new Response(new ReadableStream({
  async start(controller) {
    controller.enqueue(new TextEncoder().encode("first\n"));
    for (let i = 0; i < 20; i += 1) {
      await new Promise((resolve) => setTimeout(resolve, 100));
      try {
        controller.enqueue(new TextEncoder().encode(`tick:${i}\n`));
      } catch {
        record.stream = "enqueue-threw";
        return;
      }
    }
    record.stream = "ended-normally";
  },
  cancel() {
    record.stream = "cancel";
  },
}));

The client reads the first chunk and calls res.socket.destroy(), so the repro is intentionally testing abrupt socket disconnect rather than graceful response consumption.

Observed behavior:

  • workerd@1.20260617.1: stream source is canceled; request.signal remains pending
  • workerd@1.20260619.1: stream source runs to natural completion; request.signal remains pending

The repro uses:

compatibilityDate = "2026-04-24"

This looks related to the ReadableStreamJsController::pumpTo() path changed by c32933263 ("Remove draining read standard streams autogate"). git log -S "class PumpToReader" points to that commit as the final removal of the old default PumpToReader path in the 1.20260619.1 release.

The suspected dropped-pump path is:

  • ReadableStreamJsController::pumpTo() now always uses DrainingReader / pumpToImpl.
  • pumpToImpl only calls reader->cancel(...) from its exception path.
  • In this repro, the stream is async and is usually suspended waiting for the next chunk when the client disconnects. There is no sink->write() exception to enter that catch path.
  • When the pump task is dropped while suspended, DrainingReader destruction releases the reader lock via ReadableLockImpl::releaseReader(maybeJs = nullptr).
  • That release path releases the lock only; it does not cancel or error the JS source.
  • As a result, later controller.enqueue() calls do not throw, the source is not canceled, and the stream runs to natural completion.

This matches the comments and structure in the current code. The pumpToImpl comment says:

"The pump loop is a kj coroutine. Dropping the returned kj::Promise drops the coroutine frame, which destroys the DrainingReader (releasing the stream lock) and the sink."

The releaseReader comment says:

"When maybeJs is nullptr, that means releaseReader was called when the reader is being deconstructed and not as the result of explicitly calling releaseLock... In that case, we don't want to change the lock state itself."

The source's cancel() callback is only invoked from pumpToImpl's exception path. The reader-deconstruction path (releaseReader with maybeJs == nullptr) only releases the lock; it does not cancel or error the JS source. The maybeJs branch can reject pending reads via cancelPendingReads, but that is still not the source cancel() algorithm. So on an async stream suspended at the time of client disconnect, nothing reaches the source's cancel(). Is releasing the lock without canceling or erroring the JS source intended for a client disconnect here?

This also means the old behavior does not look recoverable with a compatibility date or runtime flag: the old default PumpToReader path has been removed rather than hidden behind a compatibility setting.

Impact: long-running streaming responses can continue consuming CPU/wall time after the client disconnects. Since neither ReadableStream.cancel() nor request.signal fires, Worker code has no visible disconnect hook in this case. Also, because later controller.enqueue() calls do not throw after the reader is gone, an orphaned async stream can continue enqueueing data until it naturally finishes or the isolate is torn down.

Tested environment:

  • OS: Ubuntu 24.04.4 LTS
  • Arch: x86_64
  • Node.js: v24.16.0
  • npm: 11.17.0
  • workerd npm packages: 1.20260617.1 and 1.20260619.1

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions