diff --git a/src/sys/lib.rs b/src/sys/lib.rs index cf908fe8fec6..20aa7668917f 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -3955,6 +3955,11 @@ mod windows_impl { // CRT-fd-backed `Fd` and ignores the directory/nofollow flags. super::openat_windows_a(dir, path.as_bytes(), flags, mode) } + /// `DuplicateHandle` with `bInheritHandle = FALSE`, the equivalent of the + /// POSIX arm's `F_DUPFD_CLOEXEC`: libuv spawns children with + /// `bInheritHandles = TRUE`, so an inheritable duplicate would leak into + /// every process spawned while it is open. Stdio handed to a child is + /// duplicated again (inheritable) by libuv itself, so nothing needs it. pub fn dup(fd: Fd) -> Maybe { // DuplicateHandle on the underlying HANDLE. let process = w::kernel32::GetCurrentProcess(); @@ -3966,7 +3971,7 @@ mod windows_impl { process, &mut target, 0, - w::TRUE, + w::FALSE, w::DUPLICATE_SAME_ACCESS, ) }; diff --git a/test/js/bun/spawn/spawn.test.ts b/test/js/bun/spawn/spawn.test.ts index 8bdca12b3567..fd2a2c30c56c 100644 --- a/test/js/bun/spawn/spawn.test.ts +++ b/test/js/bun/spawn/spawn.test.ts @@ -1,4 +1,5 @@ import { ArrayBufferSink, readableStreamToText, spawn, spawnSync } from "bun"; +import { dlopen } from "bun:ffi"; import { beforeAll, describe, expect, it } from "bun:test"; import { gcTick as _gcTick, @@ -12,6 +13,7 @@ import { isPosix, isWindows, shellExe, + tempDir, tmpdirSync, withoutAggressiveGC, } from "harness"; @@ -1312,6 +1314,84 @@ it.skipIf(isWindows)("leaves a Bun.file(fd) stdout open when stdin stream setup expect(exitCode).toBe(0); }); +// Bun.file(fd).stream() (like the shell's stdio and cwd handles) works on a +// dup() of the descriptor. On Windows that duplicate used to be created +// inheritable, and libuv spawns with bInheritHandles=TRUE, so every child +// started while one was open got a copy and kept the file open after the +// parent closed it. POSIX dup() uses F_DUPFD_CLOEXEC; the Windows side must match. +it.if(isWindows)("handles duplicated for Bun.file(fd).stream() are not inherited by children", async () => { + const N = 64; + // Bigger than the stream's high-water mark, so each reader parks on its + // duplicate instead of reading to EOF and closing it. + using dir = tempDir("spawn-dup-inherit", { "data.bin": Buffer.alloc(1024 * 1024) }); + + const k32 = dlopen("kernel32.dll", { + GetCurrentProcess: { args: [], returns: "ptr" }, + GetProcessHandleCount: { args: ["ptr", "ptr"], returns: "i32" }, + }); + const ownHandleCount = () => { + const out = new Uint32Array(1); + if (k32.symbols.GetProcessHandleCount(k32.symbols.GetCurrentProcess(), out) === 0) { + throw new Error("GetProcessHandleCount failed"); + } + return out[0]; + }; + + // The child reports how many handles it was started with. + const spawnHandleCounter = () => + spawn({ + cmd: [ + bunExe(), + "-e", + ` + import { dlopen } from "bun:ffi"; + const k32 = dlopen("kernel32.dll", { + GetCurrentProcess: { args: [], returns: "ptr" }, + GetProcessHandleCount: { args: ["ptr", "ptr"], returns: "i32" }, + }); + const out = new Uint32Array(1); + if (k32.symbols.GetProcessHandleCount(k32.symbols.GetCurrentProcess(), out) === 0) { + throw new Error("GetProcessHandleCount failed"); + } + console.log(out[0]); + `, + ], + env: bunEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + const reportedHandleCount = async (proc: ReturnType) => { + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + return Number(stdout.trim()); + }; + + const fds = Array.from({ length: N }, () => openSync(join(String(dir), "data.bin"), "r")); + const readers: ReadableStreamDefaultReader[] = []; + try { + // Plain descriptors are already non-inheritable; this child is the baseline. + await using control = spawnHandleCounter(); + + const before = ownHandleCount(); + for (const fd of fds) readers.push(Bun.file(fd).stream().getReader()); + // getReader() starts the stream, which dup()s the descriptor: the + // duplicates exist in this process while the next child is created. + expect(ownHandleCount() - before).toBeGreaterThanOrEqual(N); + await using withDuplicates = spawnHandleCounter(); + + const [controlCount, withDuplicatesCount] = await Promise.all([ + reportedHandleCount(control), + reportedHandleCount(withDuplicates), + ]); + // An inheritable dup() hands every one of the N duplicates to the child, + // so the difference used to be exactly N. + expect(withDuplicatesCount - controlCount).toBeLessThan(N / 2); + } finally { + await Promise.all(readers.map(reader => reader.cancel())); + for (const fd of fds) closeSync(fd); + } +}); + it.if(isWindows)("throws a spawn error for a cwd longer than the maximum path length", async () => { const fixture = ` try {