Skip to content

stdin: set O_NONBLOCK on the stdio fd when process.stdin starts reading - #35477

Open
robobun wants to merge 4 commits into
mainfrom
farm/24d452c9/stdin-nonblock-5305
Open

robobun wants to merge 4 commits into
mainfrom
farm/24d452c9/stdin-nonblock-5305

Conversation

@robobun

@robobun robobun commented Jul 24, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #5305.

Repro

import fs from "fs";
import readline from "readline";
readline.createInterface({ input: process.stdin, output: process.stdout });
console.log(fs.readSync(0, Buffer.alloc(64)));

With stdin as a pipe and no data available:

  • Node: throws EAGAIN: resource temporarily unavailable, read
  • Bun before: blocks forever (or, on a raw TTY, returns 1 byte per keypress)

Cause

When process.stdin starts reading, Node/libuv put fd 0 into nonblocking mode: uv_pipe_open calls uv__nonblock(fd, 1) directly, and uv_tty_init reopens the tty via ttyname_r, dup2s the fresh file description back onto the stdio fd, then sets O_NONBLOCK on it. After that, a direct fs.readSync(0, buf) observes a nonblocking fd and surfaces the kernel's EAGAIN.

Bun's FileReader::open_file_blob left fd 0 untouched. For a tty it opened a separate nonblocking fd and used that for its own reads; for a pipe it used fd 0 as-is and relied on preadv2(RWF_NOWAIT) to avoid blocking internally. Either way, fd 0's file description stayed blocking, so any user syscall on fd 0 (fs.readSync, fs.readvSync) blocked.

Fix

open_file_blob now mirrors libuv for stdin on Unix:

  • tty stdin: reopen with the fd's existing access mode (so an O_RDWR stdin stays writable) and dup2 the new nonblocking file description back onto fd 0. The reader keeps using the new fd; Fd::close already skips fd 0.
  • pipe/socket stdin: fcntl(F_SETFL, O_NONBLOCK) on fd 0 directly, matching uv_pipe_open.
  • regular file: left alone (pollable is false).
  • fd 1/2: left alone so a Bun.stdout.stream() reader cannot disturb the write side's blocking-tty contract.

Setting O_NONBLOCK on a pipe's file description is visible to any sibling in the same subshell that shares fd 0. Node bounds that by restoring the startup flag bit in ResetStdio() on the way out, so bun_initialize_process now snapshots F_GETFL for each stdio fd and bun_restore_stdio() restores the O_NONBLOCK bit at exit (and on SIGINT/SIGTERM, for which the handler is now installed whenever a stdio fd was valid at startup rather than only when one was a tty).

As a side effect the stdin pipe reader now takes the NonblockingPipe path (read_pipe) instead of read_blocking_pipe, dropping the per-read poll() readiness check.

Verification

test/js/node/process/stdin/process-stdin-nonblock.test.ts:

  • pipe stdin: fs.readSync(0) throws EAGAIN once process.stdin is reading (hangs on main)
  • regular-file stdin: fs.readSync(0) still returns data
  • tty stdin (via Bun.spawn({ terminal })): fd 0 is nonblocking, fs.writeSync(0, ...) still succeeds (access mode preserved), fs.readSync(0) throws EAGAIN (hangs on main)
  • subshell sibling: O_NONBLOCK is restored after Bun exits
  • process.stdin still delivers data over the now-nonblocking pipe

no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/node/process/stdin/process-stdin-nonblock.test.ts

When process.stdin starts reading, Node/libuv put fd 0 into nonblocking
mode (uv_pipe_open/uv_tty_init both call uv__nonblock(fd, 1)). Bun left
fd 0 untouched and relied on preadv2(RWF_NOWAIT) internally, so a user
fs.readSync(0, buf) after attaching a readline/stdin listener would block
on an empty pipe (or, on a raw TTY, return one keystroke at a time)
instead of throwing EAGAIN like Node.

FileReader::open_file_blob now mirrors libuv: for a tty stdio fd it
reopens the device with the original access mode and dup2s the fresh
nonblocking file description back onto the stdio fd; for a pipe/socket
stdio fd it sets O_NONBLOCK directly. Regular-file stdin is left alone.

Fixes #5305.
@coderabbitai

coderabbitai Bot commented Jul 24, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The change updates Unix stdin handling to preserve access modes and enable nonblocking behavior for pollable stdin, adds startup snapshots and exit-time restoration of O_NONBLOCK, exposes Unix O::ACCMODE, and adds POSIX regression tests.

Stdin nonblocking behavior

Layer / File(s) Summary
Unix stdin setup and access-mode preservation
src/sys/lib.rs, src/runtime/webcore/FileReader.rs
Unix exposes O::ACCMODE; TTY reopening preserves stdin’s access mode, and pollable stdin receives nonblocking mode without changing stdout or stderr behavior.
Startup flag capture and exit restoration
src/jsc/bindings/c-bindings.cpp
Startup captures stdio flags, and exit restoration conditionally restores differences in the O_NONBLOCK bit with EINTR handling.
Nonblocking stdin regression coverage
test/js/node/process/stdin/process-stdin-nonblock.test.ts
POSIX tests cover pipe reads, regular files, TTY access modes, streaming events, EAGAIN, and pipeline restoration.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: setting O_NONBLOCK on stdin when process.stdin starts reading.
Description check ✅ Passed The description covers the bug, cause, fix, and verification, even though it uses different headings than the template.
Linked Issues check ✅ Passed The changes address #5305 by making stdin nonblocking for pipes and TTYs, preserving other modes, and restoring flags on exit.
Out of Scope Changes check ✅ Passed The added constant, tests, and runtime changes all support the stdin nonblocking fix and do not appear unrelated.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 24, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:55 PM PT - Jul 24th, 2026

@autofix-ci[bot], your commit 3c1f8ed is building: #79906

@robobun

robobun commented Jul 24, 2026 •

Copy link
Copy Markdown
Collaborator Author

Reproduced with the snippet from #5305: with stdin as a pipe and no data available, fs.readSync(0, buf) blocks on main and throws EAGAIN with this branch, matching Node.

bun bd test test/js/node/process/stdin/process-stdin-nonblock.test.ts passes (5 cases: pipe EAGAIN, regular file unchanged, tty EAGAIN + O_RDWR preserved, exit-time restore, data still flows); the pipe and tty cases hang on main.

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Bun processes fail to read from stdin when launched by Java-based process managers #21669 - Bun processes fail to read from stdin when launched by Java-based process managers (stdin is a pipe, not a TTY) — the PR's change to set O_NONBLOCK and use the NonblockingPipe read path for pipe-based stdin matches the scenario where pipe stdin was not being read correctly

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #21669

🤖 Generated with Claude Code

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't find any bugs, but this changes fd-level stdin semantics on Unix (dup2'ing a fresh file description onto fd 0 for ttys, and flipping O_NONBLOCK on the shared file description for pipe/socket stdio) and shifts the stdio-pipe reader from read_blocking_pipe onto the NonblockingPipe path — worth a human look given the blast radius.

What was reviewed:

  • Confirmed dup2 does not carry FD_CLOEXEC to fd 0, so children spawned after process.stdin starts still inherit stdin.
  • Verified the tty reopen preserves the fd's original access mode (O_RDWR stays writable) and the parent shell's file description is untouched (the new one is dup2'd in, not fcntl'd).
  • The new set_nonblocking block only fires when pollable is true, so regular-file stdin is left blocking (covered by the second test).
Extended reasoning...

Overview

The PR modifies Lazy::open_file_blob in src/runtime/webcore/FileReader.rs to mirror libuv's behavior when process.stdin starts reading: for tty stdio it now reopens with the fd's existing access mode (previously hardcoded O_RDONLY) and dup2s the fresh nonblocking file description back onto the stdio fd; for pipe/socket stdio it sets O_NONBLOCK directly via fcntl. It also adds sys::O::ACCMODE for Unix and a new three-case test file.

Security risks

None identified. The change only touches fd-flag manipulation on the process's own stdio fds; there's no untrusted input parsing, and the added syscalls (fcntl F_GETFL, dup2, fcntl F_SETFL) fail closed (errors are ignored and fall back to the previous behavior).

Level of scrutiny

This warrants maintainer review. The mechanics are small and well-reasoned against libuv (uv_tty_init, uv_pipe_open), but the change alters process-global fd state in a way every stdin consumer sees:

  • Previously fd 0 was left completely untouched for ttys (the reader used a separate fd); now fd 0 is replaced via dup2. Any code holding assumptions about fd 0's original file description now sees a different one.
  • For pipe stdio, O_NONBLOCK is set on the shared file description, which is visible to any other fd (in this or a parent process) referencing the same description. This matches Node/libuv, but it's a real behavioral change from what Bun did before.
  • The PR notes the stdio-pipe reader now routes through FileType::NonblockingPipe / read_pipe instead of read_blocking_pipe, dropping the per-read poll(). That's a code-path swap in a hot area someone with I/O-subsystem context should sanity-check.

Other factors

I checked a few specific hazards and ruled them out: open_as_nonblocking_tty opens with O_CLOEXEC, but dup2 does not propagate FD_CLOEXEC to the target, so fd 0 remains inheritable by children. The accmode change means an O_RDWR stdin (e.g., an interactive tty) stays writable after the swap, which the previous O_RDONLY hardcode would have broken had the dup2 been added without it. The set_nonblocking block is gated on pollable && !is_nonblocking, so regular files and the already-handled tty-reopen success path are skipped. The 30s Bun.sleep race in the first test is justified with a comment (the failure mode is a blocked read(), no observable signal). The tty/dup2 path itself is not covered by an automated test, which is typical for tty behavior but leaves that branch verified only by reasoning.

Address review:

- Scope the new dup2/set_nonblocking to fd 0 only so a Bun.stdout.stream()
  reader does not flip the write side's blocking-tty contract on fd 1/2.
- Snapshot F_GETFL for each stdio fd at startup and restore the O_NONBLOCK
  bit in bun_restore_stdio(), mirroring Node's ResetStdio(), so a sibling
  process in the same subshell does not inherit a nonblocking pipe after
  Bun exits. Install the SIGINT/SIGTERM restore handler whenever a stdio
  fd was valid at startup, not only when one was a tty.
- Add a pty-backed test for the tty dup2 path (O_RDWR preserved, readSync
  sees EAGAIN) and a subshell test for the exit-time restore.
Comment thread test/js/node/process/stdin/process-stdin-nonblock.test.ts
Comment thread src/runtime/webcore/FileReader.rs
robobun and others added 2 commits July 25, 2026 00:53
…ore test

The accmode preservation only matters for the fd that is dup2'd back
(stdin); reopening fd 1/2 with their own accmode would produce a
write-only fd for '> /dev/tty' and break the reader. Keep the previous
O_RDONLY reopen for stdout/stderr.

Use harness libcPathForDlopen() and fs.constants.O_NONBLOCK in the
exit-restore probe so it runs on musl; skip on FreeBSD where the helper
has no mapping yet.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/webcore/FileReader.rs`:
- Around line 184-185: Propagate descriptor setup failures in both affected
sites: in src/runtime/webcore/FileReader.rs lines 184-185, close the reopened
temporary fd and return the error from dup2; in
src/runtime/webcore/FileReader.rs lines 281-284, close fd and return the
set_nonblocking error. Ensure both syscall failures surface as catchable errors
rather than continuing with potentially blocking stdin.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 63aafc3e-1909-41e1-8f78-ce10dc96b63a

📥 Commits

Reviewing files that changed from the base of the PR and between 028f7a3 and 3c1f8ed.

📒 Files selected for processing (4)
  • src/jsc/bindings/c-bindings.cpp
  • src/runtime/webcore/FileReader.rs
  • src/sys/lib.rs
  • test/js/node/process/stdin/process-stdin-nonblock.test.ts

Comment thread src/runtime/webcore/FileReader.rs

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No bugs found this run, but this touches process-global state (stdio fd mutation, exit-time restore, and — notably — widens the onExitSignal SIGINT/SIGTERM handler from tty-only to effectively every invocation via anyTTYs || anyFlags), so it's worth a human look.

What was reviewed:

  • tty path: accmode now scoped to stdin (fd 1/2 stays O_RDONLY), dup2 best-effort fallback keeps the pre-PR reader path.
  • pipe path: set_nonblocking failure keeps is_nonblocking = false → routes through read_blocking_pipe as before.
  • bun_restore_stdio: only touches the O_NONBLOCK bit; async-signal-safe (fcntl + EINTR loop); ordered before the termios restore/pipeline-producer suppression.
  • test: probe now uses libcPathForDlopen() + fs.constants.O_NONBLOCK, gated to Linux/macOS.
Extended reasoning...

Overview

This PR makes process.stdin put fd 0 into nonblocking mode on Unix, matching Node/libuv (uv_tty_init / uv_pipe_open). It touches four places: FileReader::open_file_blob (reopen tty stdin with preserved accmode + dup2 back onto fd 0; fcntl(F_SETFL, O_NONBLOCK) for pipe/socket stdin), bun_initialize_process (snapshot F_GETFL for each stdio fd; install SIGINT/SIGTERM restore handler whenever any stdio fd was valid, not just when one was a tty), bun_restore_stdio (restore the startup O_NONBLOCK bit at exit), and sys::O::ACCMODE (adds the Unix constant). A new 5-case test covers pipe EAGAIN, regular-file passthrough, tty accmode preservation + EAGAIN, exit-time restore via a subshell sibling probe, and data delivery over the now-nonblocking pipe.

Security risks

None identified. The changes are stdio fd flag manipulation and signal-handler installation on the process's own fds; no untrusted input is parsed.

Level of scrutiny

High. This mutates process-global state that runs for every Bun invocation:

  • Signal handler scope: anyFlags is true whenever any of fd 0/1/2 succeeds F_GETFL, which is essentially always. Before, onExitSignal was only installed when a stdio was a tty; now it's installed unconditionally in practice. The handler is transparent (bun_restore_stdio; SIG_DFL; raise) and Node's PlatformInit does the same, but it's a change in default disposition for non-interactive processes that a maintainer should sign off on — e.g., interaction with bun install / bun run subprocess signal forwarding, or user process.on('SIGINT') install/remove sequences that save/restore the previous action.
  • fd 0 mutation: dup2 swaps fd 0's file description (tty) or F_SETFL mutates the shared one (pipe). The pipe case is visible to any concurrent code touching fd 0 and to sibling processes on the same pipe until exit-time restore runs. Crash / SIGKILL leaves the sibling with nonblocking stdin (same limitation as Node).
  • Read path change: pipe stdin now takes NonblockingPipe (read_pipe) instead of read_blocking_pipe, dropping the per-read poll() — a real code-path swap for a hot input source.

Other factors

Both prior inline findings from the earlier review pass are addressed (accmode scoped to stdin; probe fixture uses libcPathForDlopen() + fs.constants.O_NONBLOCK and is gated to Linux/macOS). CodeRabbit's error-propagation concern was withdrawn after the best-effort-fallback rationale was explained. Test coverage is thorough for the observable contract, and the exit-restore test uses a real subshell chain to prove the sibling sees blocking stdin again. Given the process-init / signal-handler surface, deferring to a human reviewer.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fs.readSync behaves unusually with a running readline interface

1 participant