From cf08bf112f6b61aead736a1a57eb287f5c3f6091 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 9 Jul 2026 08:09:04 +0000 Subject: [PATCH 1/4] Bun.spawn: don't double-close extra stdio fds exposed via .stdio 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). --- src/runtime/api/bun/subprocess.rs | 11 +++++++++ src/spawn_sys/spawn_process.rs | 5 ++-- test/js/bun/spawn/spawn.test.ts | 39 +++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/runtime/api/bun/subprocess.rs b/src/runtime/api/bun/subprocess.rs index 26bb1f23b5ac..1f283326ca9a 100644 --- a/src/runtime/api/bun/subprocess.rs +++ b/src/runtime/api/bun/subprocess.rs @@ -860,6 +860,17 @@ impl Subprocess<'_> { } } } + // The raw fd numbers are now visible to JS and the caller owns them. + // Downgrade so finalize_streams never closes a number JS may have + // already closed (whose value the kernel may have since recycled). + #[cfg(not(windows))] + this.stdio_pipes.with_mut(|pipes| { + for slot in pipes.iter_mut() { + if let ExtraPipe::OwnedFd(fd) = *slot { + *slot = ExtraPipe::UnownedFd(fd); + } + } + }); Ok(array) } diff --git a/src/spawn_sys/spawn_process.rs b/src/spawn_sys/spawn_process.rs index e89ace7df911..68c61877d544 100644 --- a/src/spawn_sys/spawn_process.rs +++ b/src/spawn_sys/spawn_process.rs @@ -461,8 +461,9 @@ pub struct PosixSpawnResult { /// Entry in `extra_pipes` for a stdio slot at index >= 3. pub enum ExtraPipe { - /// We created this fd (e.g. socketpair for `"pipe"`); expose it via - /// `Subprocess.stdio[N]` and close it in `finalizeStreams`. + /// We created this fd (e.g. socketpair for `"pipe"`); `finalizeStreams` + /// closes it. Downgraded to `UnownedFd` once `.stdio` is read (the caller + /// then owns the raw number and is responsible for closing it). OwnedFd(Fd), /// The caller supplied this fd in the stdio array; expose it via /// `Subprocess.stdio[N]` but never close it — the caller retains ownership. diff --git a/test/js/bun/spawn/spawn.test.ts b/test/js/bun/spawn/spawn.test.ts index df0a3b8cb762..2fed8247f00c 100644 --- a/test/js/bun/spawn/spawn.test.ts +++ b/test/js/bun/spawn/spawn.test.ts @@ -1033,6 +1033,45 @@ describe("close handling", () => { expect(() => fstatSync(fd as number)).toThrow(expect.objectContaining({ code: "EBADF" })); }, ); + + it.skipIf(isWindows)("'pipe' at index >= 3: reading .stdio transfers fd ownership to the caller", async () => { + // Once .stdio exposes the raw fd number, JS owns it; the Subprocess + // finalizer must not close that number again at GC time (the kernel may + // have recycled it). Run in a child so a debug abort shows as exit != 0. + const fixture = /* js */ ` + const fs = require("node:fs"); + let hits = 0; + for (let i = 0; i < 4; i++) { + let p = Bun.spawn({ + cmd: ["/bin/sh", "-c", "printf hi >&3"], + stdio: ["ignore", "ignore", "ignore", "pipe"], + }); + await p.exited; + const fd = p.stdio[3]; + if (typeof fd !== "number") throw new Error("stdio[3] not a number: " + fd); + const b = Buffer.alloc(8); + if (fs.readSync(fd, b) !== 2 || b.subarray(0, 2).toString() !== "hi") + throw new Error("stdio[3] unreadable"); + fs.closeSync(fd); + const victim = fs.openSync(process.execPath, "r"); + p = null; + Bun.gc(true); + await Bun.sleep(0); + Bun.gc(true); + try { fs.fstatSync(victim); } catch { hits++; } + try { fs.closeSync(victim); } catch {} + } + if (hits) throw new Error("finalizer closed " + hits + "/4 recycled fds"); + console.log("PASS"); + `; + await using proc = spawn({ + cmd: [bunExe(), "-e", fixture], + env: bunEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ stdout: "PASS", stderr: "", exitCode: 0 }); + }); }); }); From 865cf65a3c84af2cf26f9cf5a779850f66ef0e66 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 9 Jul 2026 08:19:24 +0000 Subject: [PATCH 2/4] bun.d.ts: update Subprocess.stdio doc for 'pipe' ownership transfer --- packages/bun-types/bun.d.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 68ab17c234b7..657f94653b07 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -7220,10 +7220,10 @@ declare module "bun" { * * Entries beyond index 2 are `number` for `"pipe"` and `"socket-fd"` slots and, * on POSIX, for slots where a raw file descriptor was supplied (the same fd is - * returned). For `"pipe"`, the subprocess owns and closes the fd. For - * `"socket-fd"` and raw-fd slots, the fd remains owned by the caller and is - * never closed by the subprocess. Other slots — including raw fds on Windows — - * are `null`. + * returned). Reading this property transfers ownership of any `"pipe"` fds to + * the caller, who is then responsible for closing them; the subprocess will + * not close them. `"socket-fd"` and raw-fd slots are likewise caller-owned. + * Other slots — including raw fds on Windows — are `null`. */ readonly stdio: [null, null, null, ...(number | null)[]]; From d4c1f5e08e80499e036da6225adce00a1a1d043e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 9 Jul 2026 08:29:20 +0000 Subject: [PATCH 3/4] bun.d.ts: qualify Subprocess.stdio ownership transfer as POSIX-only --- packages/bun-types/bun.d.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 657f94653b07..979087413bc5 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -7220,10 +7220,10 @@ declare module "bun" { * * Entries beyond index 2 are `number` for `"pipe"` and `"socket-fd"` slots and, * on POSIX, for slots where a raw file descriptor was supplied (the same fd is - * returned). Reading this property transfers ownership of any `"pipe"` fds to - * the caller, who is then responsible for closing them; the subprocess will - * not close them. `"socket-fd"` and raw-fd slots are likewise caller-owned. - * Other slots — including raw fds on Windows — are `null`. + * returned). On POSIX, reading this property transfers ownership of any + * `"pipe"` fds to the caller, who is then responsible for closing them; the + * subprocess will not close them. `"socket-fd"` and raw-fd slots are likewise + * caller-owned. Other slots — including raw fds on Windows — are `null`. */ readonly stdio: [null, null, null, ...(number | null)[]]; From 1d5f386372c97644a012180d0cf0d8ad205c41b6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 9 Jul 2026 08:49:47 +0000 Subject: [PATCH 4/4] ci: retrigger