Skip to content

spawn: don't close caller-owned fds passed as extra stdio#29606

Merged
cirospaciari merged 4 commits into
mainfrom
claude/spawn-extra-stdio-fd-ownership
Apr 22, 2026
Merged

spawn: don't close caller-owned fds passed as extra stdio#29606
cirospaciari merged 4 commits into
mainfrom
claude/spawn-extra-stdio-fd-ownership

Conversation

@cirospaciari

Copy link
Copy Markdown
Member

What does this PR do?

When a raw fd number is passed as stdio[N] (N≥3), that descriptor is owned by the caller, not by Bun.spawn. #29416 made finalizeStreams unconditionally close every entry in stdio_pipes, which on POSIX includes the caller's fd — so after the Subprocess is GC'd, the parent's fd is closed and the number gets recycled. Callers that cache an fd and reuse it across many spawns (e.g. a self-exec handle passed as stdio[3]) start seeing EACCES/EBADF once the first batch of subprocesses is collected.

Fix: store bun.invalid_fd in the extra_pipes slot for the .pipe case instead of the caller's fd. finalizeStreams and getStdio already skip invalid entries (added in #29416 for the IPC slot), and this keeps index alignment for the IPC channel lookup. Windows already records .unavailable here and was unaffected.

How did you verify your code works?

  • New test in test/js/bun/spawn/spawn.test.ts — opens an fd, passes it as stdio[3] across 8 spawns, GCs, then asserts fstatSync(fd) still succeeds and the fd is still usable as stdio[3] in a fresh spawn
  • bun bd test test/js/bun/spawn/spawn.test.ts

When a raw fd number is passed in stdio[3+], the descriptor belongs to
the caller. #29416 made finalizeStreams unconditionally close every
entry in stdio_pipes, which now includes that caller-owned fd — so once
the Subprocess is GC'd, the parent's fd is closed out from under it and
the number gets recycled for something else.

Store bun.invalid_fd in the slot instead (matching the IPC-neutralization
pattern from the same PR). finalizeStreams and getStdio already skip
invalid entries, and index alignment with the options array is preserved
for the IPC channel lookup.

Windows already records .unavailable for this case and was unaffected.
@robobun

robobun commented Apr 22, 2026

Copy link
Copy Markdown
Collaborator
Updated 3:05 PM PT - Apr 22nd, 2026

@cirospaciari, your commit 8bc5485 has 4 failures in Build #47324 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29606

That installs a local version of the PR into your bun-29606 executable, so you can run:

bun-29606 --bun

@coderabbitai

coderabbitai Bot commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9697fd8c-337b-43f4-84f6-01f5ff4e1317

📥 Commits

Reviewing files that changed from the base of the PR and between 4c736ab and 8bc5485.

📒 Files selected for processing (1)
  • test/js/bun/spawn/spawn.ipc.test.ts

Walkthrough

spawnProcessPosix now marks non-owned extra stdio slots with a sentinel (bun.invalid_fd) so caller-owned file descriptors are not tracked for cleanup; tests and TypeScript typings were updated to reflect that extra stdio entries may be null when not pipes.

Changes

Cohort / File(s) Summary
Process spawn fd tracking
src/bun.js/api/bun/process.zig
When handling extra stdio entries (.pipe, .inherit, .ignore, .path), spawnProcessPosix appends bun.invalid_fd to extra_fds for descriptors it does not own (.pipe still performs dup2, but tracking uses the invalid sentinel).
Runtime types
packages/bun-types/bun.d.ts
Changed Subprocess.readonly stdio from [null, null, null, ...number[]] to `[null, null, null, ...(number
Spawn close-handling test
test/js/bun/spawn/spawn.test.ts
Added a (skipped-on-Windows) test that passes a caller-owned fd as an extra stdio slot, verifies the parent does not gain ownership (slot is null), waits for child exit + GC, asserts the fd remains valid in parent, and verifies a child can access the fd.
IPC with extra non-pipe stdio
test/js/bun/spawn/spawn.ipc.test.ts
Imported bunEnv and added a test that spawns a child with stdio: ["inherit","inherit","inherit","ignore"] and serialization: mode; the test resolves via the ipc callback on message receipt and fails if the child exits before IPC delivery (no explicit child kill performed).
Type-assertion fixture
test/integration/bun-types/fixture/spawn.ts
Adjusted TypeScript assertion for proc.stdio[3] to expect `number
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: preventing closure of caller-owned file descriptors passed as extra stdio.
Description check ✅ Passed The description includes both required sections: 'What does this PR do?' explains the problem and fix, and 'How did you verify your code works?' lists test details.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

Warning

Review ran into problems

🔥 Problems

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.


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

Comment thread src/bun.js/api/bun/process.zig
Comment thread src/bun.js/api/bun/process.zig
Follow-up from review:
- .inherit/.ignore/.path arms of the extra_fds loop now also append
  bun.invalid_fd so spawned.extra_pipes is 1:1 with options.extra_fds,
  matching Windows. Fixes an OOB read of the IPC fd when a non-pipe
  extra slot precedes the IPC channel.
- Subprocess.stdio type widened to (number | null)[] for slots >=3
  since raw-fd / inherit / ignore / path inputs now surface as null
  (Node.js semantics; Windows already did this).
@cirospaciari cirospaciari requested a review from alii as a code owner April 22, 2026 18:59
Comment thread test/js/bun/spawn/spawn.ipc.test.ts

@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.

LGTM — all prior feedback addressed (type updated, .inherit/.ignore/.path arms now aligned, test uses await using).

Extended reasoning...

Overview

This PR fixes a regression from #29416 where finalizeStreams would close caller-owned fds passed as stdio[N] (N≥3). The fix stores bun.invalid_fd as a sentinel in extra_pipes for slots that Bun does not own (.pipe, .inherit, .ignore, .path), so finalizeStreams and getStdio skip them. The change touches spawnProcessPosix in process.zig (~6 lines), updates the published Subprocess.stdio type in bun.d.ts to ...(number | null)[], and adds two regression tests plus a type-fixture update.

Security risks

None. The change narrows what Bun closes — it stops closing descriptors it doesn't own, which is strictly safer than the prior behavior. No new fds are opened or exposed; the sentinel is never passed to a syscall.

Level of scrutiny

Moderate — process spawning is load-bearing, but the edit is mechanical and mirrors the Windows path, which already records .unavailable for these slots. I verified the two downstream consumers: finalizeStreams (subprocess.zig:752) and getStdio (subprocess.zig:488-491) both branch on item.isValid() and skip the sentinel, and the IPC lookup at js_bun_spawn_bindings.zig:681/798 now indexes correctly because every options.extra_fds arm appends exactly one entry to the result array.

Other factors

I left three comments on earlier revisions; all have been addressed: the bun.d.ts type was widened (with the fixture updated), the .inherit/.ignore/.path arms now also append the sentinel (with a new IPC alignment test in spawn.ipc.test.ts), and the IPC test was switched to await using so the child is reaped on failure. The new spawn.test.ts case directly exercises the original bug (fd reused across 8 spawns + GC). No bugs flagged by the bug-hunting system on the current revision.

@cirospaciari cirospaciari merged commit 890ef5a into main Apr 22, 2026
64 of 65 checks passed
@cirospaciari cirospaciari deleted the claude/spawn-extra-stdio-fd-ownership branch April 22, 2026 20:32
structwafel pushed a commit to structwafel/bun that referenced this pull request Apr 25, 2026
)

## What does this PR do?

When a raw fd number is passed as `stdio[N]` (N≥3), that descriptor is
owned by the caller, not by `Bun.spawn`. oven-sh#29416 made `finalizeStreams`
unconditionally close every entry in `stdio_pipes`, which on POSIX
includes the caller's fd — so after the `Subprocess` is GC'd, the
parent's fd is closed and the number gets recycled. Callers that cache
an fd and reuse it across many spawns (e.g. a self-exec handle passed as
`stdio[3]`) start seeing `EACCES`/`EBADF` once the first batch of
subprocesses is collected.

Fix: store `bun.invalid_fd` in the `extra_pipes` slot for the `.pipe`
case instead of the caller's fd. `finalizeStreams` and `getStdio`
already skip invalid entries (added in oven-sh#29416 for the IPC slot), and
this keeps index alignment for the IPC channel lookup. Windows already
records `.unavailable` here and was unaffected.

## How did you verify your code works?

- [ ] New test in `test/js/bun/spawn/spawn.test.ts` — opens an fd,
passes it as `stdio[3]` across 8 spawns, GCs, then asserts
`fstatSync(fd)` still succeeds and the fd is still usable as `stdio[3]`
in a fresh spawn
- [ ] `bun bd test test/js/bun/spawn/spawn.test.ts`
xhjkl pushed a commit to xhjkl/bun that referenced this pull request May 14, 2026
)

## What does this PR do?

When a raw fd number is passed as `stdio[N]` (N≥3), that descriptor is
owned by the caller, not by `Bun.spawn`. oven-sh#29416 made `finalizeStreams`
unconditionally close every entry in `stdio_pipes`, which on POSIX
includes the caller's fd — so after the `Subprocess` is GC'd, the
parent's fd is closed and the number gets recycled. Callers that cache
an fd and reuse it across many spawns (e.g. a self-exec handle passed as
`stdio[3]`) start seeing `EACCES`/`EBADF` once the first batch of
subprocesses is collected.

Fix: store `bun.invalid_fd` in the `extra_pipes` slot for the `.pipe`
case instead of the caller's fd. `finalizeStreams` and `getStdio`
already skip invalid entries (added in oven-sh#29416 for the IPC slot), and
this keeps index alignment for the IPC channel lookup. Windows already
records `.unavailable` here and was unaffected.

## How did you verify your code works?

- [ ] New test in `test/js/bun/spawn/spawn.test.ts` — opens an fd,
passes it as `stdio[3]` across 8 spawns, GCs, then asserts
`fstatSync(fd)` still succeeds and the fd is still usable as `stdio[3]`
in a fresh spawn
- [ ] `bun bd test test/js/bun/spawn/spawn.test.ts`
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants