node:stream: lock the web stream in Readable.fromWeb at once and keep the lock

RO
1mo ago
+109-17

Problem

  • Readable.fromWeb(rs) over a JS-source ReadableStream leaves rs.locked === false until the first read. A second consumer can call rs.getReader() and take chunks. The Readable then fails with ERR_INVALID_STATE (Invalid state: ReadableStream is locked).
  • Cause: ReadableFromWeb (src/js/internal/webstreams_adapters.ts:103) calls getReader() in the first _read(), and releaseLock() at 'end' and on an error. Node takes the reader inside fromWeb() and never releases it.

Fix

  • ReadableFromWeb takes the reader in its constructor and keeps it. destroy() before the first read cancels through that reader.
  • The node-fetch and undici wrappers (responseBody: true, #42573) still take the reader on the first read. Only they set #stream now, so #responseBody and the unused reader parameters are deleted.
  • Stacked on #42584. On main an already aborted signal destroys the Readable inside super(), before the reader exists. #42584 adds the signal after the construction. With both, that call cancels the source and keeps rs locked, as Node does.
  • Verified: test/js/node/stream/node-stream.test.js (6 new tests, all fail on the base branch), node-fetch.test.js, undici.test.ts, streams.test.js. Self-reviewed: 3 concerns raised, 3 addressed (Notes).

Background

  • A ReadableStream is locked while a reader exists. A locked stream refuses every other consumer.
  • A stream with a native source (Blob, file, fetch body) goes to a plain Readable, which locks it at once. Every other stream gets a ReadableFromWeb.
  • node-fetch and undici wrap a Response body in a ReadableFromWeb directly. It stays unlocked until the first read, because res.text() reads the web stream itself.
Notes

Base. This PR is stacked on #42584. That branch is the base, so this change cannot land before it.

Origin. No issue exists. Found during the verification of #42584.

Reproduction.

import { Readable } from "node:stream";
let n = 0;
const rs = new ReadableStream({ pull(c) { c.enqueue(new Uint8Array([++n])); if (n === 4) c.close(); } });
const r = Readable.fromWeb(rs);
console.log("locked after fromWeb:", rs.locked);
let second;
try { second = rs.getReader(); console.log("second getReader(): ok"); } catch (e) { console.log("second getReader():", e.code, e.message); }
r.on("error", e => console.log("Readable error:", e.code ?? e.name, e.message));
r.on("data", d => console.log("Readable data:", [...d]));
r.on("close", () => console.log("Readable close"));
if (second) console.log("second reader got:", [...(await second.read()).value]);

Node v26.3.0 and this PR:

locked after fromWeb: true
second getReader(): ERR_INVALID_STATE Invalid state: ReadableStream is locked
Readable data: [ 1 ]
Readable data: [ 2 ]
Readable data: [ 3 ]
Readable data: [ 4 ]
Readable close

Bun 1.4.3-canary (367d939d9):

locked after fromWeb: false
second getReader(): ok
second reader got: [ 1 ]
Readable error: ERR_INVALID_STATE Invalid state: ReadableStream is locked
Readable close

rs.locked per stage, JS source. Bun's native path (Blob, Bun.file(), fetch body) already gives true in each row.

stageNode v26.3.0main#42584 (base)this PR
after fromWeb()truefalsefalsetrue
after destroy(err) before the first readtruefalsefalsetrue
after 'end'truefalsefalsetrue
after an error of the web streamtruefalsefalsetrue
after signal aborts later, before the first readtruefalsefalsetrue
after a signal that is already abortedtruefalse, TypeErrorfalsetrue

destroy(err) before the first read still passes err to the cancel() hook of the source, as Node does. The assertions of the first new test and of the two signal tests also pass as plain node:assert scripts under Node v26.3.0.

Already aborted signal. On main Readable.fromWeb(rs, { signal: AbortSignal.abort() }) emits a TypeError and never cancels the source. The abort runs _destroy() inside super(), before the private fields exist. #42584 fixes that: it adds the signal after the construction. On main alone, this change would take the reader after that failed destroy, and rs would stay locked with no way to cancel it. That is why this PR is stacked on #42584. On the base the call cancels the source with the AbortError but leaves rs.locked === false. With this change rs.locked === true, as Node gives. Two of the new tests cover a signal that aborts before and after fromWeb().

Response bodies. A releaseLock() at the end of a Response body has no effect on the Response: bodyUsed, the body methods (Body already used) and clone() give the same results with and without it. With a reader taken in the constructor for every stream (a test mutation), 31 tests of node-fetch.test.js and undici.test.ts fail. So those suites cover the lazy path.

Other streams on this path. response.textStream() and type: "direct" streams also get a ReadableFromWeb. For a direct stream, getReader() does not call pull(). That still happens on the first read. For textStream() the native source now starts inside fromWeb(). Both deliver the same chunks as before.

Invalid options. The constructor takes the reader after super() has checked the options. A throw there (highWaterMark: -1, signal: {}) leaves rs unlocked, as on main. Node takes the reader first and leaves rs locked. #42584 asserts the unlocked result for an invalid signal.

Test assertions. pipeThrough(), pipeTo() and cancel() on a locked stream throw the TypeError of the streams spec in Bun. Node adds code: 'ERR_INVALID_STATE'. The test asserts the class and the word locked in the message for these three calls.

Self-review. Concerns raised:

  1. The aborted signal case above. The PR is now stacked on #42584, and two tests cover the signal cases.
  2. The assertions for pipeThrough(), pipeTo() and cancel() checked only the class. They now check the message too, and a comment names the difference from Node.
  3. No assertion covered a second Readable.fromWeb(rs) on the same stream. Added.

Not in this PR.

  • Node also watches reader.closed. An error of the web stream destroys the Readable at once, even when no read is pending. Bun reports the error on the next _read(). That changes the time of the error, so it is a separate change: #43748, stacked on this branch.
  • A cancel() hook that throws during destroy() gives an 'error' event in Bun (callback(error ?? cancelError), from #32863). Node ignores the rejection and emits only 'close'.

Suites run on the debug build: node-stream.test.js (137 pass with the tests of the base), node-fetch.test.js and undici.test.ts (57 pass), streams.test.js and regression 09555 (617 pass), body.test.ts, fetch-backpressure.test.ts, and these Node tests: test-stream-readable-from-web-termination.js, test-readable-from-web-enqueue-then-close.js, test-whatwg-webstreams-adapters-to-*.js, test-webstreams-adapters-*.js, test-webstreams-duplex-fromweb-writev-unhandled-rejection.js, test-stream-duplex.js, test-stream-duplex-from.js, test-stream-compose.js, test-stream-pipeline.js, test-stream-finished.js, test-stream-consumers.js, test-stream-readable-to-web*.js, test-webstreams-compose.js, test-webstreams-finished.js, test-webstreams-pipeline.js. The new tests pass 10 times in a row. One test of body.test.ts (string body consumption does not leak > Response.json) exceeds its 5 s limit on the debug build. It does not load node:stream.

tsc -p src/js/tsconfig.json, oxlint and prettier pass.