node:stream: Readable.fromWeb emits an AbortError when the web stream fails with a falsy value

RO
11d ago
+89-5

Problem

  • Readable.fromWeb(stream) emits 'close' with neither 'end' nor 'error' when the web stream fails with a falsy value: controller.error(null), or controller.abort(null) on a fetch() body. A promise that waits for 'end' or 'error' never settles. Node v26.3.0 emits 'error' with an AbortError (code: "ABORT_ERR").
  • Cause: destroy(null) means no error. ReadableFromWeb.#handleError (src/js/internal/webstreams_adapters.ts:85) called this.destroy(error), and read() (src/js/internal/streams/native-readable.ts) gave a falsy pull failure to errorOrDestroy().

Fix

  • #handleError calls destroyer(this, error), as Node's adapter does. destroyer() replaces a falsy error with an AbortError.
  • read() sends a rejected or throwing pull() to failPull(), which passes reason || $makeAbortError() on. A truthy reason is unchanged.
  • Verified: 8 new cases in test/js/node/stream/node-stream.test.js and 6 in test/js/web/fetch/fetch-abort-stream-body.test.ts. All 14 fail on main. Other suites: see Notes.

Background

  • Readable.fromWeb returns a NativeReadable for a stream with a native source (a fetch body, a file), and a ReadableFromWeb for 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 uncaught AbortError (exit code 1), as in Node.
  • for await, pipeline() and finished() now report the AbortError where they reported ERR_STREAM_PREMATURE_CLOSE, as in Node. For a fetch() body Node's code is 20 (a DOMException), not "ABORT_ERR".
  • Cost: +153 bytes of built-in JS and one jmp bytecode more per native read(). 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 from pull(), and Readable hands what _read() throws to errorOrDestroy() as is. Repro: Readable.fromWeb(new HTMLRewriter().transform(new Response(input)).body) after input was errored with null. The review found this twin of the rejection path. Its 4 test cases fail on the first head of this PR.
  • With fetch(): node answers abort(null) with an AbortError for every body reader, because undici replaces a falsy reason at the fetch level. Bun keeps the reason (await res.text() rejects with null), 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

timing1.4.3-canary (367d939d9, before #43699)main 179664ea89this 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 DOMException for 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() calls controller.error(v). An HTMLRewriter output whose input failed with v. A fetch() body with controller.abort(v) before Readable.fromWeb(). The same with the abort 250 ms after the consumer starts.
  • Values: undefined, null, 0, "", false, and new Error("x") as the control.
consumermainthis PRrows
resume(), no 'error' listener'close', exit code 0uncaught AbortError, exit code 118
pipe(dest), no 'error' listener'close', exit code 0uncaught AbortError, exit code 118
a promise on 'end' and 'error'never settlesrejects with the AbortError18
for awaitthrows ERR_STREAM_PREMATURE_CLOSEthrows the AbortError18
pipeline()callback gets ERR_STREAM_PREMATURE_CLOSEcallback gets the AbortError18
finished()callback gets ERR_STREAM_PREMATURE_CLOSEcallback gets the AbortError18
  • Bun has 144 rows. 108 move, as the table shows. 36 do not move: the 24 control rows, and the 12 fetch() rows with abort(undefined). abort(undefined) is abort(), and its default reason is a DOMException, 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, all fetch() bodies, move to an AbortError with code: "ABORT_ERR" where Node has a DOMException with code: 20. 26 rows are equal to Node before and after.
  • The last 4 rows differ from Node before and after: abort() on a fetch() body with no 'error' listener. Node throws the DOMException. Bun throws an ERR_UNHANDLED_ERROR, because Error.isError(new DOMException()) is false in Bun and node:events tests that. This PR does not touch it. #45255 changes that test in node:events, and #32898 makes a DOMException an error for Error.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.js grows by 150 bytes and internal/webstreams_adapters.js by 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 when pull() throws. The path that does not throw runs one more jmp, 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. perf and valgrind are not in my container.

Suites

  • On this PR merged with main 179664ea89 (debug build, Linux x64): node-stream.test.js 126 pass, fetch-abort-stream-body.test.ts 81 pass, web-stream-state.test.ts 46 pass, node-stream-uint8array.test.ts 5 pass, test/js/web/streams/streams.test.js 727 pass. The 23 files in test/js/node/test/parallel with webstreams, readable-from-web or toWeb in 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