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
workerd@1.20260619.1appears to regress client-disconnect handling for async JavaScriptReadableStreamresponse 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 JSReadableStreamsource is not canceled, andrequest.signalis 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? Ifrequest.signalis meant to be that hook, it does not fire in this repro either.Minimal repro:
https://github.com/cnluzhang/workerd-stream-cancel-repro
Run:
Observed output:
The non-zero exit code is the failing regression assertion for
1.20260619.1; theSummarylines are the version comparison.The repro worker returns an async stream:
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.signalremains pendingworkerd@1.20260619.1: stream source runs to natural completion;request.signalremains pendingThe repro uses:
compatibilityDate = "2026-04-24"This looks related to the
ReadableStreamJsController::pumpTo()path changed byc32933263("Remove draining read standard streams autogate").git log -S "class PumpToReader"points to that commit as the final removal of the old defaultPumpToReaderpath in the1.20260619.1release.The suspected dropped-pump path is:
ReadableStreamJsController::pumpTo()now always usesDrainingReader/pumpToImpl.pumpToImplonly callsreader->cancel(...)from its exception path.sink->write()exception to enter that catch path.DrainingReaderdestruction releases the reader lock viaReadableLockImpl::releaseReader(maybeJs = nullptr).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
pumpToImplcomment says:The
releaseReadercomment says:The source's
cancel()callback is only invoked frompumpToImpl's exception path. The reader-deconstruction path (releaseReaderwithmaybeJs == nullptr) only releases the lock; it does not cancel or error the JS source. ThemaybeJsbranch can reject pending reads viacancelPendingReads, but that is still not the sourcecancel()algorithm. So on an async stream suspended at the time of client disconnect, nothing reaches the source'scancel(). 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
PumpToReaderpath 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()norrequest.signalfires, Worker code has no visible disconnect hook in this case. Also, because latercontroller.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:
x86_64v24.16.011.17.01.20260617.1and1.20260619.1