Read a character device synchronously when the kernel refuses to poll it
Problem
- On main,
Bun.file("/dev/zero").slice(0, n).stream()rejects withEPERM: Operation not permitted (epoll_ctl). So do/dev/urandomand/dev/null. 1.4.2 streams them (#41420, unreleased). Lazy::open_file_blob(src/runtime/webcore/FileReader.rs:222) marks every non-regular path as pollable. The kernel refuses, and #41420 made the refusal the stream's error.Bun.serveresets the connection for them. A served FIFO whose writer leaves hangs:finish()wrote no framing.
Fix
PosixBufferedReader::try_register_polltreatsEPERMorEINVALas "cannot be polled". It drops the poll and reads synchronously. Such a reader reportsEAGAINas an error.FileReader::on_startreleases the ref it took for a pollable source, so an abandoned device stream closes its fd.FileResponseStream::finishends withresp.end(b"")(as #37082).start()applies a.slice()bound once the poll is refused.- Verified:
streams.test.jsandbun-serve-file.test.ts, 6 new tests each. All fail on 1.4.3.
Background
PosixBufferedReaderreads forBun.file().stream(),Bun.servefile bodies, subprocess pipes and the shell. It waits on aFilePoll, or reads synchronously.- Only the kernel knows whether an fd polls:
/dev/randomdoes,/dev/zerodoes not. - Considered a bound for every sliced device. One that polls ends its stream inside
start()with the poll registered: a use-after-free, owned by #41874.
Downsides
/dev/random, a FIFO and a socket still ignore a served.slice(), until #41874.- The
EAGAINarm has no test: no such device on the test machines. - Cost: one branch on a refused registration, one flag test per file response not sent with
sendfile. No allocation or syscall.
Notes
Repro on main:
for await (const chunk of Bun.file("/dev/zero").slice(0, 1 << 20).stream()) {}
// EPERM: Operation not permitted (epoll_ctl)
The errno pair. epoll_ctl answers EPERM, kqueue EINVAL. The predicate is bun_io::pipes::is_unpollable. The shell's IOWriter::__start used the same pair and now calls it. The shell IOReader reads a demoted fd at once.
The served slice, in three steps. An earlier push bounded every sliced non-regular body. For a device that polls with the whole slice ready (/dev/random), the stream then finished inside FileResponseStream::start and freed itself with its poll registered: heap-use-after-free in PosixBufferedReader::begin_read from FilePoll::on_epoll_event, 3 of 3 processes on a debug ASAN build with Malloc=1. That bound was reverted. The bound is back for a reader the kernel refused to poll only (StartOptions::unpollable_length, set by the fetch handler and by static routes): such a reader has no poll to leave behind. The fault itself is older than this branch. printf hello | bun with a server that answers new Response(Bun.stdin) reports it on main. #41874 releases the poll in finish(). After it, the bound can apply to every source.
Measured with a served .slice(0, 16), debug ASAN build, Malloc=1, 3 of 3 processes, no sanitizer report:
| device | this branch | 1.4.3 |
|---|---|---|
/dev/urandom, /dev/zero, /dev/full | 200, 16 bytes | connection reset |
/dev/null | 200, content-length: 0 | connection reset |
/dev/random | 200, no end | 200, no end |
Also 20 fully awaited requests for each bounded device (the stream ends inside start() every time), and 20 requests whose client leaves after 64 KiB: no report.
finish(). resp.end(b"") gives an empty body content-length: 0 and a chunked body its last chunk. On 1.4.3 a served FIFO whose writer closes after its last write stops at a\r\nPIPEBYTES!\r\n, and the client waits for the idle timeout. Two FIFO tests cover the empty and the non-empty case. They are Linux only: kqueue does not wake a FIFO reader parked on an empty pipe when the last writer closes (#40099).
The ref release. On a build without the FileReader::on_start hunk, the test abandoned streams release their file descriptors reports 40 of 40 descriptors open, 3 of 3 runs. The older test a stream left mid-read does not keep the process alive passes on that build: a leaked ref does not keep the event loop alive.
EAGAIN with no poll. Before this change a reader with no poll that got EAGAIN returned quietly and its consumer waited for ever. A device with no poll support and a non-blocking read that can return EAGAIN would hit that (/dev/hwrng is the example from the review). It now ends the stream with the error. Subprocess and shell pipe readers always start pollable, so they never take this arm.
Platforms. Buildkite 123209 ran commit f4a4c6f6d1, the head before a last test-only commit: 181 of 181 jobs passed, and neither changed test file needed a retry. The two bounded-slice tests run on macOS too, so kqueue refuses /dev/zero and /dev/urandom there as well. bun run rust:check-all: 12 of 12 targets.
Slice offset. The fetch handler passes the slice offset to the reader, and a reader the kernel refused to poll reads with pread at that offset. A static route reads a non-regular file from the start, as it did before this PR, so only the length of the slice applies there. For /dev/zero, /dev/urandom, /dev/full and /dev/null a read offset has no meaning. A static route for Bun.file("/dev/zero").slice(5, 21) answers 200 with 16 bytes.
The shell. cat is disabled on POSIX unless BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS=1. With the flag, 1, 2, 3 and 5 regular files print and exit 0 on a debug build with assertions. 1.4.3 exits 1 with no output for any regular file. The read is synchronous, so the stack grows by one frame per file.
Related pull requests. This supersedes the reader half of #34257. finish() is the same one-line change as #37082. #41874 owns the poll lifetime in FileResponseStream.
Self-reviewed: 4 concerns raised, 4 addressed. Later review findings (the EAGAIN arm, the leak test, the slice bound for demoted devices) are addressed in the last commit.
no test proof · iteration 2 · 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, test/js/bun/http/bun-serve-file.test.ts