Error the Bun.file() stream when read() fails instead of hanging the pull promise
Repro
Bun.file(path).stream() hangs forever when read(2) on the file fails. Every other read path on the same file surfaces the error.
// read(2) on /proc/self/mem returns EIO: nothing is mapped at address 0.
for await (const chunk of Bun.file("/proc/self/mem").stream()) {}
console.log("done"); // never reached
text() rejected: EIO
arrayBuffer() rejected: EIO
fs.promises.readFile rejected: EIO
fs.createReadStream rejected: EIO
stream() for-await NEVER-SETTLES
stream() getReader().read() NEVER-SETTLES
stream() pipeTo NEVER-SETTLES
Any strace -f -e inject=read:error=EIO:when=3 bun stream-it.ts style injection reproduces it too; /proc/self/mem just needs no tooling. This is the normal failure mode of real storage (bad sectors, NFS/FUSE, removable media), and it silently hangs whatever awaited the stream: a request handler, a queue worker.
Cause
FileReader::on_reader_error delivers the error only through self.pending:
self.pending.with_mut(|p| p.result = streams::Result::Err(..));
self.pending.with_mut(|p| p.run());
Pending::run() returns immediately unless state == Pending, and pending is armed only when a previous on_pull returned Result::Pending. On POSIX a regular file is read synchronously inside on_pull (read_inside_on_pull = Js(buffer) → reader().read()), so at error time pending.state is None: p.run() no-ops and the error is dropped. on_pull then sees amount_read == 0, is_done() == false, and returns Result::Pending. With no poll registered and no read in flight, nothing will ever settle it.
Windows reads files through libuv, so the error always arrives with a pending read to reject; this is POSIX-only.
Fix
Keep a read_error slot on FileReader. on_reader_error settles pending when it is armed, and otherwise stashes the error. on_pull takes it at the two points where the stream would otherwise wait: before issuing a new read, and after a synchronous read that produced nothing. Both sites return streams::Result::Err, which process_result already turns into a rejected pull promise, so the stream errors.
Taking the error before issuing a new read is what keeps the stash from going stale: once a read fails, the stream terminates with that error rather than retrying the broken fd.
Buffered bytes still win over the error, so a read that fails part-way delivers what it read, then errors on the next pull.
Verification
Added to test/js/web/streams/streams.test.js: the three stream legs (for await, getReader().read(), pipeTo) must reject with EIO, Bun.file().text() must keep reporting the same error, and a stream over a write-only fd must reject with EBADF (the same non-retryable read-error path, and the case that also covers macOS).
Before / after
USE_SYSTEM_BUN=1 bun test (1.4.0):
(fail) for await rejects with the read error [5000.01ms]
^ this test timed out after 5000ms.
(fail) getReader().read() rejects with the read error [5000.00ms]
^ this test timed out after 5000ms.
(fail) pipeTo() rejects with the read error [5000.00ms]
^ this test timed out after 5000ms.
(pass) Bun.file().text() reports the same read error [1.43ms]
(fail) a stream over a write-only fd rejects with EBADF [5000.00ms]
^ this test timed out after 5000ms.
bun bd test:
(pass) for await rejects with the read error [31.41ms]
(pass) getReader().read() rejects with the read error [5.32ms]
(pass) pipeTo() rejects with the read error [8.21ms]
(pass) Bun.file().text() reports the same read error [5.98ms]
(pass) a stream over a write-only fd rejects with EBADF [29.54ms]
streams.test.js in full: 141 pass, 0 fail. spawn.test.ts (the other FileReader consumer, via subprocess stdout): 129 pass, 0 fail.