node:stream: lock the web stream in Readable.fromWeb at once and keep the lock
Problem
Readable.fromWeb(rs)over a JS-sourceReadableStreamleavesrs.locked === falseuntil the first read. A second consumer can callrs.getReader()and take chunks. The Readable then fails withERR_INVALID_STATE(Invalid state: ReadableStream is locked).- Cause:
ReadableFromWeb(src/js/internal/webstreams_adapters.ts:103) callsgetReader()in the first_read(), andreleaseLock()at'end'and on an error. Node takes the reader insidefromWeb()and never releases it.
Fix
ReadableFromWebtakes the reader in its constructor and keeps it.destroy()before the first read cancels through that reader.- The
node-fetchandundiciwrappers (responseBody: true, #42573) still take the reader on the first read. Only they set#streamnow, so#responseBodyand the unusedreaderparameters are deleted. - Stacked on #42584. On main an already aborted
signaldestroys the Readable insidesuper(), before the reader exists. #42584 adds the signal after the construction. With both, that call cancels the source and keepsrslocked, 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
ReadableStreamis 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 aReadableFromWeb. node-fetchandundiciwrap a Response body in aReadableFromWebdirectly. It stays unlocked until the first read, becauseres.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.
| stage | Node v26.3.0 | main | #42584 (base) | this PR |
|---|---|---|---|---|
after fromWeb() | true | false | false | true |
after destroy(err) before the first read | true | false | false | true |
after 'end' | true | false | false | true |
| after an error of the web stream | true | false | false | true |
after signal aborts later, before the first read | true | false | false | true |
after a signal that is already aborted | true | false, TypeError | false | true |
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:
- The aborted
signalcase above. The PR is now stacked on #42584, and two tests cover thesignalcases. - The assertions for
pipeThrough(),pipeTo()andcancel()checked only the class. They now check the message too, and a comment names the difference from Node. - 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 duringdestroy()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.