Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/runtime/bin_entry/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,10 @@ 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();
Comment thread
robobun marked this conversation as resolved.
Resolved

// Windows-only startup. Must run before anything reads
// `Bun.env`/`process.env` (env conversion).
#[cfg(windows)]
Expand Down Expand Up @@ -212,6 +216,21 @@ pub(crate) unsafe extern "C" fn main(argc: c_int, argv: *const *const c_char) ->
Global::exit(0)
}

/// 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;
// `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,
Expand Down
132 changes: 132 additions & 0 deletions test/js/bun/spawn/spawn-sigchld-ignored.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { describe, expect, test } from "bun:test";
Comment thread
robobun marked this conversation as resolved.
Resolved
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/<pid>/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([
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
["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"],
])("%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);
});

// 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" }],
])("%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 <signal.h>
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",
});
Comment thread
robobun marked this conversation as resolved.
Resolved
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,
});

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);
});
});
Loading