spawn: open Bun.file() stdio paths in the parent so a blocking open cannot freeze the runtime

RO
2mo ago
+292-40

Problem

  • Bun.spawn / Bun.spawnSync with a Bun.file(path) stdio whose open(2) blocks (FIFO with no peer, hung mount, device waiting for carrier) freezes the whole runtime inside the spawn call. timeout and signal never run, and SIGTERM is blocked around the spawn.
  • Cause: the path was opened as a spawn file action, so the open ran in the child before exec, and the parent thread is suspended until file actions finish (vfork on Linux, inside the posix_spawn syscall on macOS).
  • A failed open took the exec-failure error route, so it blamed the command: ENOENT: no such file or directory, posix_spawn 'true' for a stdout file in a missing directory.
  • stdin was opened with O_RDONLY|O_CREAT, so stdin: Bun.file(missing) silently created an empty file and the child read EOF.

Fix

  • The parent opens the path itself with O_NONBLOCK|O_CLOEXEC|O_NOCTTY, clears O_NONBLOCK, and registers a dup2 onto the target fd. Nothing that can block runs inside the spawn any more, so the call always returns; Windows already worked this way.
  • stdin is opened read-only, a directory is rejected with EISDIR, and open failures are thrown as open errors carrying the stdio path, on POSIX and Windows.
  • Behavior changes: a stdout/stderr FIFO with no reader fails at spawn with ENXIO instead of waiting, and a relative Bun.file("out.txt") resolves against the parent's cwd, not the cwd option (as on Windows already).
  • Verification: one new test file. 14 of 15 cases fail on an unfixed build and all pass with the fix; the FIFO cases run in a child bun so a regression is a timed-out test, not a frozen runner. A Windows debug build passes it too; the shipped canary fails 10 of its 11 cases.

Background

  • Spawn file actions are the open/dup2/close list applied between fork and exec. With vfork (Linux) or inside posix_spawn (macOS) the parent thread waits for all of them, so a blocking open blocks bun's event loop.
  • Opening a FIFO waits for a peer. With O_NONBLOCK a read open returns at once (EOF until a writer arrives) and a write open with no reader fails with ENXIO. The flag lives on the open file, so it must be cleared again or the child inherits a non-blocking stdin.
  • Spawn errors carry a syscall tag. The JS binding rewrites exec-shaped errors (ENOENT, EACCES, ...) to name argv[0], so an open failure needs its own tag and path to escape that rewrite.
  • open(2) on a directory succeeds read-only; only an fstat of the fd tells a directory stdin from a file, hence the separate directory check.

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/bun/spawn/spawn-stdio-bunfile.test.ts

Original description

What does this PR do?

Bun.spawn / Bun.spawnSync with a Bun.file(path) stdio whose open(2) blocks freezes the whole runtime inside the Bun.spawn(...) call. timeout / signal cannot help because the call never returns, and SIGTERM does not get through either since signals are blocked around the spawn.

mkfifo /tmp/ff
bun -e '
setTimeout(() => console.log("timer fired"), 200);
console.log("spawning");
const p = Bun.spawn(["true"], { stdin: Bun.file("/tmp/ff"), timeout: 300 });
console.log("returned", p.pid);'
# before: prints "spawning" and hangs until something opens /tmp/ff for writing

Same with stdout: Bun.file(fifo), with spawnSync, and with any other path whose open blocks (hung mount, device node waiting for carrier).

Cause

A path stdio was registered as an open file action (spawn_process.rs -> actions.open), so the open() ran in the child before exec. On Linux that child is a vfork child (bun-spawn.cpp), so the parent thread is suspended until the open returns; on macOS the file actions are executed inside the posix_spawn syscall, with the same effect. A failing open was reported by the same route as an exec failure, so the error blamed the executable:

ENOENT: no such file or directory, posix_spawn 'true'     # stdout: Bun.file("/nonexistent-dir/out.txt")

and stdin was opened with O_RDONLY|O_CREAT, so stdin: Bun.file(missing) silently created an empty file and the child read EOF.

Fix

spawn_process_posix now opens the path itself (open_stdio_path) with O_NONBLOCK|O_CLOEXEC|O_NOCTTY, clears O_NONBLOCK again, and registers a dup2 onto the target fd; the parent's copy is closed after the spawn through the existing fd guard. This is what spawn_process_windows already did with uv_fs_open. The child-side Actions::open (and its to_posix_path) had no other callers and is removed; the /dev/null open for "ignore" stays an action since it cannot block.

  • stdin is opened with O_RDONLY only. Since a directory opens fine read-only, the parent fstats and rejects it with EISDIR (reject_directory_stdio, shared with the Windows path). On POSIX that is what the O_CREAT open used to produce; on Windows a directory stdin used to spawn successfully even before this change, so it is a fix there too.
  • Open failures are returned as open errors carrying the stdio path, and the JS bindings no longer rewrite those to argv[0] (same shape as the existing cgroup guard). Windows attaches the path to its uv_fs_open error too, stops passing O_CREAT for stdin, and runs the same directory check.
ENOENT: no such file or directory, open '/nonexistent-dir/out.txt'
ENOENT: no such file or directory, open '/tmp/missing-input'        # was: spawned, file created, exit 0
EISDIR: illegal operation on a directory, open '/etc'

Behavior notes

  • A FIFO no longer blocks the spawn. With no writer yet, the child reads EOF; a FIFO with no reader given as stdout/stderr fails with ENXIO at spawn time. Anyone who wants the blocking open can still do it explicitly and pass the fd (stdin: openSync(fifo, "r")).
  • A relative Bun.file("out.txt") now resolves against the parent's cwd like every other use of that Bun.file, instead of against the cwd option. Windows already behaved this way.
  • O_NOCTTY keeps a tty path given as stdio from becoming bun's controlling terminal when bun is a session leader.
  • #35352 (add O_TRUNC for stdout/stderr paths) touches the same flags; either order rebases trivially.

How did you verify your code works?

New test/js/bun/spawn/spawn-stdio-bunfile.test.ts. The FIFO cases run inside a child bun so that on an unfixed build they show up as timed-out tests rather than a frozen runner (the hook afterwards kills that bun and unblocks the grandchild it left in open()).

  • stdin FIFO with no writer: spawn and spawnSync return, child exits 0 (before: both hang)
  • stdout FIFO with no reader: ENXIO with syscall: "open" and the fifo path (before: hangs)
  • the child's stdin does not have O_NONBLOCK set (fcntl(0, F_GETFL) via bun:ffi)
  • stdin: Bun.file(missing) throws ENOENT and the file is not created; stdin: Bun.file(dir) throws EISDIR; stdout / stderr / stdio[3] in a missing directory report the file's path. All for both spawn and spawnSync (before: path is argv[0], and the missing-stdin case spawns)
  • relative path resolves against the parent's cwd

Unfixed build: 14 fail / 1 pass (the O_NONBLOCK guard also passes there). With the fix: 15 pass, and spawn.test.ts, spawnSync.test.ts, null-byte-injection.test.ts, spawn-renamed-cwd.test.ts, exit-code.test.ts still pass. On a Windows debug build the file passes as well (11 pass, the FIFO cases are POSIX-only); with the shipped canary it fails 10 of those 11, including the directory case. cargo check -p bun_spawn_sys -p bun_spawn passes for the x86_64-pc-windows-msvc, aarch64-pc-windows-msvc and x86_64-apple-darwin targets.