Conversation
Bun.file(fd).stream() treated a tty fd other than stdin as a regular file: a dup with synchronous reads that returned nothing. The stream never delivered input and nothing held the event loop. FileReader now applies the stdin tty path to any fd that isatty(): it reopens the terminal by ttyname with O_NONBLOCK and polls that private description, so the caller's fd keeps its flags. When the reopen is denied, the reader polls a dup it owns and reads only when poll(2) reports data. A pty master is never reopened (TIOCGPTN guard); it gets the polled dup.
|
Warning Review limit reached
On-demand reviews are free for the next 14 days. After that, they cost $0.25 per reviewed file. Or wait 10 seconds for your next included review. View limit detailsLimit details: You’ve used all 10 included reviews currently available. Review configuration: ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Essentials Run ID: 📒 Files selected for processing (2)
Comment |
|
Reproduced under a pty: a child spawned with CI status: the diff is green. The red lanes are unrelated and reported separately: |
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline finding, I checked fd ownership on the new non-stdio reopen path in FileReader.rs — the reopened fd is owned by the reader the same as the old dup path and is closed on the fstat error exit (line 223), so no new leak. Also traced the get_or_insert_with side-effect: caching is_atty = Some(false) for non-tty fds is benign since every downstream read is unwrap_or(false). The #[cfg(not(unix))] arm binds reopened_tty = None and falls through to the pre-existing stdio/dup branches unchanged.
Extended reasoning...
The native change extends an existing tty-reopen mechanism from stdio-only to any isatty() fd, with the fallback structure preserved (stdio → shared fd, non-stdio → owned dup). I traced ownership of the newly-introduced reopened fd through the error paths in open_file_blob and confirmed it is closed on fstat failure the same way the dup was; the is_atty cache mutation via get_or_insert_with changes None → Some(false) for non-tty fds, but every consumer in the function uses unwrap_or(false) so the observable behavior is identical. The Windows arm is a static None that routes to unchanged code. The inline finding covers the test-side resource cleanup; nothing further to add on the Rust side.
|
Updated 2:34 AM PT - Sep 6th, 2026
❌ @robobun, your commit 12acaf4 has 4 failures in
🧪 To try this PR locally: bunx bun-pr 41504That installs a local version of the PR into your bun-41504 --bun |
…stdin (#147) commit 6f0127b redirected a backgrounded agent's stdin from the literal /dev/tty alias. On macOS, kqueue refuses to poll an fd opened through that alias path (ttyname_r resolves it to /dev/tty again rather than a real device) -- Bun-compiled claude then crashes with EINVAL on launch. Resolve the controlling terminal's actual device path via `tty` first and redirect from that instead, falling back to the literal /dev/tty when resolution fails. Root cause corroborated externally: oven-sh/bun#41504. Claude-Session: https://claude.ai/code/session_01PFi83LbqMTznL3DW2sVm9d Co-authored-by: T <t@t.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Problem
Bun.file(fd).stream()on a tty fd other than stdin never delivers input.fs.openSync("/dev/tty")thenstream().getReader().read()hangs forever under a pty, and with no pending read the process exits before any input.Lazy::open_file_blob(src/runtime/webcore/FileReader.rs:135) only applied the tty path to fd 0, 1 and 2. Any other fd was treated as a regular file: a dup with synchronous reads and no poll.Fix
isatty(): the reader reopens the terminal byttynamewithO_NONBLOCK(open_as_nonblocking_tty) and polls that private description. The caller's fd keeps its flags and stays open.poll(2)reports data, the same fallback stdin already has. A pty master is never reopened (the existing TIOCGPTN guard) and gets the polled dup, which also reads anO_NONBLOCKmaster.test/js/bun/util/bun-file-fd-read.test.ts(one new test underBun.Terminal, hangs on 1.4.3). Alsotty.test.ts,process-stdin,streams.test.js,bun-file*,node-stream,child_process,spawn.Background
FileReaderis the native source behindBun.file().stream(). A pollable fd registers aFilePolland reads on readiness, which holds the event loop. A non-pollable fd is read synchronously withpread, which a tty does not support.uv_tty_initreopens a tty through/dev/pts/Nso thatO_NONBLOCKnever leaks onto a shared description. Bun ports that asopen_as_nonblocking_ttyinc-bindings.cppand used it for stdio only.Notes
This came out of a fuzz ledger item:
tty.ReadStreamon a/dev/ttyfd cannot be destroyed until the user presses Enter, and that line is lost. Routingtty.ReadStreamthrough this pollable reader fixes that, but #41495 replacestty.ReadStreamwith anet.Socketover a native TTY handle that reopens the terminal the same way, and #41421 edits the same constructor for anO_NONBLOCKfd. So this PR carries only theFileReaderchange, which stands on its own.macOS:
ttyname_rof an fd opened from/dev/tty(the controlling-terminal alias) returns/dev/ttyagain, and kqueue rejects that device withEINVAL. That is a kqueue limit (libuv falls back to aselectthread for it) and it applies to stdin the same way. A pty slave opened by its own path works, so the test opens/dev/fd/0on macOS and/dev/ttyon Linux.Probed on Linux: a blocking
/dev/ttyfd, a pty master with and withoutO_NONBLOCK, and stdin all deliver data andcancel()releases the poll. Pre-existing debug-only timing failures, identical on main:stdin-fixtures.test.ts(1 s auto-kill) andchild_process.test.ts"extra stdio pipes are not double-closed on GC".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/bun/util/bun-file-fd-read.test.ts