spawn: open Bun.file() stdio paths in the parent so a blocking open cannot freeze the runtime
Problem
Bun.spawn/Bun.spawnSyncwith aBun.file(path)stdio whoseopen(2)blocks (FIFO with no peer, hung mount, device waiting for carrier) freezes the whole runtime inside the spawn call.timeoutandsignalnever 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_spawnsyscall 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, sostdin: 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, clearsO_NONBLOCK, and registers adup2onto 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 asopenerrors carrying the stdio path, on POSIX and Windows. - Behavior changes: a stdout/stderr FIFO with no reader fails at spawn with
ENXIOinstead of waiting, and a relativeBun.file("out.txt")resolves against the parent's cwd, not thecwdoption (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_NONBLOCKa read open returns at once (EOF until a writer arrives) and a write open with no reader fails withENXIO. 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 anfstatof 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_RDONLYonly. Since a directory opens fine read-only, the parentfstats and rejects it withEISDIR(reject_directory_stdio, shared with the Windows path). On POSIX that is what theO_CREATopen 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
openerrors carrying the stdio path, and the JS bindings no longer rewrite those to argv[0] (same shape as the existingcgroupguard). Windows attaches the path to itsuv_fs_openerror too, stops passingO_CREATfor 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
ENXIOat 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 thatBun.file, instead of against thecwdoption. Windows already behaved this way. O_NOCTTYkeeps a tty path given as stdio from becoming bun's controlling terminal when bun is a session leader.- #35352 (add
O_TRUNCfor 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:
spawnandspawnSyncreturn, child exits 0 (before: both hang) - stdout FIFO with no reader:
ENXIOwithsyscall: "open"and the fifo path (before: hangs) - the child's stdin does not have
O_NONBLOCKset (fcntl(0, F_GETFL)viabun:ffi) stdin: Bun.file(missing)throwsENOENTand the file is not created;stdin: Bun.file(dir)throwsEISDIR; stdout / stderr /stdio[3]in a missing directory report the file's path. All for bothspawnandspawnSync(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.