Read a character device synchronously when the kernel refuses to poll it

RO
16d ago
+353-23

Problem

  • On main, Bun.file("/dev/zero").slice(0, n).stream() rejects with EPERM: Operation not permitted (epoll_ctl). So do /dev/urandom and /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.serve resets the connection for them. A served FIFO whose writer leaves hangs: finish() wrote no framing.

Fix

  • PosixBufferedReader::try_register_poll treats EPERM or EINVAL as "cannot be polled". It drops the poll and reads synchronously. Such a reader reports EAGAIN as an error.
  • FileReader::on_start releases the ref it took for a pollable source, so an abandoned device stream closes its fd.
  • FileResponseStream::finish ends with resp.end(b"") (as #37082). start() applies a .slice() bound once the poll is refused.
  • Verified: streams.test.js and bun-serve-file.test.ts, 6 new tests each. All fail on 1.4.3.

Background

  • PosixBufferedReader reads for Bun.file().stream(), Bun.serve file bodies, subprocess pipes and the shell. It waits on a FilePoll, or reads synchronously.
  • Only the kernel knows whether an fd polls: /dev/random does, /dev/zero does 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 EAGAIN arm 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:

devicethis branch1.4.3
/dev/urandom, /dev/zero, /dev/full200, 16 bytesconnection reset
/dev/null200, content-length: 0connection reset
/dev/random200, no end200, 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