From f63829379f194ec975e5664f57648cab9c717756 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:27:33 +0000 Subject: [PATCH 1/4] Reset an inherited ignored SIGCHLD at startup on Linux An ignored SIGCHLD survives execve. While it is ignored, Linux reaps the children of the process itself and wait4() fails with ECHILD. bun started by a parent that ignores SIGCHLD lost the exit status of every child: Bun.spawnSync().exitCode was null, Bun.spawn().exited rejected with ECHILD, and bun run exited 0 for a script that failed. main() now queries the SIGCHLD disposition and sets SIG_DFL when it is exactly SIG_IGN. A handler installed before main() stays. Other inherited SIG_IGN dispositions (SIGHUP, SIGINT, SIGTERM under nohup) stay ignored. macOS and FreeBSD keep children waitable after execve, so the statement is compiled for Linux and Android only. --- src/runtime/bin_entry/mod.rs | 22 ++++ .../bun/spawn/spawn-sigchld-ignored.test.ts | 118 ++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 test/js/bun/spawn/spawn-sigchld-ignored.test.ts diff --git a/src/runtime/bin_entry/mod.rs b/src/runtime/bin_entry/mod.rs index b1f3c207d77e..b5ad4e675a98 100644 --- a/src/runtime/bin_entry/mod.rs +++ b/src/runtime/bin_entry/mod.rs @@ -180,6 +180,9 @@ pub(crate) unsafe extern "C" fn main(argc: c_int, argv: *const *const c_char) -> libc::signal(libc::SIGXFSZ, libc::SIG_IGN); } + #[cfg(any(target_os = "linux", target_os = "android"))] + keep_children_waitable(); + // Windows-only startup. Must run before anything reads // `Bun.env`/`process.env` (env conversion). #[cfg(windows)] @@ -212,6 +215,25 @@ pub(crate) unsafe extern "C" fn main(argc: c_int, argv: *const *const c_char) -> Global::exit(0) } +/// An ignored SIGCHLD survives execve, and while it is ignored Linux reaps +/// our children itself, so every wait4() fails with ECHILD. It is the one +/// inherited SIG_IGN we undo (HUP/INT/TERM stay ignored, for nohup). Only an +/// exact SIG_IGN is replaced: a handler installed before main() stays. +/// macOS and FreeBSD keep children waitable after execve, hence the gate. +#[cfg(any(target_os = "linux", target_os = "android"))] +fn keep_children_waitable() { + // SAFETY: `old` is a valid out-pointer and a null new action only queries; + // `SIGCHLD` and `SIG_DFL` are a valid signal and disposition. + unsafe { + let mut old: libc::sigaction = bun_core::ffi::zeroed(); + if libc::sigaction(libc::SIGCHLD, core::ptr::null(), &raw mut old) == 0 + && old.sa_sigaction == libc::SIG_IGN + { + libc::signal(libc::SIGCHLD, libc::SIG_DFL); + } + } +} + /// Linux's `expand_fdtable()` waits for an RCU grace period (tens of ms on a /// many-core machine) each time the fd table doubles past 64 once a second /// thread exists. Single-threaded it is a memcpy and the table never shrinks, diff --git a/test/js/bun/spawn/spawn-sigchld-ignored.test.ts b/test/js/bun/spawn/spawn-sigchld-ignored.test.ts new file mode 100644 index 000000000000..6046c837d961 --- /dev/null +++ b/test/js/bun/spawn/spawn-sigchld-ignored.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, isLinux, libcPathForDlopen, tempDir } from "harness"; +import { join } from "node:path"; + +// An ignored SIGCHLD survives execve. While it is ignored, Linux reaps the +// children of the process itself and wait4() fails with ECHILD, so the exit +// status of every child is lost. + +// `trap "" CHLD` sets SIGCHLD to SIG_IGN in bash and `exec` hands that to the +// next program. dash leaves SIGCHLD alone, so /bin/sh cannot be the launcher. +function launcher(ignore: string) { + return ["bash", "-c", `trap "" ${ignore}; exec "$0" "$@"`]; +} + +// Bit n-1 of a signal mask in /proc//status is set for signal n. +// SigIgn has the ignored signals, SigCgt the ones with a handler. +function signals(status: string, field: "SigIgn" | "SigCgt") { + const mask = parseInt(new RegExp(`^${field}:\\s*([0-9a-f]+)$`, "m").exec(status)?.[1].slice(-8) ?? "0", 16); + return { SIGHUP: (mask & 1) !== 0, SIGINT: (mask & 2) !== 0, SIGCHLD: (mask & (1 << 16)) !== 0 }; +} + +const printStatus = `console.log(require("node:fs").readFileSync("/proc/self/status", "utf8"))`; + +const launcherIgnoresSigchld = + isLinux && + Bun.which("bash") != null && + signals(Bun.spawnSync({ cmd: [...launcher("CHLD"), "cat", "/proc/self/status"] }).stdout.toString(), "SigIgn") + .SIGCHLD; + +const cc = Bun.which("cc") ?? Bun.which("clang") ?? Bun.which("gcc"); + +describe.skipIf(!launcherIgnoresSigchld)("bun started with SIGCHLD ignored", () => { + async function run(ignore: string, args: string[], options: { cwd?: string; env?: typeof bunEnv } = {}) { + await using proc = Bun.spawn({ + cmd: [...launcher(ignore), bunExe(), ...args], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + // Without the reset, `bun run` with BUN_FEATURE_FLAG_NO_ORPHANS=1 does not stop on SIGTERM. + killSignal: "SIGKILL", + ...options, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + test.concurrent.each([ + ["Bun.spawnSync", `Bun.spawnSync(["sh", "-c", "exit 7"]).exitCode`, "7"], + ["Bun.spawn", `await Bun.spawn(["sh", "-c", "exit 7"]).exited`, "7"], + ["child_process.spawnSync", `require("node:child_process").spawnSync("sh", ["-c", "exit 7"]).status`, "7"], + ["Bun.$", `(await Bun.$\`sh -c "exit 5"\`.nothrow().quiet()).exitCode`, "5"], + // system() forks and waits inside libc, not through bun's spawn. The wait + // status of a command that exits holds its exit code in bits 8 to 15. + [ + "system() through bun:ffi", + `require("bun:ffi").dlopen(${JSON.stringify(libcPathForDlopen())}, { system: { args: ["cstring"], returns: "int" } }).symbols.system(Buffer.from("exit 7\\0")) >> 8`, + "7", + ], + ])("%s reports the exit code of the child", async (_, expression, expected) => { + const { stdout, stderr, exitCode } = await run("CHLD", ["-e", `console.log(${expression})`]); + expect(stderr).toBe(""); + expect(stdout).toBe(expected + "\n"); + expect(exitCode).toBe(0); + }); + + test.concurrent.each([ + ["bun run", {}], + ["bun run with BUN_FEATURE_FLAG_NO_ORPHANS=1", { BUN_FEATURE_FLAG_NO_ORPHANS: "1" }], + ])("%s exits with the exit code of the script", async (_, env) => { + using dir = tempDir("sigchld-ignored", { + "package.json": JSON.stringify({ name: "sigchld-ignored", scripts: { fail: "exit 3" } }), + }); + const { stderr, exitCode } = await run("CHLD", ["run", "fail"], { cwd: String(dir), env: { ...bunEnv, ...env } }); + expect(stderr).toBe('$ exit 3\nerror: script "fail" exited with code 3\n'); + expect(exitCode).toBe(3); + }); + + // Only SIGCHLD is put back to its default. `nohup bun` depends on the others. + test.concurrent("SIGHUP and SIGINT stay ignored", async () => { + const { stdout, stderr, exitCode } = await run("CHLD HUP INT", ["-e", printStatus]); + expect(stderr).toBe(""); + expect(signals(stdout, "SigIgn")).toEqual({ SIGHUP: true, SIGINT: true, SIGCHLD: false }); + expect(exitCode).toBe(0); + }); + + // Only an exact SIG_IGN is replaced. A preloaded library runs before main(), + // and the handler that it installs stays. + test.concurrent.skipIf(cc == null)("a SIGCHLD handler installed before main() stays", async () => { + using dir = tempDir("sigchld-handler", { + "handler.c": ` + #include + static void on_sigchld(int sig) { (void)sig; } + __attribute__((constructor)) static void install(void) { + struct sigaction sa = {0}; + sa.sa_handler = on_sigchld; + sigaction(SIGCHLD, &sa, 0); + } + `, + }); + const library = join(String(dir), "handler.so"); + await using compile = Bun.spawn({ + cmd: [cc!, "-shared", "-fPIC", "-o", library, join(String(dir), "handler.c")], + stdout: "pipe", + stderr: "pipe", + }); + expect({ stderr: await compile.stderr.text(), exitCode: await compile.exited }).toEqual({ + stderr: "", + exitCode: 0, + }); + + const { stdout, stderr, exitCode } = await run("CHLD", ["-e", printStatus], { + env: { ...bunEnv, LD_PRELOAD: library }, + }); + expect(stderr).toBe(""); + expect(signals(stdout, "SigCgt").SIGCHLD).toBe(true); + expect(exitCode).toBe(0); + }); +}); From de295b701bcd732a62496dfb771604768c88457f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:02:59 +0000 Subject: [PATCH 2/4] test: call libcPathForDlopen() inside the test body A skipped describe still runs its callback to collect the tests, and libcPathForDlopen() throws on Windows. The call was in a test.each table, so the file failed to load on a platform where every test is skipped. The system() case is now its own test and reads the path when it runs. --- .../bun/spawn/spawn-sigchld-ignored.test.ts | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/test/js/bun/spawn/spawn-sigchld-ignored.test.ts b/test/js/bun/spawn/spawn-sigchld-ignored.test.ts index 6046c837d961..ee3d53eedf53 100644 --- a/test/js/bun/spawn/spawn-sigchld-ignored.test.ts +++ b/test/js/bun/spawn/spawn-sigchld-ignored.test.ts @@ -49,13 +49,6 @@ describe.skipIf(!launcherIgnoresSigchld)("bun started with SIGCHLD ignored", () ["Bun.spawn", `await Bun.spawn(["sh", "-c", "exit 7"]).exited`, "7"], ["child_process.spawnSync", `require("node:child_process").spawnSync("sh", ["-c", "exit 7"]).status`, "7"], ["Bun.$", `(await Bun.$\`sh -c "exit 5"\`.nothrow().quiet()).exitCode`, "5"], - // system() forks and waits inside libc, not through bun's spawn. The wait - // status of a command that exits holds its exit code in bits 8 to 15. - [ - "system() through bun:ffi", - `require("bun:ffi").dlopen(${JSON.stringify(libcPathForDlopen())}, { system: { args: ["cstring"], returns: "int" } }).symbols.system(Buffer.from("exit 7\\0")) >> 8`, - "7", - ], ])("%s reports the exit code of the child", async (_, expression, expected) => { const { stdout, stderr, exitCode } = await run("CHLD", ["-e", `console.log(${expression})`]); expect(stderr).toBe(""); @@ -63,6 +56,21 @@ describe.skipIf(!launcherIgnoresSigchld)("bun started with SIGCHLD ignored", () expect(exitCode).toBe(0); }); + // system() forks and waits inside libc, not through bun's spawn. The wait + // status of a command that exits holds its exit code in bits 8 to 15. + test.concurrent("system() through bun:ffi reports the exit code of the command", async () => { + // A skipped describe still runs its callback, and libcPathForDlopen() throws on Windows. + const libc = JSON.stringify(libcPathForDlopen()); + const { stdout, stderr, exitCode } = await run("CHLD", [ + "-e", + `const { symbols } = require("bun:ffi").dlopen(${libc}, { system: { args: ["cstring"], returns: "int" } }); + console.log(symbols.system(Buffer.from("exit 7\\0")) >> 8);`, + ]); + expect(stderr).toBe(""); + expect(stdout).toBe("7\n"); + expect(exitCode).toBe(0); + }); + test.concurrent.each([ ["bun run", {}], ["bun run with BUN_FEATURE_FLAG_NO_ORPHANS=1", { BUN_FEATURE_FLAG_NO_ORPHANS: "1" }], From 1eeec59f5600fc2e0ac8ac56d9f18932f84cb6c1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:44:31 +0000 Subject: [PATCH 3/4] Shorten the comments on keep_children_waitable One line says why the function exists. One line at the call site says why macOS and FreeBSD do not need it. The tests pin the rest: an exact SIG_IGN is the only value replaced, and SIGHUP and SIGINT stay ignored. --- src/runtime/bin_entry/mod.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/runtime/bin_entry/mod.rs b/src/runtime/bin_entry/mod.rs index b5ad4e675a98..8f40b4cf2651 100644 --- a/src/runtime/bin_entry/mod.rs +++ b/src/runtime/bin_entry/mod.rs @@ -180,6 +180,7 @@ pub(crate) unsafe extern "C" fn main(argc: c_int, argv: *const *const c_char) -> libc::signal(libc::SIGXFSZ, libc::SIG_IGN); } + // macOS and FreeBSD keep children waitable after execve. #[cfg(any(target_os = "linux", target_os = "android"))] keep_children_waitable(); @@ -215,11 +216,7 @@ pub(crate) unsafe extern "C" fn main(argc: c_int, argv: *const *const c_char) -> Global::exit(0) } -/// An ignored SIGCHLD survives execve, and while it is ignored Linux reaps -/// our children itself, so every wait4() fails with ECHILD. It is the one -/// inherited SIG_IGN we undo (HUP/INT/TERM stay ignored, for nohup). Only an -/// exact SIG_IGN is replaced: a handler installed before main() stays. -/// macOS and FreeBSD keep children waitable after execve, hence the gate. +/// An inherited SIG_IGN on SIGCHLD makes Linux reap our children itself: wait4() fails with ECHILD. #[cfg(any(target_os = "linux", target_os = "android"))] fn keep_children_waitable() { // SAFETY: `old` is a valid out-pointer and a null new action only queries; From 5e18f533a9fc35e4c8c83f6633be5a423ada07d5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:50:54 +0000 Subject: [PATCH 4/4] test: drain the compiler's stdout and stderr together The compile step piped stdout and never read it, and read stderr before it waited for the exit. It now reads both pipes and the exit code in one Promise.all, as the run() helper in the same file does. --- test/js/bun/spawn/spawn-sigchld-ignored.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/js/bun/spawn/spawn-sigchld-ignored.test.ts b/test/js/bun/spawn/spawn-sigchld-ignored.test.ts index ee3d53eedf53..fae71d2dfd61 100644 --- a/test/js/bun/spawn/spawn-sigchld-ignored.test.ts +++ b/test/js/bun/spawn/spawn-sigchld-ignored.test.ts @@ -111,7 +111,13 @@ describe.skipIf(!launcherIgnoresSigchld)("bun started with SIGCHLD ignored", () stdout: "pipe", stderr: "pipe", }); - expect({ stderr: await compile.stderr.text(), exitCode: await compile.exited }).toEqual({ + const [compileStdout, compileStderr, compileExitCode] = await Promise.all([ + compile.stdout.text(), + compile.stderr.text(), + compile.exited, + ]); + expect({ stdout: compileStdout, stderr: compileStderr, exitCode: compileExitCode }).toEqual({ + stdout: "", stderr: "", exitCode: 0, });