node:stream: Readable.fromWeb emits an AbortError when the web stream fails with a falsy value
RO
11d ago+89+89+01234567890123456789-5-5-0123456789
Problem
Readable.fromWeb(stream)emits'close'with neither'end'nor'error'when the web stream fails with a falsy value:controller.error(null), orcontroller.abort(null)on afetch()body. A promise that waits for'end'or'error'never settles. Node v26.3.0 emits'error'with anAbortError(code: "ABORT_ERR").- Cause:
destroy(null)means no error.ReadableFromWeb.#handleError(src/js/internal/webstreams_adapters.ts:85) calledthis.destroy(error), andread()(src/js/internal/streams/native-readable.ts) gave a falsy pull failure toerrorOrDestroy().
Fix
#handleErrorcallsdestroyer(this, error), as Node's adapter does.destroyer()replaces a falsy error with anAbortError.read()sends a rejected or throwingpull()tofailPull(), which passesreason || $makeAbortError()on. A truthy reason is unchanged.- Verified: 8 new cases in
test/js/node/stream/node-stream.test.jsand 6 intest/js/web/fetch/fetch-abort-stream-body.test.ts. All 14 fail on main. Other suites: see Notes.
Background
Readable.fromWebreturns aNativeReadablefor a stream with a native source (a fetch body, a file), and aReadableFromWebfor every other stream.- Considered replacing a falsy abort reason in
fetch(), as undici does: user streams stay broken. Both adapters see every web stream failure.
Downsides
- A program with no
'error'listener got a quiet'close'. It now ends with an uncaughtAbortError(exit code 1), as in Node. for await,pipeline()andfinished()now report theAbortErrorwhere they reportedERR_STREAM_PREMATURE_CLOSE, as in Node. For afetch()body Node's code is20(aDOMException), not"ABORT_ERR".- Cost: +153 bytes of built-in JS and one
jmpbytecode more per nativeread(). No new allocation.
Notes
Repro
import { Readable } from "node:stream";
for (const reason of [null, 0, ""]) {
const readable = Readable.fromWeb(new ReadableStream({ start(c) { c.error(reason); } }));
const seen = [];
readable.on("error", e => seen.push(`error(${e.name})`)).on("end", () => seen.push("end"));
readable.on("close", () => console.log(String(reason), seen.join(","), "close"));
readable.resume();
}
// node v26.3.0: error(AbortError) close bun main: close
The two paths
- The throwing
pull(): a native source that already holds its error throws it frompull(), andReadablehands what_read()throws toerrorOrDestroy()as is. Repro:Readable.fromWeb(new HTMLRewriter().transform(new Response(input)).body)afterinputwas errored withnull. The review found this twin of the rejection path. Its 4 test cases fail on the first head of this PR. - With
fetch(): node answersabort(null)with anAbortErrorfor every body reader, because undici replaces a falsy reason at the fetch level. Bun keeps the reason (await res.text()rejects withnull), which is what the Fetch spec describes. This PR does not change that. destroyer()replaces a falsy error only while the stream is not finished (!err && !isFinished(stream),src/js/internal/streams/destroy.ts:306).
fetch() bodies: when controller.abort(null) arrives
| timing | 1.4.3-canary (367d939d9, before #43699) | main 179664ea89 | this PR |
|---|---|---|---|
before Readable.fromWeb(res.body) | 'close' | 'close' | 'error' (AbortError), 'close' |
one tick after the first 'data' | 'end', 'close' | 'close' | 'error' (AbortError), 'close' |
after fromWeb(), before the first read | 'end', 'close' | 'error' (DOMException, code: 20), 'close' | the same as main |
- Before #43699 the second and third rows ended as if the body were complete. #43699 made the second row fail with the reason, and a falsy reason then gave
'close'alone. - The third row does not pass through this change. Nothing waits for the reason there, and the native source fails with its own
DOMExceptionfor every reason.
What moves for a program
One process per row, on main 179664ea89, on this PR merged with that main (debug builds, Linux x64), and on Node v26.3.0.
- Sources: a web stream whose
pull()callscontroller.error(v). AnHTMLRewriteroutput whose input failed withv. Afetch()body withcontroller.abort(v)beforeReadable.fromWeb(). The same with the abort 250 ms after the consumer starts. - Values:
undefined,null,0,"",false, andnew Error("x")as the control.
| consumer | main | this PR | rows |
|---|---|---|---|
resume(), no 'error' listener | 'close', exit code 0 | uncaught AbortError, exit code 1 | 18 |
pipe(dest), no 'error' listener | 'close', exit code 0 | uncaught AbortError, exit code 1 | 18 |
a promise on 'end' and 'error' | never settles | rejects with the AbortError | 18 |
for await | throws ERR_STREAM_PREMATURE_CLOSE | throws the AbortError | 18 |
pipeline() | callback gets ERR_STREAM_PREMATURE_CLOSE | callback gets the AbortError | 18 |
finished() | callback gets ERR_STREAM_PREMATURE_CLOSE | callback gets the AbortError | 18 |
- Bun has 144 rows. 108 move, as the table shows. 36 do not move: the 24 control rows, and the 12
fetch()rows withabort(undefined).abort(undefined)isabort(), and its default reason is aDOMException, which is not falsy. - Node has 108 of these rows (it has no
HTMLRewriter). No row is equal to Node on main and different with this PR. 30 rows move to Node's answer. 48 rows, allfetch()bodies, move to anAbortErrorwithcode: "ABORT_ERR"where Node has aDOMExceptionwithcode: 20. 26 rows are equal to Node before and after. - The last 4 rows differ from Node before and after:
abort()on afetch()body with no'error'listener. Node throws theDOMException. Bun throws anERR_UNHANDLED_ERROR, becauseError.isError(new DOMException())is false in Bun andnode:eventstests that. This PR does not touch it. #45255 changes that test innode:events, and #32898 makes aDOMExceptionan error forError.isError.
Costs
- Size: the production bundle of
src/js(bundle-modules.ts --debug=OFF,InternalModuleRegistryConstants.bin) is 2,714,593 bytes on main and 2,714,746 bytes with this PR.internal/streams/native-readable.jsgrows by 150 bytes andinternal/webstreams_adapters.jsby 3. - Per call: the bytecode of
read()(BUN_JSC_dumpGeneratedBytecodes=1, debug build) has 216 instructions on main and 226 with this PR. 9 of the 10 run only whenpull()throws. The path that does not throw runs one morejmp, over the catch block. A pending pull makes 2 closures in both versions. ReadableFromWeb:destroyer()runs only when the web stream fails. The path with no failure is the same code.- There is no instruction count.
perfandvalgrindare not in my container.
Suites
- On this PR merged with main 179664ea89 (debug build, Linux x64):
node-stream.test.js126 pass,fetch-abort-stream-body.test.ts81 pass,web-stream-state.test.ts46 pass,node-stream-uint8array.test.ts5 pass,test/js/web/streams/streams.test.js727 pass. The 23 files intest/js/node/test/parallelwithwebstreams,readable-from-webortoWebin the name pass. - With the
src/of main, the 14 new cases fail and the other 193 cases of the two files pass. child_process.test.ts: 83 pass and 2 fail, with and without this change. The 2 are "extra stdio pipes are not double-closed on GC" and "spawn still works with more than 10240 fds open" (my container allows 1024 open files).
no test proof · iteration 4 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/web/fetch/fetch-abort-stream-body.test.ts, test/js/node/stream/node-stream.test.js