Skip to content

Bun.spawn: don't close user-provided extra stdio fds#29621

Closed
robobun wants to merge 1 commit into
mainfrom
farm/66e73898/spawn-extra-fd-ownership
Closed

Bun.spawn: don't close user-provided extra stdio fds#29621
robobun wants to merge 1 commit into
mainfrom
farm/66e73898/spawn-extra-fd-ownership

Conversation

@robobun

@robobun robobun commented Apr 23, 2026

Copy link
Copy Markdown
Collaborator

What

When a raw file descriptor number is passed in the stdio array at an index > 2 (e.g. stdio: ['ignore', 'ignore', 'ignore', fd]), that fd is owned by the caller, not by Bun.spawn. Previously the fd was appended to the subprocess's extra_pipes list and closed in finalizeStreams() when the Subprocess was garbage-collected.

Repro

const fd = openSync('out.txt', 'w');
for (let i = 0; i < 4; i++) {
  const proc = Bun.spawn({
    cmd: ['/bin/sh', '-c', 'echo hi >&3'],
    stdio: ['ignore', 'ignore', 'ignore', fd],
  });
  await proc.exited;
  Bun.gc(true);
}
// second iteration throws: EBADF: bad file descriptor, posix_spawn

Cause

spawnProcessPosix stored the user's fd directly in extra_fdsstdio_pipes, and Subprocess.finalizeStreams() closes every valid fd in that list. For stdio[0..2] a raw fd is handled via Readable/Writable which explicitly do not close the .fd variant; the extra-fd path was inconsistent with that.

Fix

Store bun.invalid_fd in the slot instead, so the positional entry is preserved for subprocess.stdio but finalizeStreams() skips the close. This matches:

  • the existing behaviour for raw fds at stdio[0..2]
  • the Windows path, which already reports these slots as .unavailable

Verification

  • New test test/js/bun/spawn/spawn-extra-fd-ownership.test.ts fails on main with EBADF: bad file descriptor, posix_spawn and passes with this change.
  • bun bd test test/js/bun/spawn/spawn.ipc.test.ts passes (IPC uses the same extra_fds array via socketpair — those fds are still owned and closed correctly).
  • bun run zig:check-all passes on all targets.

When a raw file descriptor number is passed in the stdio array at an
index > 2, that fd is owned by the caller, not by Bun.spawn. Previously
the fd was appended to the subprocess's extra_pipes list and closed in
finalizeStreams() when the Subprocess was garbage-collected, leading to
EBADF on subsequent use of the fd (or silent corruption if the slot was
reused).

Store invalid_fd in the slot instead so the positional entry is
preserved for subprocess.stdio but finalizeStreams() skips the close.
This matches the existing behaviour for raw fds at stdio[0..2] and the
Windows path for extra fds.
@robobun

robobun commented Apr 23, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:54 PM PT - Apr 22nd, 2026

@robobun, your commit 7dce7a5 is building: #47397

@cirospaciari

Copy link
Copy Markdown
Member

a fix for this is already merged on main

@coderabbitai

coderabbitai Bot commented Apr 23, 2026

Copy link
Copy Markdown
Contributor

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8dfa452c-cbeb-439b-96a1-eb5605a7cb66

📥 Commits

Reviewing files that changed from the base of the PR and between e194566 and 7dce7a5.

📒 Files selected for processing (2)
  • src/bun.js/api/bun/process.zig
  • test/js/bun/spawn/spawn-extra-fd-ownership.test.ts

Disabled knowledge base sources:

  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.


Walkthrough

Modified POSIX spawn handling to prevent Bun from taking ownership of extra file descriptors when stdio is .pipe. The change records bun.invalid_fd instead of the caller-supplied fd, maintaining positional mapping for subprocess.stdio while avoiding closure of user-provided file descriptors. A test validates this behavior.

Changes

Cohort / File(s) Summary
POSIX spawn file descriptor ownership
src/bun.js/api/bun/process.zig
Modified to record bun.invalid_fd instead of the provided fd for extra stdio entries when .pipe is used, preventing Bun from taking ownership of caller-supplied file descriptors during stream finalization.
Extra stdio file descriptor ownership test
test/js/bun/spawn/spawn-extra-fd-ownership.test.ts
Added POSIX-only test validating that Bun.spawn does not claim ownership of raw extra stdio file descriptors; spawns subprocess with extra fd and verifies it remains accessible after garbage collection.

Possibly related PRs

Suggested reviewers

  • alii
  • dylan-conway

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

Comment on lines 1511 to +1517
try actions.dup2(fd, fileno);

try extra_fds.append(fd);
// This file descriptor was passed in by the user (e.g. a
// number in the stdio array). It is not owned by Bun.spawn,
// so we must not close it in finalizeStreams. Store
// invalid_fd to keep the positional slot for
// subprocess.stdio without taking ownership.
try extra_fds.append(bun.invalid_fd);

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.

🟡 This change makes proc.stdio[n] return null instead of the fd number for user-provided extra fds on POSIX (since getStdio at subprocess.zig:488-492 now sees invalid_fd). This is arguably the correct behavior — it matches Node.js and the existing Windows path — but it's an observable change worth calling out, and the TypeScript type readonly stdio: [null, null, null, ...number[]] at packages/bun-types/bun.d.ts:7165 should be updated to allow null in the rest tuple.

Extended reasoning...

What changed

Subprocess.getStdio (src/bun.js/api/bun/subprocess.zig:480-493) iterates stdio_pipes and, on POSIX, pushes JSValue.jsNumber(item.cast()) when item.isValid() and null otherwise. Before this PR, when a raw fd number was passed at stdio[3+], spawnProcessPosix stored that fd directly in extra_fdsstdio_pipes, so proc.stdio[3] returned the fd number. After this PR, bun.invalid_fd is stored instead, so item.isValid() is false and proc.stdio[3] returns null.

Step-by-step proof

  1. User calls Bun.spawn({ stdio: ['ignore', 'ignore', 'ignore', 7] }).
  2. The fd 7 is mapped to PosixSpawnOptions.Stdio{ .pipe = 7 } in options.extra_fds[0].
  3. spawnProcessPosix (process.zig:1509-1517) hits the .pipe arm and now appends bun.invalid_fd to extra_fds (previously appended 7).
  4. extra_fds becomes spawned.extra_pipes, which becomes Subprocess.stdio_pipes.
  5. JS reads proc.stdiogetStdio runs → for index 3, item == bun.invalid_fd, item.isValid() is false → pushes .null.
  6. Before: proc.stdio[3] === 7. After: proc.stdio[3] === null.

Why existing code doesn't prevent it

Nothing in getStdio special-cases "user-provided fd that we don't own"; it only checks isValid(). The PR intentionally uses invalid_fd as the sentinel for "slot exists but Bun doesn't own it", and getStdio maps that to null.

Addressing the refutation ("this is intentional, not a bug")

The refuter is right that this is the intended direction of the fix and aligns with both Node.js (which returns null for non-'pipe' stdio slots) and Bun's own Windows path (which already reported .unavailablenull). The previous return value was also dangerous: it echoed back an fd that Bun was about to close on GC, so any code relying on it was already racy/broken. And no information is lost — the user already has the fd they passed in. I agree this should not block the PR.

However, two things make it worth a comment rather than silence:

  • The TypeScript type is now wrong. packages/bun-types/bun.d.ts:7165 declares readonly stdio: [null, null, null, ...number[]], and the type-test fixture at test/integration/bun-types/fixture/spawn.ts:74 expects number | undefined for indices ≥ 3. With this change, those slots can be null on every platform, so the type should become something like [null, null, null, ...(number | null)[]].
  • It's a real observable change on POSIX. The PR description says the positional entry is "preserved" but doesn't mention the value changes from the fd number to null. Worth a one-line note for anyone scanning the changelog.

Impact

Low. Existing POSIX code that did const fd = proc.stdio[3] to read back the fd it just passed in will now get null. Since the user already has that fd, the workaround is trivial (use the value you passed in). The type mismatch is the more concrete actionable item.

Suggested fix

Update packages/bun-types/bun.d.ts:7165 to readonly stdio: [null, null, null, ...(number | null)[]] (and the corresponding type-test fixture), and add a sentence to the PR description noting that proc.stdio[n] now returns null for raw-fd slots on POSIX, matching Windows and Node.js.

expect(stdout.trim()).toBe("ok");
expect(exitCode).toBe(0);
},
20000,

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.

🟡 Nit: per test/CLAUDE.md ("CRITICAL: Do not set a timeout on tests. Bun already has timeouts."), the explicit 20000 timeout argument should be removed — the harness already manages per-test timeouts.

Extended reasoning...

What

The new test at test/js/bun/spawn/spawn-extra-fd-ownership.test.ts:67 passes 20000 as the third argument to test(), setting an explicit 20-second timeout.

Why this violates repo conventions

test/CLAUDE.md:120 states:

CRITICAL: Do not set a timeout on tests. Bun already has timeouts.

The test harness in this repo manages timeouts centrally, and explicit per-test timeouts have historically caused friction (drift between local and CI, masking hangs vs. genuine slowness, etc.). None of the other tests in test/js/bun/spawn/ set explicit timeouts, so this also breaks local consistency.

Step-by-step

  1. test(name, fn, 20000) is called at line 67.
  2. bun:test records 20000 ms as the per-test timeout, overriding the harness default.
  3. The repo guidance explicitly forbids step 2.

Impact

This is purely a convention/style issue — the test itself is functionally correct and the 20s value is generous enough that it won't cause flakes. Filing as a nit.

Fix

Drop the third argument:

  },
);

instead of

  },
  20000,
);

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.

2 participants