Skip to content
85 changes: 67 additions & 18 deletions src/jsc/bindings/BunProcess.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1048,6 +1048,7 @@ static const NeverDestroyed<String>* getSignalNames()
MAKE_STATIC_STRING_IMPL("SIGINFO"),
MAKE_STATIC_STRING_IMPL("SIGSYS"),
MAKE_STATIC_STRING_IMPL("SIGBREAK"),
MAKE_STATIC_STRING_IMPL("SIGPWR"),
};

return signalNames;
Expand Down Expand Up @@ -1150,6 +1151,9 @@ static void loadSignalNumberMap()
#ifdef SIGSYS
signalNameToNumberMap->add(signalNames[30], SIGSYS);
#endif
#ifdef SIGPWR
signalNameToNumberMap->add(signalNames[32], SIGPWR);
#endif
#endif
});
}
Expand All @@ -1162,6 +1166,11 @@ bool isSignalName(WTF::String input)

extern "C" void Bun__onSignalForJS(int signalNumber, Zig::GlobalObject* globalObject)
{
// SigintWatcher::install() can prime the signal ring buffer without any process.on()
// ever running, in which case this map is still null and there is no listener to emit to.
if (!signalNumberToNameMap)
return;

Process* process = globalObject->processObject();

String signalName = signalNumberToNameMap->get(signalNumber);
Expand Down Expand Up @@ -1384,6 +1393,36 @@ __attribute__((noinline)) static void forwardSignal(int signalNumber)
Bun__onPosixSignal(signalNumber);
}

#if OS(LINUX)
static struct sigaction s_jscSuspendResumeAction;

static void Bun__sigThreadSuspendResumeGuard(int signalNumber, siginfo_t* info, void* ucontext)
{
// JSC's GC suspend/resume always uses pthread_kill from this process (si_code == SI_TKILL,
// si_pid == us). Any other delivery is unsolicited and would null-deref targetThread in
// WTF::Thread::signalHandlerSuspendResume, so forward it to JS listeners instead.
if (info && info->si_code == SI_TKILL && info->si_pid == getpid()) [[likely]] {
s_jscSuspendResumeAction.sa_sigaction(signalNumber, info, ucontext);
return;
}
Bun__onPosixSignal(signalNumber);
Comment thread
robobun marked this conversation as resolved.
}

extern "C" void Bun__installSigThreadSuspendResumeGuard()
{
int signalNumber = g_wtfConfig.sigThreadSuspendResume;
if (!signalNumber)
return;
if (sigaction(signalNumber, nullptr, &s_jscSuspendResumeAction))
return;
if (!(s_jscSuspendResumeAction.sa_flags & SA_SIGINFO))
return;
struct sigaction action = s_jscSuspendResumeAction;
action.sa_sigaction = &Bun__sigThreadSuspendResumeGuard;
sigaction(signalNumber, &action, nullptr);
}
#endif

extern "C" void Bun__MemoryPressure__install(JSC::JSGlobalObject* global);
extern "C" void Bun__MemoryPressure__uninstall(JSC::JSGlobalObject* global);

Expand Down Expand Up @@ -1509,6 +1548,9 @@ static void onDidChangeListeners(EventEmitter& eventEmitter, const Identifier& e
#endif
#ifdef SIGBREAK
signalNumberToNameMap->add(SIGBREAK, signalNames[31]);
#endif
#ifdef SIGPWR
signalNumberToNameMap->add(SIGPWR, signalNames[32]);
#endif
});

Expand All @@ -1517,11 +1559,7 @@ static void onDidChangeListeners(EventEmitter& eventEmitter, const Identifier& e
}

if (auto signalNumber = signalNameToNumberMap->get(eventName.string())) {
#if OS(LINUX)
// SIGKILL and SIGSTOP cannot be handled, and JSC needs its own signal handler to
// suspend and resume the JS thread which we must not override.
if (signalNumber != SIGKILL && signalNumber != SIGSTOP && signalNumber != g_wtfConfig.sigThreadSuspendResume) {
#elif OS(DARWIN) || OS(FREEBSD)
#if OS(LINUX) || OS(DARWIN) || OS(FREEBSD)
// these signals cannot be handled
if (signalNumber != SIGKILL && signalNumber != SIGSTOP) {
#elif OS(WINDOWS)
Expand All @@ -1540,18 +1578,26 @@ static void onDidChangeListeners(EventEmitter& eventEmitter, const Identifier& e
};
#if !OS(WINDOWS)
Bun__ensureSignalHandler();
struct sigaction action;
memset(&action, 0, sizeof(struct sigaction));
#if OS(LINUX)
// JSC owns the handler for its GC suspend/resume signal; our wrapper
// (Bun__installSigThreadSuspendResumeGuard) already forwards unsolicited
// deliveries into Bun__onPosixSignal, so we must not replace it here.
if (signalNumber != g_wtfConfig.sigThreadSuspendResume)
#endif
{
struct sigaction action;
memset(&action, 0, sizeof(struct sigaction));

// Set the handler in the action struct
action.sa_handler = forwardSignal;
// Set the handler in the action struct
action.sa_handler = forwardSignal;

// Clear the sa_mask
sigemptyset(&action.sa_mask);
sigaddset(&action.sa_mask, signalNumber);
action.sa_flags = SA_RESTART;
// Clear the sa_mask
sigemptyset(&action.sa_mask);
sigaddset(&action.sa_mask, signalNumber);
action.sa_flags = SA_RESTART;

sigaction(signalNumber, &action, nullptr);
sigaction(signalNumber, &action, nullptr);
}
#else
signal_handle.handle = Bun__UVSignalHandle__init(
eventEmitter.scriptExecutionContext()->jsGlobalObject(),
Expand All @@ -1568,10 +1614,13 @@ static void onDidChangeListeners(EventEmitter& eventEmitter, const Identifier& e
if (signalToContextIdsMap->find(signalNumber) != signalToContextIdsMap->end() && eventEmitter.listenerCount(eventName) == 0) {

#if !OS(WINDOWS)
if (void (*oldHandler)(int) = signal(signalNumber, SIG_DFL); oldHandler != forwardSignal) {
// Don't uninstall the old handler if it's not the one we installed.
signal(signalNumber, oldHandler);
}
#if OS(LINUX)
if (signalNumber != g_wtfConfig.sigThreadSuspendResume)
#endif
if (void (*oldHandler)(int) = signal(signalNumber, SIG_DFL); oldHandler != forwardSignal) {
// Don't uninstall the old handler if it's not the one we installed.
signal(signalNumber, oldHandler);
}
#else
SignalHandleValue signal_handle = signalToContextIdsMap->get(signalNumber);
Bun__UVSignalHandle__close(signal_handle.handle);
Expand Down
8 changes: 8 additions & 0 deletions src/jsc/bindings/ZigGlobalObject.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,10 @@ extern "C" unsigned getJSCBytecodeCacheVersion()
extern "C" void Bun__REPRL__registerFuzzilliFunctions(Zig::GlobalObject*);
#endif

#if OS(LINUX)
extern "C" void Bun__installSigThreadSuspendResumeGuard();
#endif

extern "C" void JSCInitialize(const char* envp[], size_t envc, void (*onCrash)(const char* ptr, size_t length), bool evalMode, bool oneShotStartup)
{
static std::once_flag jsc_init_flag;
Expand All @@ -284,6 +288,10 @@ extern "C" void JSCInitialize(const char* envp[], size_t envc, void (*onCrash)(c
std::set_terminate([]() { Zig__GlobalObject__onCrash(); });
WTF::initializeMainThread();

#if OS(LINUX)
Bun__installSigThreadSuspendResumeGuard();
#endif

// Use JSC::initialize with a callback to set Options during initialization.
// The callback runs BEFORE IPInt::initialize() so we can configure WASM options early.
// Under ASAN+Linux, JSC's notifyOptionsChanged() already disables
Expand Down
152 changes: 152 additions & 0 deletions test/js/node/process/process-sigpwr.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, isDebug, isLinux } from "harness";

// JSC uses SIGPWR on Linux to suspend/resume the JS thread for conservative stack scanning.
// An unsolicited SIGPWR (from process.kill, Bun.spawn().kill, or an external `kill -PWR`)
// used to reach WTF::Thread::signalHandlerSuspendResume with targetThread == nullptr and
// segfault at offset 0x58. These tests prove the process now survives and that a JS listener
// registered via process.on("SIGPWR", ...) actually fires.

describe.skipIf(!isLinux)("SIGPWR", () => {
async function runScript(script: string, extraEnv: Record<string, string> = {}) {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", script],
env: { ...bunEnv, ...extraEnv },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout, stderr, exitCode, signalCode: proc.signalCode };
}

async function spawnAndSignalAfterReady(deliver: (proc: import("bun").Subprocess<"ignore", "pipe", "pipe">) => void) {
const script = /*js*/ `
const { promise, resolve } = Promise.withResolvers();
process.on("SIGPWR", () => { console.log("handler ran"); resolve(); });
console.log("ready");
await promise;
console.log("survived");
`;

await using proc = Bun.spawn({
cmd: [bunExe(), "-e", script],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});

const stderrPromise = proc.stderr.text();
const reader = proc.stdout.getReader();
const decoder = new TextDecoder();
let stdout = "";
let sent = false;
while (true) {
const { done, value } = await reader.read();
if (value) stdout += decoder.decode(value, { stream: true });
if (!sent && stdout.includes("ready\n")) {
sent = true;
deliver(proc);
}
if (done) break;
}
stdout += decoder.decode();

const [stderr, exitCode] = await Promise.all([stderrPromise, proc.exited]);
return { stdout, stderr, exitCode, signalCode: proc.signalCode };
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const ok = (stdout: string) => ({ stdout, stderr: "", exitCode: 0, signalCode: null });

// The literal 30 is intentional: this exercises the numeric-signal path from the repro,
// which hands the raw number straight to kill(2) without name-table lookup.
test.concurrent("process.kill(self, 30) runs the SIGPWR listener instead of crashing", async () => {
const script = /*js*/ `
const { promise, resolve } = Promise.withResolvers();
process.on("SIGPWR", (name, num) => {
console.log("handler", name, num);
resolve();
});
process.kill(process.pid, 30);
await promise;
console.log("survived");
`;
expect(await runScript(script)).toEqual(ok("handler SIGPWR 30\nsurvived\n"));
});

test.concurrent('process.kill(self, "SIGPWR") runs the listener', async () => {
const script = /*js*/ `
const { promise, resolve } = Promise.withResolvers();
process.on("SIGPWR", () => { console.log("handler ran"); resolve(); });
process.kill(process.pid, "SIGPWR");
await promise;
`;
expect(await runScript(script)).toEqual(ok("handler ran\n"));
});

test.concurrent("SIGPWR delivered from outside the process runs the listener", async () => {
const result = await spawnAndSignalAfterReady(proc => process.kill(proc.pid!, "SIGPWR"));
expect(result).toEqual(ok("ready\nhandler ran\nsurvived\n"));
});

test.concurrent("subprocess.kill('SIGPWR') runs the listener in the child", async () => {
const result = await spawnAndSignalAfterReady(proc => proc.kill("SIGPWR"));
expect(result).toEqual(ok("ready\nhandler ran\nsurvived\n"));
});

test.concurrent("unsolicited SIGPWR with no listener does not crash the process", async () => {
const script = /*js*/ `
process.kill(process.pid, 30);
await new Promise(r => setImmediate(r));
console.log("survived");
`;
expect(await runScript(script)).toEqual(ok("survived\n"));
});

test.concurrent("unsolicited SIGPWR after breakOnSigint primed the signal ring does not crash", async () => {
const script = /*js*/ `
require("node:vm").runInNewContext("1", {}, { breakOnSigint: true });
process.kill(process.pid, 30);
await new Promise(r => setImmediate(r));
console.log("survived");
`;
expect(await runScript(script)).toEqual(ok("survived\n"));
});

// collectContinuously runs a dedicated collector thread in the same VM that suspends the
// main mutator via pthread_kill(SIGPWR); a broken SI_TKILL passthrough would hang here.
// The exact `handled` count also proves internal deliveries are not misrouted to JS.
test.concurrent("GC suspend/resume still works with the SIGPWR guard installed", async () => {
const iterations = isDebug ? 10 : 50;
const script = /*js*/ `
let handled = 0;
process.on("SIGPWR", () => { handled++; });
for (let i = 0; i < ${iterations}; i++) {
const junk = [];
for (let j = 0; j < 200; j++) junk.push({ a: j, b: Buffer.alloc(64, 65).toString() });
Bun.gc(true);
process.kill(process.pid, 30);
await new Promise(r => setImmediate(r));
}
console.log(JSON.stringify({ handled }));
`;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const { stdout, stderr, exitCode, signalCode } = await runScript(script, { BUN_JSC_collectContinuously: "1" });
expect(stderr).toBe("");
expect(JSON.parse(stdout.trim())).toEqual({ handled: iterations });
expect(exitCode).toBe(0);
expect(signalCode).toBe(null);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

test.concurrent("removing all SIGPWR listeners does not reset the disposition to SIG_DFL", async () => {
const script = /*js*/ `
const fn = () => {};
process.on("SIGPWR", fn);
process.off("SIGPWR", fn);
Bun.gc(true);
process.kill(process.pid, 30);
await new Promise(r => setImmediate(r));
console.log("survived");
`;
expect(await runScript(script)).toEqual(ok("survived\n"));
});
});
Loading