Release the poll and surface the error when a Bun.file() stream read fails
Problem
- A
Bun.file().stream()whoseread(2)fails with no pull pending never settles.for await (const c of Bun.file("/proc/self/mem").stream())hangs forever, whileBun.file(path).text()rejects withEIO(#33362). - After a read error on a pollable fd (a pty master, a non-blocking pipe) the process never exits.
reader.read()rejects withEIO: i/o error, read, but theFilePollstays registered and the dup'd fd open. An errored stream is never cancelled, so nothing releases them. - Both come from
FileReader::on_reader_error(src/runtime/webcore/FileReader.rs). It only runspending, a no-op unless a pull is pending (Pending::run), and it never closes the reader.
Fix
on_reader_errorsettles the pending pull when there is one. Otherwise it stores the error in a newread_errorslot, and the nexton_pullreturns it after any buffered bytes (reader_finished,end_of_reader).on_reader_errorthen closes the reader (close_after_error): it marks the source done and releases the poll and the fd throughdeinit, which reports no second completion.- Verified:
test/js/web/streams/streams.test.js(6 new tests, 5 fail on 1.4.1). Also the rest oftest/js/web/streams/, the fs, stream and spawn suites.
Background
FileReaderis the native source behindBun.file().stream(). A pull first tries a synchronous read (read_into). With no data on a pollable fd, theBufferedReaderregisters a poll and the pull becomes pending. A registered poll keeps the event loop alive.- The reader reports through
on_read_chunk,on_reader_doneoron_reader_error. On EOF it closes itself before it reports. On error it did not. sinkis the native consumer path (a fetch body). It gets the error throughsink.endand needs the same reader close.
Notes
This supersedes #33362, which stores the error in a read_error slot in the same way but does not release the poll and the fd. Its tests are carried over unchanged.
A stream over a non-blocking pty master with a pending read: before this change the rejection lands and the process then hangs, with or without reader.cancel() or releaseLock() afterwards. cancel() on an errored stream rejects with the stored error and never reaches the native source.
The sync pull path reaches on_reader_error from inside on_pull. The reader is closed by the time read_into returns, so on_pull returns the stored error through end_of_reader in the same call, where it previously returned Pending with no poll registered.
The second PR, for tty.ReadStream on a non-blocking fd (#41414), is built on this branch and needs the poll release: node-pty's pty master reports the child's exit as EIO.
Pre-existing failures on a debug build, with or without this change: test/js/node/process/stdin/stdin-fixtures.test.ts (the runner kills the child after 1 s, a debug child needs 1.1 s) and test/js/web/streams/streams-leak.test.ts "Absolute memory usage" (times out at 5 s).
no test proof · iteration 5 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/web/streams/streams.test.js