From 4b9d469701ba300e85ef7e8c717f085eb4f47901 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 18 May 2026 01:55:33 +0000 Subject: [PATCH 1/5] spawnSync: do not forward SIGPWR on Linux JSC uses SIGPWR for GC thread suspend/resume on Linux. spawnSync's signal-forwarding helper installed a SA_RESETHAND handler for SIGPWR and saved the previous handler into a process-global array with no synchronization. Bun.openInEditor spawns a detached thread per call that runs spawnSync; concurrent calls raced on the previous-handler table and left SIGPWR at SIG_DFL, so the next GC suspend terminated the process with signal 30. --- src/jsc/bindings/c-bindings.cpp | 4 +- test/js/bun/spawn/spawnSync-sigpwr-gc.test.ts | 58 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 test/js/bun/spawn/spawnSync-sigpwr-gc.test.ts diff --git a/src/jsc/bindings/c-bindings.cpp b/src/jsc/bindings/c-bindings.cpp index a20749bef69..213bd362a9a 100644 --- a/src/jsc/bindings/c-bindings.cpp +++ b/src/jsc/bindings/c-bindings.cpp @@ -933,9 +933,11 @@ static struct sigaction previous_actions[NSIG]; M(SIGIO); #if OS(LINUX) +// SIGPWR is intentionally excluded: JSC uses it for GC thread suspend/resume +// (see WTF/wtf/posix/ThreadingPOSIX.cpp). Replacing its handler can terminate +// the process when the GC next signals a thread. #define FOR_EACH_LINUX_ONLY_SIGNAL(M) \ M(SIGPOLL); \ - M(SIGPWR); \ M(SIGSTKFLT); #endif diff --git a/test/js/bun/spawn/spawnSync-sigpwr-gc.test.ts b/test/js/bun/spawn/spawnSync-sigpwr-gc.test.ts new file mode 100644 index 00000000000..cd934ead41e --- /dev/null +++ b/test/js/bun/spawn/spawnSync-sigpwr-gc.test.ts @@ -0,0 +1,58 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir, isLinux } from "harness"; +import { join } from "path"; +import { symlinkSync } from "fs"; + +// Regression: spawnSync's signal-forwarding list included SIGPWR on Linux. +// JSC uses SIGPWR to suspend/resume threads for GC. Bun.openInEditor spawns a +// detached thread per call that runs spawnSync; concurrent calls race on the +// process-wide previous-handler table and can leave SIGPWR at SIG_DFL. The +// next GC suspend then terminates the process with signal 30. +test.skipIf(!isLinux)( + "spawnSync signal forwarding does not clobber JSC's SIGPWR handler", + async () => { + const sleepBin = Bun.which("sleep"); + expect(sleepBin).toBeTruthy(); + + using dir = tempDir("spawnSync-sigpwr", { + "run.js": ` +for (let i = 0; i < 64; i++) { + try { Bun.openInEditor("0.2"); } catch {} +} +let junk = []; +for (let i = 0; i < 2000; i++) { + junk.push({ a: new Uint8Array(4096).fill(i), b: { c: i } }); +} +for (let i = 0; i < 30; i++) Bun.gc(true); +console.log("ok"); +`, + }); + // Make `code` (first in the editor preference list) resolve to `sleep`, so + // each background spawnSync holds its signal-forwarding window open long + // enough for threads to overlap. + symlinkSync(sleepBin!, join(String(dir), "code")); + + await using proc = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "run.js")], + env: { + ...bunEnv, + PATH: String(dir), + EDITOR: "", + VISUAL: "", + }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + proc.stdout.text(), + proc.stderr.text(), + proc.exited, + ]); + + expect(proc.signalCode).toBeNull(); + expect(stderr).not.toContain("AddressSanitizer"); + expect(stdout.trim()).toBe("ok"); + expect(exitCode).toBe(0); + }, +); From ac020466dfc6713dfc1f0b4b2e5ba7b1f6b639db Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 01:57:57 +0000 Subject: [PATCH 2/5] [autofix.ci] apply automated fixes --- test/js/bun/spawn/spawnSync-sigpwr-gc.test.ts | 65 +++++++++---------- 1 file changed, 29 insertions(+), 36 deletions(-) diff --git a/test/js/bun/spawn/spawnSync-sigpwr-gc.test.ts b/test/js/bun/spawn/spawnSync-sigpwr-gc.test.ts index cd934ead41e..807ab646c7f 100644 --- a/test/js/bun/spawn/spawnSync-sigpwr-gc.test.ts +++ b/test/js/bun/spawn/spawnSync-sigpwr-gc.test.ts @@ -1,21 +1,19 @@ import { expect, test } from "bun:test"; -import { bunEnv, bunExe, tempDir, isLinux } from "harness"; -import { join } from "path"; import { symlinkSync } from "fs"; +import { bunEnv, bunExe, isLinux, tempDir } from "harness"; +import { join } from "path"; // Regression: spawnSync's signal-forwarding list included SIGPWR on Linux. // JSC uses SIGPWR to suspend/resume threads for GC. Bun.openInEditor spawns a // detached thread per call that runs spawnSync; concurrent calls race on the // process-wide previous-handler table and can leave SIGPWR at SIG_DFL. The // next GC suspend then terminates the process with signal 30. -test.skipIf(!isLinux)( - "spawnSync signal forwarding does not clobber JSC's SIGPWR handler", - async () => { - const sleepBin = Bun.which("sleep"); - expect(sleepBin).toBeTruthy(); +test.skipIf(!isLinux)("spawnSync signal forwarding does not clobber JSC's SIGPWR handler", async () => { + const sleepBin = Bun.which("sleep"); + expect(sleepBin).toBeTruthy(); - using dir = tempDir("spawnSync-sigpwr", { - "run.js": ` + using dir = tempDir("spawnSync-sigpwr", { + "run.js": ` for (let i = 0; i < 64; i++) { try { Bun.openInEditor("0.2"); } catch {} } @@ -26,33 +24,28 @@ for (let i = 0; i < 2000; i++) { for (let i = 0; i < 30; i++) Bun.gc(true); console.log("ok"); `, - }); - // Make `code` (first in the editor preference list) resolve to `sleep`, so - // each background spawnSync holds its signal-forwarding window open long - // enough for threads to overlap. - symlinkSync(sleepBin!, join(String(dir), "code")); + }); + // Make `code` (first in the editor preference list) resolve to `sleep`, so + // each background spawnSync holds its signal-forwarding window open long + // enough for threads to overlap. + symlinkSync(sleepBin!, join(String(dir), "code")); - await using proc = Bun.spawn({ - cmd: [bunExe(), join(String(dir), "run.js")], - env: { - ...bunEnv, - PATH: String(dir), - EDITOR: "", - VISUAL: "", - }, - stdout: "pipe", - stderr: "pipe", - }); + await using proc = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "run.js")], + env: { + ...bunEnv, + PATH: String(dir), + EDITOR: "", + VISUAL: "", + }, + stdout: "pipe", + stderr: "pipe", + }); - const [stdout, stderr, exitCode] = await Promise.all([ - proc.stdout.text(), - proc.stderr.text(), - proc.exited, - ]); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(proc.signalCode).toBeNull(); - expect(stderr).not.toContain("AddressSanitizer"); - expect(stdout.trim()).toBe("ok"); - expect(exitCode).toBe(0); - }, -); + expect(proc.signalCode).toBeNull(); + expect(stderr).not.toContain("AddressSanitizer"); + expect(stdout.trim()).toBe("ok"); + expect(exitCode).toBe(0); +}); From 1b8f2aeec5bcbce0376afb0d8d280169435a210f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 18 May 2026 02:28:20 +0000 Subject: [PATCH 3/5] ci: retrigger From 2d5b42e7bc7a39415f5116c789662bc7165ec979 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 18 May 2026 02:51:48 +0000 Subject: [PATCH 4/5] test: drop negative crash-marker assertion --- test/js/bun/spawn/spawnSync-sigpwr-gc.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/js/bun/spawn/spawnSync-sigpwr-gc.test.ts b/test/js/bun/spawn/spawnSync-sigpwr-gc.test.ts index 807ab646c7f..ba19544d191 100644 --- a/test/js/bun/spawn/spawnSync-sigpwr-gc.test.ts +++ b/test/js/bun/spawn/spawnSync-sigpwr-gc.test.ts @@ -39,13 +39,12 @@ console.log("ok"); VISUAL: "", }, stdout: "pipe", - stderr: "pipe", + stderr: "inherit", }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); expect(proc.signalCode).toBeNull(); - expect(stderr).not.toContain("AddressSanitizer"); expect(stdout.trim()).toBe("ok"); expect(exitCode).toBe(0); }); From 7e2c283ae6aa6718b2a33752daa0a4e36c768c21 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 18 May 2026 07:30:34 +0000 Subject: [PATCH 5/5] spawnSync: make signal-forwarding register/unregister reentrant Bun.openInEditor spawns detached threads that call spawnSync, so register/unregister can run concurrently. Guard previous_actions[] with a mutex and depth counter so only the outermost pair installs and restores handlers. Also drop SIGIOT/SIGPOLL from the list since they alias SIGABRT/SIGIO and the second sigaction() was overwriting the saved handler with our own. --- src/jsc/bindings/c-bindings.cpp | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/jsc/bindings/c-bindings.cpp b/src/jsc/bindings/c-bindings.cpp index 213bd362a9a..571d4ed8909 100644 --- a/src/jsc/bindings/c-bindings.cpp +++ b/src/jsc/bindings/c-bindings.cpp @@ -908,14 +908,23 @@ extern "C" int ffi_fileno(FILE* file) #if OS(LINUX) || OS(DARWIN) || OS(FREEBSD) #include #include +#include -// Note: We only ever use bun.spawnSync on the main thread. +// bun.spawnSync is primarily used from the main thread (e.g. `bun run`), but +// Bun.openInEditor spawns detached threads that also go through this path. +// The depth counter + lock below keep previous_actions[] from being corrupted +// by overlapping register/unregister calls; only the outermost pair touches +// process-wide signal dispositions. extern "C" int64_t Bun__currentSyncPID = 0; static int Bun__pendingSignalToSend = 0; static struct sigaction previous_actions[NSIG]; +static std::mutex signalForwardingLock; +static int signalForwardingDepth = 0; // This list of signals is copied from npm. // https://github.com/npm/cli/blob/fefd509992a05c2dfddbe7bc46931c42f1da69d7/workspaces/arborist/lib/signals.js#L26-L57 +// SIGIOT is omitted because it aliases SIGABRT; registering both would +// overwrite previous_actions[SIGABRT] with our own handler. #define FOR_EACH_POSIX_SIGNAL(M) \ M(SIGABRT); \ M(SIGALRM); \ @@ -929,15 +938,14 @@ static struct sigaction previous_actions[NSIG]; M(SIGTRAP); \ M(SIGSYS); \ M(SIGQUIT); \ - M(SIGIOT); \ M(SIGIO); #if OS(LINUX) // SIGPWR is intentionally excluded: JSC uses it for GC thread suspend/resume // (see WTF/wtf/posix/ThreadingPOSIX.cpp). Replacing its handler can terminate // the process when the GC next signals a thread. +// SIGPOLL is omitted because it aliases SIGIO (see SIGIOT note above). #define FOR_EACH_LINUX_ONLY_SIGNAL(M) \ - M(SIGPOLL); \ M(SIGSTKFLT); #endif @@ -978,6 +986,10 @@ extern "C" void Bun__sendPendingSignalIfNecessary() extern "C" void Bun__registerSignalsForForwarding() { + std::lock_guard lock(signalForwardingLock); + if (signalForwardingDepth++ != 0) + return; + Bun__pendingSignalToSend = 0; struct sigaction sa; memset(&sa, 0, sizeof(sa)); @@ -1005,6 +1017,10 @@ extern "C" void Bun__unregisterSignalsForForwarding() { Bun__currentSyncPID = 0; + std::lock_guard lock(signalForwardingLock); + if (--signalForwardingDepth != 0) + return; + #define UNREGISTER_SIGNAL(SIG) \ if (sigaction(SIG, &previous_actions[SIG], NULL) == -1) { \ }