Repository navigation
ReadableStream without start() calls pull() synchronously inside the constructor #7694
Description
Which Cloudflare product(s) does this pertain to?
Workers Runtime (workerd).
What versions & operating system are you using?
workerd 1.20261011.1 and 1.20260730.1, compatibility date 2026-10-11 and
2026-07-30, no compatibility flags. macOS 26.7.1 (arm64). Same result at
compatibility date 2022-11-30, with streams_enable_constructors at
2021-11-03, and with pedantic_wpt.
Please provide a minimal reproduction
async function order(source) {
const log = [];
new ReadableStream({ ...source, pull: () => void log.push("pull") });
log.push("constructor returned");
await Promise.resolve();
log.push("next microtask");
return log.join(" > ");
}
// A wrapper that locks its source only while pulling, as std's
// concatReadableStreams() does. Cancelled right after construction.
async function cancelRightAway() {
const source = new ReadableStream({
start(controller) {
controller.enqueue("a");
},
});
const wrapper = new ReadableStream({
async pull(controller) {
const reader = source.getReader();
const { value } = await reader.read();
controller.enqueue(value);
reader.releaseLock();
},
cancel(reason) {
return source.cancel(reason);
},
});
try {
await wrapper.cancel("stop");
return "cancel() resolved";
} catch (e) {
return `cancel() rejected: ${e.name}: ${e.message}`;
}
}
export default {
async fetch() {
return Response.json({
withoutStart: await order({}),
withStart: await order({ start() {} }),
cancelRightAway: await cancelRightAway(),
});
},
};Describe the bug
If the underlying source has no start(), the stream calls pull() before
the constructor returns. With an empty start() {} it waits for a microtask,
as the spec says.
Code that runs right after new ReadableStream() therefore sees a stream
that is already pulling. In the repro, pull() has locked source by the
time wrapper.cancel() runs, so source.cancel() rejects with "This
ReadableStream is currently locked to a reader."
In
maybeRunAlgorithm()
a missing algorithm calls onSuccess(js) synchronously, and for start that
is pullIfNeeded(). maybeRunAlgorithmAsync() right below already handles
this case with a resolved promise.
Hit by Deno std's concatReadableStreams(). Its test
"concatReadableStreams cancels all streams when concatenated stream is
cancelled" gets one cancel reason instead of two: the first stream is locked
by the in-flight pull, so its cancel() rejects.
Expected
{"withoutStart":"constructor returned > pull > next microtask","withStart":"constructor returned > pull > next microtask","cancelRightAway":"cancel() resolved"}As in Chromium 152 and Node 24.18. In
SetUpReadableStreamDefaultController
(and SetUpReadableByteStreamController),
a missing start makes the start algorithm return undefined. pull() runs
only "upon fulfillment of startPromise", so never before the constructor
returns.
Actual
{"withoutStart":"pull > constructor returned > next microtask","withStart":"constructor returned > pull > next microtask","cancelRightAway":"cancel() rejected: TypeError: This ReadableStream is currently locked to a reader."}🤖 Generated with Claude Code
Activity
jasnell commentedon Oct 11, 2026
Yep, known divergence. Should be fixed in the upcoming TS streams impl (worth a test) and will require a compat flag with the legacy impl.