diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index 23df5fd1a565..563fed707348 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -1048,6 +1048,7 @@ static const NeverDestroyed* getSignalNames() MAKE_STATIC_STRING_IMPL("SIGINFO"), MAKE_STATIC_STRING_IMPL("SIGSYS"), MAKE_STATIC_STRING_IMPL("SIGBREAK"), + MAKE_STATIC_STRING_IMPL("SIGPWR"), }; return signalNames; @@ -1150,6 +1151,9 @@ static void loadSignalNumberMap() #ifdef SIGSYS signalNameToNumberMap->add(signalNames[30], SIGSYS); #endif +#ifdef SIGPWR + signalNameToNumberMap->add(signalNames[32], SIGPWR); +#endif #endif }); } @@ -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); @@ -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); +} + +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); @@ -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 }); @@ -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) @@ -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(), @@ -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); diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index 5be04f650868..e85e0b4cd9e6 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -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; @@ -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 diff --git a/test/js/node/process/process-sigpwr.test.ts b/test/js/node/process/process-sigpwr.test.ts new file mode 100644 index 000000000000..a061eb1792f3 --- /dev/null +++ b/test/js/node/process/process-sigpwr.test.ts @@ -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 = {}) { + 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 }; + } + + 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 })); + `; + + 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); + }); + + 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")); + }); +});