Bun.spawn: don't double-close extra stdio fds exposed via .stdio#33828
Bun.spawn: don't double-close extra stdio fds exposed via .stdio#33828robobun wants to merge 4 commits into
Conversation
With stdio[N>=3] = "pipe", Bun.spawn creates a socketpair and stores the parent end as ExtraPipe::OwnedFd. The .stdio getter exposes that fd as a raw number to JS, but Subprocess::finalize_streams would still close the same number at GC time. If the caller had already closed it (the only way to release it or signal EOF), the kernel may have recycled the value for an unrelated fd, which the finalizer then silently destroys. Downgrade OwnedFd -> UnownedFd in the .stdio getter so the caller becomes the sole owner once the number is observed. If .stdio is never read, finalize_streams still closes the fd (no leak).
WalkthroughThis PR clarifies and enforces fd ownership semantics when reading Subprocess.stdio on POSIX. get_stdio now downgrades OwnedFd entries to UnownedFd after exposing raw fd numbers, so finalizeStreams no longer closes caller-owned fds. Comments, type docs, and a regression test were updated accordingly. ChangesFD Ownership Downgrade on stdio Access
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Reproduced on 1.4.0 release and current main: with Fix: the Test added in CI status: the diff is green on every test lane that ran in build 70933 (all linux/musl/asan/alpine, darwin-x64, all windows). The build is marked failing only because two |
|
Updated 4:08 AM PT - Jul 9th, 2026
✅ @robobun, your commit 1d5f386372c97644a012180d0cf0d8ad205c41b6 passed in 🧪 To try this PR locally: bunx bun-pr 33828That installs a local version of the PR into your bun-33828 --bun |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
Not a duplicate of #31833. That PR's This PR fixes the |
There was a problem hiding this comment.
I didn't find any bugs, but this changes fd-ownership semantics (reading .stdio now transfers ownership of "pipe" extra fds to the caller, so unread → closed at GC, read-but-not-closed → leak) — worth a quick human sanity check on that trade-off.
Extended reasoning...
Overview
Fixes a double-close of extra stdio fds (index ≥ 3, "pipe") in Bun.spawn. The .stdio getter exposes the parent-side socketpair fd as a raw number; previously finalize_streams would close that same number at GC time, so if JS had already closed it and the kernel recycled the value, the finalizer would silently destroy an unrelated fd (or trip a debug assert). The fix downgrades ExtraPipe::OwnedFd → UnownedFd inside get_stdio after the array is built, so once JS observes the number it becomes the sole owner. A doc comment on ExtraPipe::OwnedFd is updated and a subprocess-based regression test is added.
Touches src/runtime/api/bun/subprocess.rs (getter), src/spawn_sys/spawn_process.rs (comment only), and test/js/bun/spawn/spawn.test.ts.
Security risks
None. This is fd-lifecycle bookkeeping local to the spawning process; no auth/crypto/untrusted-input parsing is involved. The bug being fixed is itself the riskier behavior (closing a recycled fd out from under unrelated code).
Level of scrutiny
Medium-high. The diff is small and mechanically correct — the downgrade loop runs after the fallible array.push(...)? calls (so a mid-loop throw leaves ownership with the finalizer), stdio is not a cached getter in BunObject.classes.ts so re-reads are fine (idempotent), and it's #[cfg(not(windows))]-gated to match finalize_streams. It also mirrors the existing ObservableGetter transfer-on-read pattern for stdin/stdout/stderr and the "socket-fd" return-time downgrade.
That said, this is native fd-ownership code in the spawn path — the exact area CLAUDE.md flags as most-blocked — and it introduces a user-visible semantic: code that reads .stdio for a "pipe" extra slot but never closes the fd will now leak it, whereas before GC would eventually close it. That trade-off (leak vs. double-close-on-recycled-fd) is clearly the right call, but it's a design point a maintainer should nod at rather than have auto-approved.
Other factors
- No bugs from the bug-hunting system.
- Test looks solid: runs in a child so a debug abort surfaces as exit ≠ 0, verifies the fd is actually readable, and probes for the recycled-fd close over 4 iterations. It follows the same
{stdout, stderr, exitCode}assertion shape as neighboring tests in the file. - No CODEOWNERS entries for these paths.
|
On the trade-off: the old behavior had no correct usage. If the caller closed the fd (the only way to release it or signal EOF to the child), GC double-closed a possibly-recycled number; if the caller never closed, the child never saw EOF until an arbitrary GC. A caller that reads The alternative (expose a proper stream object at Updated the JSDoc on |
There was a problem hiding this comment.
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/api/bun/subprocess.rs`:
- Around line 863-873: The `stdio_pipes.with_mut` loop in `subprocess.rs` is
trying to move an `ExtraPipe` out of `&mut ExtraPipe`, which will not compile
because `ExtraPipe` is not `Copy`. Update the downgrade logic to replace each
`slot` in place using `mem::replace` in the `ExtraPipe::OwnedFd` case, and keep
the `OwnedFd` to `UnownedFd` transition inside the same `this.stdio_pipes`
block.
🪄 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: 79822d1c-e073-435c-85ac-27afe49dcac2
📒 Files selected for processing (4)
packages/bun-types/bun.d.tssrc/runtime/api/bun/subprocess.rssrc/spawn_sys/spawn_process.rstest/js/bun/spawn/spawn.test.ts
There was a problem hiding this comment.
Doc nit addressed — the fix itself looks correct and idempotent, but since it changes the ownership contract of a public API (fd now leaks if the caller reads .stdio and forgets to close) and you flagged an alternative design, I'd like a maintainer to sign off on the transfer-on-getter approach.
Extended reasoning...
Overview
Fixes a double-close in Bun.spawn: when stdio[N>=3] = "pipe", the .stdio getter exposes the parent-end socketpair fd as a raw number, but finalize_streams still closed that same number at GC time. If JS had already closed it, the finalizer would close a recycled, unrelated fd. The fix downgrades ExtraPipe::OwnedFd → UnownedFd inside get_stdio after the array is built, so the finalizer skips those slots once JS has observed them. Touches src/runtime/api/bun/subprocess.rs (11 lines added to get_stdio), a doc-comment update in src/spawn_sys/spawn_process.rs, JSDoc in packages/bun-types/bun.d.ts, and a new POSIX-only regression test in test/js/bun/spawn/spawn.test.ts.
Security risks
None introduced. This is strictly a fd-lifetime tightening: it removes a path where the finalizer could close an unrelated recycled fd (which is itself a mild security/correctness hazard). No auth, crypto, or untrusted-input parsing is involved.
Level of scrutiny
Medium-high. The runtime change is small and follows the existing ObservableGetter pattern used for stdin/stdout/stderr, and node:child_process is unaffected (it maps extra "pipe" slots to "socket-fd", which is already UnownedFd). The downgrade is placed after all fallible array.push(?) calls, so an early-return error path leaves ownership with the finalizer (no leak), and repeated .stdio reads are idempotent since the getter is not cached but the loop finds no OwnedFd on the second pass. However, this is native fd-lifetime code and it changes the public ownership contract of Subprocess.stdio — a caller who reads .stdio and never closes the fd now leaks it, whereas before the finalizer would (unsafely) close it.
Other factors
My earlier nit (POSIX-qualify the JSDoc since the downgrade is #[cfg(not(windows))]-gated) was addressed in d4c1f5e and the thread is resolved. The author explicitly offered an alternative design (expose a proper stream object at stdio[i>=3] instead of a raw number) and asked for a preference — that's an API-design call a maintainer should make rather than a bot. The test looks solid (fails on unfixed build per the PR description) and CI is building.
There was a problem hiding this comment.
Doc nit is addressed — deferring to a human on the API-semantics choice (getter-side-effect ownership transfer vs. exposing a stream object), since you flagged that alternative yourself and it's a fd-lifecycle change in native Subprocess code.
Extended reasoning...
Overview
Four files: an 11-line addition to get_stdio in src/runtime/api/bun/subprocess.rs that downgrades ExtraPipe::OwnedFd → UnownedFd after the raw fd numbers are exposed to JS (POSIX-only), a comment update on the ExtraPipe::OwnedFd variant in src/spawn_sys/spawn_process.rs, a JSDoc rewording on Subprocess.stdio in bun.d.ts, and a new subprocess-in-subprocess regression test in spawn.test.ts. My earlier inline comment (that the ownership-transfer sentence needed a POSIX qualifier to match the #[cfg(not(windows))] gate) was addressed in d4c1f5e and the thread is resolved.
Security risks
None apparent. This is fd-lifecycle bookkeeping for parent-side socketpair ends created by Bun.spawn; no auth, crypto, path handling, or untrusted-input parsing is touched. The change strictly reduces the finalizer's authority (it stops closing an fd number once JS has observed it), so the failure mode moves from "double-close a possibly-recycled fd" to "leak one fd if the caller forgets to close" — safer, not riskier.
Level of scrutiny
Medium-high. The code change itself is small and well-reasoned, and the test demonstrates the bug on an unfixed build. But it sits in native Subprocess fd-ownership code (the most-blocked review category per CLAUDE.md), and it introduces a user-visible API semantics change: reading the .stdio getter now has a side effect (ownership transfer). The author explicitly surfaced a design alternative — exposing a proper stream object at stdio[i>=3] instead of a raw number — and said "happy to go that route instead if preferred." That's a maintainer-level call I shouldn't make on their behalf.
Other factors
The bug hunter found nothing, CodeRabbit's mem::replace concern was withdrawn after confirming Fd is Copy, and the new test follows harness conventions (subprocess fixture, drains all pipes concurrently, asserts combined {stdout, stderr, exitCode}). The test asserts stderr: "" exactly, which CLAUDE.md flags as fragile under ASAN/debug, but that's a minor style point rather than a blocker. Net: the implementation looks correct for the chosen approach; a human should just confirm the approach.
What
With
stdio[N>=3] = "pipe",Bun.spawncreates a socketpair and stores the parent end asExtraPipe::OwnedFd. The.stdiogetter exposes that fd as a raw number to JS, butSubprocess::finalize_streamsstill closed the same number at GC time.If the caller had already closed it (the only way to release it or signal EOF to the child), the kernel may have recycled the value for an unrelated fd, which the finalizer then silently destroys. On debug-assert builds the second close trips
debug_assert!(err.is_none())inFdExt::close.Reproduction
Fix
Downgrade
OwnedFdtoUnownedFdin the.stdiogetter so the caller becomes the sole owner once the number is observed. If.stdiois never read,finalize_streamsstill closes the fd (no leak). This mirrors the ownership-transfer-on-getter pattern already used for stdin/stdout/stderr viaObservableGetter, and matches what"socket-fd"(used bynode:child_process) already does at return time.Verification
bun bd test test/js/bun/spawn/spawn.test.ts -t "'pipe' at index >= 3"passesfinalizer closed 3/4 recycled fdsclose handlingdescribe (83 tests) passesno test proof · iteration 1 · 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