-
Notifications
You must be signed in to change notification settings - Fork 4.7k
Bun.spawn: don't close user-provided extra stdio fds #29621
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| import { test, expect } from "bun:test"; | ||
| import { bunEnv, bunExe, isPosix, tempDir } from "harness"; | ||
|
|
||
| // When a raw file descriptor number is passed in the stdio array at an | ||
| // index > 2, Bun.spawn does not own that fd and must not close it when the | ||
| // Subprocess is finalized. | ||
| test.skipIf(!isPosix)( | ||
| "Bun.spawn does not close user-provided extra stdio fds", | ||
| async () => { | ||
| using dir = tempDir("spawn-extra-fd", { | ||
| "run.js": ` | ||
| const { openSync, fstatSync, readFileSync } = require("node:fs"); | ||
| const path = require("node:path"); | ||
|
|
||
| const file = path.join(process.argv[2], "out.txt"); | ||
| const fd = openSync(file, "w"); | ||
|
|
||
| async function once() { | ||
| const proc = Bun.spawn({ | ||
| cmd: ["/bin/sh", "-c", "echo hi >&3"], | ||
| stdio: ["ignore", "ignore", "ignore", fd], | ||
| }); | ||
| await proc.exited; | ||
| } | ||
|
|
||
| for (let i = 0; i < 4; i++) { | ||
| await once(); | ||
| Bun.gc(true); | ||
| await Bun.sleep(1); | ||
| Bun.gc(true); | ||
| } | ||
|
|
||
| // If Bun had closed fd during finalization of one of the subprocesses | ||
| // above, the next fstat would fail with EBADF (or worse, the fd slot | ||
| // could have been reused by an unrelated file). | ||
| fstatSync(fd); | ||
|
|
||
| // Also make sure the child was actually able to write through fd 3 so | ||
| // we know the fd was wired up. | ||
| const contents = readFileSync(file, "utf8"); | ||
| if (!contents.includes("hi")) { | ||
| throw new Error("child did not write to fd 3: " + JSON.stringify(contents)); | ||
| } | ||
|
|
||
| console.log("ok"); | ||
| `, | ||
| }); | ||
|
|
||
| await using proc = Bun.spawn({ | ||
| cmd: [bunExe(), "run.js", String(dir)], | ||
| env: bunEnv, | ||
| cwd: String(dir), | ||
| stdout: "pipe", | ||
| stderr: "pipe", | ||
| }); | ||
|
|
||
| const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); | ||
|
|
||
| const stderrLines = stderr | ||
| .split("\n") | ||
| .filter(l => l && !l.startsWith("WARNING: ASAN interferes")) | ||
| .join("\n"); | ||
| expect(stderrLines).toBe(""); | ||
| expect(stdout.trim()).toBe("ok"); | ||
| expect(exitCode).toBe(0); | ||
| }, | ||
| 20000, | ||
|
Check warning on line 67 in test/js/bun/spawn/spawn-extra-fd-ownership.test.ts
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Nit: per Extended reasoning...WhatThe new test at Why this violates repo conventions
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 Step-by-step
ImpactThis 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. FixDrop the third argument: },
);instead of },
20000,
); |
||
| ); | ||
There was a problem hiding this comment.
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]returnnullinstead of the fd number for user-provided extra fds on POSIX (sincegetStdioat subprocess.zig:488-492 now seesinvalid_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 typereadonly stdio: [null, null, null, ...number[]]at packages/bun-types/bun.d.ts:7165 should be updated to allownullin the rest tuple.Extended reasoning...
What changed
Subprocess.getStdio(src/bun.js/api/bun/subprocess.zig:480-493) iteratesstdio_pipesand, on POSIX, pushesJSValue.jsNumber(item.cast())whenitem.isValid()andnullotherwise. Before this PR, when a raw fd number was passed atstdio[3+],spawnProcessPosixstored that fd directly inextra_fds→stdio_pipes, soproc.stdio[3]returned the fd number. After this PR,bun.invalid_fdis stored instead, soitem.isValid()is false andproc.stdio[3]returnsnull.Step-by-step proof
Bun.spawn({ stdio: ['ignore', 'ignore', 'ignore', 7] }).7is mapped toPosixSpawnOptions.Stdio{ .pipe = 7 }inoptions.extra_fds[0].spawnProcessPosix(process.zig:1509-1517) hits the.pipearm and now appendsbun.invalid_fdtoextra_fds(previously appended7).extra_fdsbecomesspawned.extra_pipes, which becomesSubprocess.stdio_pipes.proc.stdio→getStdioruns → for index 3,item == bun.invalid_fd,item.isValid()is false → pushes.null.proc.stdio[3] === 7. After:proc.stdio[3] === null.Why existing code doesn't prevent it
Nothing in
getStdiospecial-cases "user-provided fd that we don't own"; it only checksisValid(). The PR intentionally usesinvalid_fdas the sentinel for "slot exists but Bun doesn't own it", andgetStdiomaps that tonull.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
nullfor non-'pipe'stdio slots) and Bun's own Windows path (which already reported.unavailable→null). 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:
packages/bun-types/bun.d.ts:7165declaresreadonly stdio: [null, null, null, ...number[]], and the type-test fixture attest/integration/bun-types/fixture/spawn.ts:74expectsnumber | undefinedfor indices ≥ 3. With this change, those slots can benullon every platform, so the type should become something like[null, null, null, ...(number | null)[]].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 getnull. 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:7165toreadonly stdio: [null, null, null, ...(number | null)[]](and the corresponding type-test fixture), and add a sentence to the PR description noting thatproc.stdio[n]now returnsnullfor raw-fd slots on POSIX, matching Windows and Node.js.