Error the Bun.file() stream when read() fails instead of hanging the pull promise

Closed
RO
2mo ago
+85-6

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.