Skip to content

ReadableStream without start() calls pull() synchronously inside the constructor #7694

Open

Description

@mikepage

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

  1. jasnell commented on Oct 11, 2026

    @jasnell
    Collaborator

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions