workerd@1.20260619.1 regresses client-disconnect handling for async JavaScript ReadableStream response bodies, and the regression is still present in the current release (1.20260701.1).
TL;DR: when the client disconnects mid-stream, the JS source's cancel() algorithm no longer runs. With enable_request_signal opted in, request.signal still aborts on every affected version β the disconnect demonstrably reaches the worker; only the stream-cancel wiring was lost. That points at dropped wiring rather than an intentional behavior change. (An earlier revision of this issue reported request.signal as not firing either; that observation was made without the enable_request_signal flag, which has no default enable date, so the signal never fires by design in that configuration.)
Minimal repro:
https://github.com/cnluzhang/workerd-stream-cancel-repro
git clone git@github.com:cnluzhang/workerd-stream-cancel-repro.git
cd workerd-stream-cancel-repro
npm install
npm test
The repro runs a 3-version x 2-topology matrix β direct (client socket terminates on the streaming worker's own HTTP listener) and proxied (a proxy worker forwards over a service binding, matching production topologies with an ingress hop) β with enable_request_signal opted in:
- workerd@1.20260617.1 [direct]: stream=cancel, signal=aborted
- workerd@1.20260617.1 [proxied]: stream=cancel, signal=aborted
- workerd@1.20260619.1 [direct]: stream=ended-normally, signal=aborted
- workerd@1.20260619.1 [proxied]: stream=ended-normally, signal=aborted
- workerd@1.20260701.1 [direct]: stream=ended-normally, signal=aborted
- workerd@1.20260701.1 [proxied]: stream=ended-normally, signal=aborted
signal=aborted in every cell shows the disconnect reaches the worker on all three versions and both topologies. Only the stream leg regressed: from 1.20260619.1 on, the source's cancel() never runs, later controller.enqueue() calls do not throw, and the async stream runs to natural completion after the client is gone.
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() (abortive close). That is the path kj's HttpServer reliably notices mid-response, dropping the response-body pump. Gentler client teardown may not be observed by the server at all (see the TODO(perf): If the client disconnects, should we cancel the response? comment in kj's HttpServer::Connection), so a non-repro with a different client teardown does not invalidate this report.
Root cause (confirmed by code inspection)
Commit c32933263 ("Remove draining read standard streams autogate") deleted the old default PumpToReader path. Its teardown was the only code that translated a dropped response-body pump into the stream's JS cancel() algorithm: when the kj promise holding the pump was dropped, the still-pending JS continuation found the WeakRef<PumpToReader> invalidated and called readable->getController().cancel(js, kj::none).
The replacement path has no equivalent:
ReadableStreamJsController::pumpTo() now always uses DrainingReader / pumpToImpl (src/workerd/api/streams/standard.c++).
pumpToImpl invokes reader->cancel() only from its error (KJ_CATCH) path. In this repro the stream is async and usually suspended awaiting the next chunk when the client disconnects, so there is no sink->write() exception to enter that path.
- When the pump coroutine is dropped while suspended,
~DrainingReader (src/workerd/api/streams/readable.c++) releases the reader lock via ReadableLockImpl::releaseReader(maybeJs = nullptr), which only releases the lock β it does not cancel or error the JS source.
As a result, the source's cancel() never runs, later enqueue() calls do not throw, and the stream runs to natural completion. The ENABLE_DRAINING_READ_ON_STANDARD_STREAMS autogate key was removed entirely, so the old behavior is not recoverable with a compatibility date or runtime flag.
Impact
- Long-running streaming responses keep consuming CPU/wall time after the client disconnects; an orphaned async source runs to natural completion (or, without pending work, stays suspended until isolate teardown).
- Without
enable_request_signal β which has no default enable date β Worker code has no visible disconnect hook at all for this case.
Proposed fix
Fix PR: #6833
From pumpToImpl's teardown, when the coroutine is dropped before settling, schedule the source cancel as a waitUntil task so IncomingRequest::drain() keeps the context open until the JS cancel() runs β the same scheduling WorkerEntrypoint uses for its disconnect abort task. The isolate lock is unavailable during coroutine teardown, and the pump promise can itself be owned by the IoContext's task sets (so the drop can happen during ~IoContext); an IoContext::WeakRef guard (the pattern documented at IoContext::getWeakRef()) makes that case a safe no-op. A pumpSettled flag suppresses the cancel on the normal-completion and error paths, and cancelling with kj::none (undefined reason) matches the pre-regression PumpToReader drop behavior. Includes regression tests for both the drop-mid-stream and clean-completion cases.
Tested environment
- OS: Ubuntu 24.04.4 LTS,
x86_64
- Node.js:
v24.16.0, npm: 11.17.0
- workerd npm packages:
1.20260617.1, 1.20260619.1, 1.20260701.1
workerd@1.20260619.1regresses client-disconnect handling for async JavaScriptReadableStreamresponse bodies, and the regression is still present in the current release (1.20260701.1).TL;DR: when the client disconnects mid-stream, the JS source's
cancel()algorithm no longer runs. Withenable_request_signalopted in,request.signalstill aborts on every affected version β the disconnect demonstrably reaches the worker; only the stream-cancel wiring was lost. That points at dropped wiring rather than an intentional behavior change. (An earlier revision of this issue reportedrequest.signalas not firing either; that observation was made without theenable_request_signalflag, which has no default enable date, so the signal never fires by design in that configuration.)Minimal repro:
https://github.com/cnluzhang/workerd-stream-cancel-repro
The repro runs a 3-version x 2-topology matrix β
direct(client socket terminates on the streaming worker's own HTTP listener) andproxied(a proxy worker forwards over a service binding, matching production topologies with an ingress hop) β withenable_request_signalopted in:signal=abortedin every cell shows the disconnect reaches the worker on all three versions and both topologies. Only the stream leg regressed: from1.20260619.1on, the source'scancel()never runs, latercontroller.enqueue()calls do not throw, and the async stream runs to natural completion after the client is gone.The repro worker returns an async stream:
The client reads the first chunk and calls
res.socket.destroy()(abortive close). That is the path kj'sHttpServerreliably notices mid-response, dropping the response-body pump. Gentler client teardown may not be observed by the server at all (see theTODO(perf): If the client disconnects, should we cancel the response?comment in kj'sHttpServer::Connection), so a non-repro with a different client teardown does not invalidate this report.Root cause (confirmed by code inspection)
Commit
c32933263("Remove draining read standard streams autogate") deleted the old defaultPumpToReaderpath. Its teardown was the only code that translated a dropped response-body pump into the stream's JScancel()algorithm: when the kj promise holding the pump was dropped, the still-pending JS continuation found theWeakRef<PumpToReader>invalidated and calledreadable->getController().cancel(js, kj::none).The replacement path has no equivalent:
ReadableStreamJsController::pumpTo()now always usesDrainingReader/pumpToImpl(src/workerd/api/streams/standard.c++).pumpToImplinvokesreader->cancel()only from its error (KJ_CATCH) path. In this repro the stream is async and usually suspended awaiting the next chunk when the client disconnects, so there is nosink->write()exception to enter that path.~DrainingReader(src/workerd/api/streams/readable.c++) releases the reader lock viaReadableLockImpl::releaseReader(maybeJs = nullptr), which only releases the lock β it does not cancel or error the JS source.As a result, the source's
cancel()never runs, laterenqueue()calls do not throw, and the stream runs to natural completion. TheENABLE_DRAINING_READ_ON_STANDARD_STREAMSautogate key was removed entirely, so the old behavior is not recoverable with a compatibility date or runtime flag.Impact
enable_request_signalβ which has no default enable date β Worker code has no visible disconnect hook at all for this case.Proposed fix
Fix PR: #6833
From
pumpToImpl's teardown, when the coroutine is dropped before settling, schedule the source cancel as a waitUntil task soIncomingRequest::drain()keeps the context open until the JScancel()runs β the same schedulingWorkerEntrypointuses for its disconnect abort task. The isolate lock is unavailable during coroutine teardown, and the pump promise can itself be owned by the IoContext's task sets (so the drop can happen during~IoContext); anIoContext::WeakRefguard (the pattern documented atIoContext::getWeakRef()) makes that case a safe no-op. ApumpSettledflag suppresses the cancel on the normal-completion and error paths, and cancelling withkj::none(undefined reason) matches the pre-regressionPumpToReaderdrop behavior. Includes regression tests for both the drop-mid-stream and clean-completion cases.Tested environment
x86_64v24.16.0, npm:11.17.01.20260617.1,1.20260619.1,1.20260701.1