Conversation
dup2() returns the target fd on success. The stdio sanitation in bun_initialize_process treated any nonzero return as failure and called abort(), so replacing fd 1 or 2 with /dev/null always aborted.
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configuration
📒 Files selected for processing (2)
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review. WalkthroughChangesStandard descriptor initialization
Suggested reviewers: Priority: ➖ Normal Merge Risk: ⚪ Minimal · up to Startup now replaces only descriptors that are actually closed, and POSIX tests cover the closed and O_PATH cases. No concrete merge-blocking risk remains. 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Status: reproduced on bun 1.4.3 with The PR fixes the cause: startup confirms CI (build 123286, d4c1f4b): every lane that ran is green. That is Linux x64 and aarch64 (glibc, musl, ASAN), macOS aarch64, and Windows x64 and aarch64. The two |
There was a problem hiding this comment.
LGTM — dup2 returns the target fd on success, so != 0 was wrong for fds 1 and 2; < 0 is the correct check.
What was reviewed:
- Verified
dup2(2)semantics and that the retry loop already used< 0, so only the abort check was inconsistent. - Checked the other
dup2call sites insrc/jsc/bindings/bun-spawn.cpp— they already compare against-1, so this was the only instance of the bug class. - Test follows harness conventions:
await using, output asserted beforeexitCode, fd closed infinally, only one pipe so no drain deadlock, andO_PATH(0o10000000) is Linux-only so thedescribe.if(isLinux)gate is correct.
Extended reasoning...
Overview
This PR changes one comparison in bun_initialize_process() in src/jsc/bindings/c-bindings.cpp: the post-dup2 check goes from if (err != 0) abort() to if (err < 0) abort(). dup2(oldfd, newfd) returns newfd on success and -1 on failure, so the old check aborted on a successful redirect of fd 1 or 2. The path is reachable when isatty() returns EBADF for an occupied fd — which happens on Linux for O_PATH descriptors — because in that case open("/dev/null") does not land on the target slot and the dup2 branch actually runs. A Linux-gated test in test/js/bun/spawn/spawn.test.ts spawns Bun with an O_PATH fd on stdout, then stderr, and asserts the process prints to the other stream and exits 0.
Security risks
None. This is process-startup stdio hygiene; the change relaxes an over-strict success check on a syscall whose return semantics are well-defined. No untrusted input parsing, no auth/crypto/permissions surface.
Level of scrutiny
Low. The fix is a textbook errno-check correction that matches the EINTR retry loop's own < 0 condition two lines above and the == -1 checks at the sibling dup2 sites in bun-spawn.cpp. The added comment is load-bearing (explains the non-obvious return value). The test is placed in the existing spawn test file, uses bunExe()/bunEnv/await using, drains the single pipe concurrently with proc.exited, asserts output before exit code, and cleans up the fd in finally.
Other factors
Per REVIEW.md's "fix the whole bug class" rule, I grepped for other dup2 return-value checks in src/jsc/bindings/ — the two in bun-spawn.cpp already use == -1, so no siblings share the defect. The O_PATH constant (0o10000000 = 0x200000) matches the Linux fcntl.h value. The PR description's note about an unrelated pre-existing flaky test in the same file is disclosed and not touched here.
|
Updated 4:59 AM PT - Oct 3rd, 2026
✅ @robobun, your commit d4c1f4b883c17fc5f45b62c52e081fe4277a4af1 passed in 🧪 To try this PR locally: bunx bun-pr 41749That installs a local version of the PR into your bun-41749 --bun |
# Conflicts: # test/js/bun/spawn/spawn.test.ts
isatty() fails with EBADF on a closed fd and on an open O_PATH
descriptor. bun_initialize_process took both as closed and pointed the
slot at /dev/null with dup2. Confirm the EBADF with fcntl(F_GETFD) and
leave an open descriptor on its slot. A closed slot is the lowest free
number, so open("/dev/null") lands on it and the dup2 path, the spare
fd and both assertions go away.
There was a problem hiding this comment.
LGTM — the rewrite to probe with fcntl(F_GETFD) and open /dev/null only for a genuinely closed slot resolves both points from the earlier review, and the new mixed closed+O_PATH cells cover them.
What was reviewed:
- The "lower fds are all open, so
open()returns this fd" invariant inopenDevNullIfStdioIsClosed: holds on each of the three iterations since a slot is either left alone (fcntl succeeded) or just reopened; theabort()onopen()failure is the same outcome the olddup2(-1, fd)path had. - Behavior change for an O_PATH fd 0 (left in place, reads now fail with EBADF rather than reading
/dev/null): intentional, matches node, andbun_is_stdio_nullconsumers in the shell interpreter just inherit the fd in that case. - Test shape:
it.concurrent.eachmatrix, fd 3 report asserted together withexitCode/signalCode, resources released infinally; the stdout/stderr pipes only receive a few bytes so leaving them undrained cannot block the child.
Extended reasoning...
The diff replaces the setDevNullFd dup2 lambda in bun_initialize_process (src/jsc/bindings/c-bindings.cpp, POSIX-only) with a 15-line static helper that probes fcntl(F_GETFD) and opens /dev/null only when the fd is really closed, and adds one describe block with a 9-cell startup matrix to test/js/bun/spawn/spawn.test.ts. It touches process startup but no security-sensitive surface (no auth, parsing of untrusted data, or privilege boundaries). The two inline comments from the previous run (fd 1 O_PATH + fd 2 closed hitting the debug ASSERT; serial it instead of it.concurrent) are both addressed in the current code and explicitly covered by test cells. The change is small, the invariant reasoning is checkable by inspection, the function is called once before any thread starts, and the changed paths are not under CODEOWNERS, which decided approve over defer.
There was a problem hiding this comment.
I re-reviewed the reworked version and found no bugs; the earlier concern about the spare /dev/null fd landing on another stdio slot no longer applies since the dup2 path is gone, and the tests now run concurrently. Because this changes startup behavior for every bun process on Linux/macOS/FreeBSD (an O_PATH or otherwise-EBADF-but-open descriptor on fd 0-2 is now left in place instead of replaced), a human look at that policy choice is still worthwhile.
What was reviewed:
- Traced the lowest-free-fd assumption in
openDevNullIfStdioIsClosed: the loop handles 0,1,2 in order and each lower slot is either open, replaced, or aborted, soopen()must returnfd. - Checked the non-Linux arms: Darwin/FreeBSD reach the
fcntl(F_GETFD)recheck only for closed fds (or a revoked tty on FreeBSD, which now stays on its slot rather than being overwritten). - Test block: fd 3 report pattern matches the existing stdio[3] tests in the file, resources are released in
finally, O_PATH cells are Linux-only, and the O_PATH cells abort on an unfixed binary.
Extended reasoning...
The diff replaces the setDevNullFd lambda in bun_initialize_process (src/jsc/bindings/c-bindings.cpp) with a static helper that confirms EBADF via fcntl(F_GETFD) before opening /dev/null and aborts if the returned fd is not the target slot; the dup2 path, cached fd, and trailing close/ASSERT are deleted. It adds a nine-cell it.concurrent.each matrix to test/js/bun/spawn/spawn.test.ts covering closed and O_PATH stdio slots. No security-sensitive surface (auth, crypto, injection) is touched and no CODEOWNERS entry covers these files. Both of my earlier inline findings are addressed by the rework rather than patched around. Deferring rather than approving because the change alters process-startup semantics on all POSIX platforms for an open-but-EBADF descriptor, which is a behavior decision a maintainer should confirm.
Problem
O_PATHdescriptor on fd 1 or 2, every bun command dies withpanic: abort() called. Bun creates this state itself: afterfs.closeSync(1),fs.realpathSync()leaves one on fd 1, and a bun child that inherits it aborts.bun_initialize_process(src/jsc/bindings/c-bindings.cpp) readsisatty(fd) == 0 && errno == EBADFas "closed". An openO_PATHdescriptor gives the same error, and the code copies/dev/nullover it withdup2.Fix
openDevNullIfStdioIsClosedconfirms the EBADF withfcntl(fd, F_GETFD). Only a closed fd becomes/dev/null. An open descriptor stays, as in node 26.open("/dev/null")returns it. The spare fd, thedup2path and both assertions are gone.test/js/bun/spawn/spawn.test.ts(9 fd tables, bun 1.4.3 fails 7). All 27 tables of {pipe, closed, O_PATH}³ match node.Bun.$exit code 65527, not tracked.Background
bun_initialize_processruns first inmainand fills closed fds 0 to 2, so a lateropencannot take a stdio number.O_PATHdescriptor permits no I/O:read,writeandioctlfail with EBADF.Downsides
O_PATHfd 0 stays, so reads give EBADF, not EOF. On a directoryprocess.stdinthrows EISDIR until process.stdin: end instead of throwing EISDIR when fd 0 is a directory #41484.O_PATHfifo or socket on fd 1 or 2 reaches aprocess.stdoutdefect (node:fs: write stdio synchronously when the FileSink cannot be opened #41444) where startup aborted before.fcntlper closed stdio slot. Open stdio: 5 → 5 syscalls, 56 → 52 instructions, 701 → 622 bytes.Notes
How it was found. An automated stdio matrix (fd 0 to 2 of every kind, bun against node) plus a read of the
dup2check. No user reported it. The sameerr != 0defect was found once before, in #27041 (closed by the stale sweep after the Rust rewrite).Repro (bun 1.4.3-canary.1+367d939d9):
From bun alone, no
O_PATHin user code:isatty()isioctl(fd, TCGETS). For fd 1 =O_PATHthe old sequence wasioctl(1) = EBADF,openat("/dev/null") = 3,dup2(3, 1) = 1, thenif (err != 0) abort().dup2returns the target fd, so only fd 0 passed. Now it isioctl(1) = EBADF,fcntl(1, F_GETFD) = 0, and nothing else.The faces on main, one predicate
O_PATH: SIGABRT at startup (release and debug).O_PATH: no abort, the descriptor is replaced by/dev/null.O_PATHand fd 1 or 2 closed: the/dev/nullfd lands on the closed slot and is then copied over fd 0. The spare fd stays on a stdio number: a debug build failsASSERTION FAILED: devNullFd_ == -1 || devNullFd_ > 2, and in release that/dev/nullis not recorded inbun_is_stdio_null. With fd 1 and fd 2 both closed, release aborts too.The first version of this PR only changed
err != 0toerr < 0. A review found that this turned face 1 into face 2 and extended face 3 to "fd 1 =O_PATH, fd 2 closed". This version removes the cause instead.27 fd tables
{pipe, closed, O_PATH}³, fds 0 to 2 andfs.writeSyncresults compared with node 26 (CLOEXEC bit ignored):Syscalls over the same 27 tables, inside
bun_initialize_process: main issues adup2in 19 tables and opens/dev/null12 times on a number that was not a closed slot. This PR issues nodup2, oneopenper closed slot (27 of 27 land on the closed slot) and onefcntl(F_GETFD)per EBADF slot.Prior art. Probe with
fcntl(F_GETFD), open/dev/null, require the returned number to be the slot, otherwise stop: this is Go'sruntime.checkfds(src/runtime/fds_unix.go) and glibc'scheck_one_fd(csu/check_fds.c). Node probes withfstat()and copies/dev/nullover the slot withdup2whenopen()returns another number. Node added thatdup2in nodejs/node#44461, after its older open-and-compare-or-abort code aborted on FreeBSD: therefstat()gives EBADF for a revoked tty that still occupies the slot. Here the open runs only afterfcntl(F_GETFD)says the slot is free, andF_GETFDreads the descriptor table only, so an occupied slot never reaches the open.When
/dev/nullcannot be opened for a closed slot the process still aborts, as on main (there throughdup2(-1, fd)), as in node and Go. #27041 proposed to continue with the slot closed (for #15661, macOS App Sandbox). That leaves a stdio number free for the nextopen(#43844), so it is a separate decision and not part of this PR.Measurements
ioctl+openat→ioctl+fcntl+openat, at most 3 extrafcntlper process.O_PATHslot:openat+dup2+ abort →fcntlonly..text, release flags:bun_initialize_process547 + lambda 154 = 701 bytes →bun_initialize_process515 +openDevNullIfStdioIsClosed107 = 622 bytes. Measured by compilingc-bindings.cppwith the release compile command and running the ThinLTO backend on that module. For the unchanged file this gives 547 and 154, the same asnm -Son the release binary.bun_initialize_processper start (gdbnexticount): 56 → 52 with non-tty stdio, 101 → 93 with three ttys. The baseline is from the release binary. The new count is from a harness linked against the release-flag object, and the same harness gives 56 and 101 for the unchanged file.fcntlis in the helper, not in the loop condition. In the loop condition it cost two more instructions per start (one more callee-saved register).What happens with an
O_PATHdescriptor left on a slotbun --version,console.log,console.error, an uncaught exception: no crash, the write fails with EBADF and is dropped.fs.writeSync(1),Bun.write(Bun.stdout): EBADF.process.stdout.writeon anO_PATHfile, directory or character device: the callback and an'error'event get EBADF.O_PATHdirectory:process.stdinthrows EISDIR, the same asbun x.js < /tmpdoes today (process.stdin: end instead of throwing EISDIR when fd 0 is a directory #41484 makes both end like node). fd 0 =O_PATHfile:'error'EBADF.stdio: "inherit"gets the caller's descriptor.Open PRs that edit the same block. #37128 gets 3 conflict regions in
bun_initialize_processfrom this change (none against main). #37260 has 1 region in this file with or without it. #35477, #38843 and #39775 merge cleanly. Each of them still carries thesetDevNullFdlambda. When one is rebased, keepopenDevNullIfStdioIsClosed(fd)and drop the lambda,devNullFd_and the trailingASSERTandclose: the lambda is the abort. The new test block fails if it comes back.Not in this PR
process.stdout/process.stderrwhenBun.file(fd).writer()throws (anO_PATHfifo or socket):$assert(underlyingSink)ingetStdioWriteStreamfails on a debug build, and on release the writes after the first EBADF never call back. Main reaches this today withfs.closeSync(1); fs.openSync(fifo, O_PATH). node:fs: write stdio synchronously when the FileSink cannot be opened #41444 is the open PR for it.Bun.$builtinechoexits with 65527 when stdout is not writable (bun x.js 1</dev/nullon main). Found during this work, not tracked yet.nul.Self-review. Raised: say how the bug was found, list the open PRs in the same block with a resolution rule, cite #27041, state why Node has a
dup2here, link #41444 and #41484, run the closed-slot path on macOS (the two closed-only tables now run on every POSIX platform), and track theBun.$exit code. The last one is still open.Tests run.
bun bd test test/js/bun/spawn/spawn.test.tson the debug ASAN build: 187 pass, 8 skip, 0 fail, and the new block passes 9 of 9. On bun 1.4.3 the new block fails 7 of 9. The two closed-only tables pass there: they guard the rewritten closed path and also run on macOS. With node as the child all 9 expectations hold.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.test.ts