spawnSync: do not forward SIGPWR on Linux#30956
Conversation
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.
|
Updated 2:30 AM PT - May 18th, 2026
✅ @robobun, your commit 7e2c283ae6aa6718b2a33752daa0a4e36c768c21 passed in 🧪 To try this PR locally: bunx bun-pr 30956That installs a local version of the PR into your bun-30956 --bun |
WalkthroughRemoved SIGPWR from the Linux-only signal-forwarding macro used by spawnSync, added process-wide mutex and reference-counting around signal handler register/unregister, and added a Linux regression test that stresses overlapping spawns and GC to confirm the SIGPWR handler remains intact. ChangesSignal Forwarding Fix
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/jsc/bindings/c-bindings.cpp (1)
936-953:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy liftSignal-forwarding globals are still unsafe under overlapping
spawnSynccalls.Removing
SIGPWRfixes this specific crash path, but the forwarding mechanism still relies on unsynchronized process-global state (previous_actions,Bun__currentSyncPID,Bun__pendingSignalToSend). With concurrentspawnSyncwindows, handlers/PID state can still be clobbered and restored incorrectly for other forwarded signals.Also applies to: 915-916, 979-1015
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/jsc/bindings/c-bindings.cpp` around lines 936 - 953, The signal-forwarding globals (previous_actions, Bun__currentSyncPID, Bun__pendingSignalToSend) used by spawnSync are not safe for overlapping calls; serialize access and make the handler logic robust: introduce a single shared mutex (e.g., spawnSyncSignalMutex) and lock it around any code that installs/restores signal handlers and mutates previous_actions, Bun__currentSyncPID, and Bun__pendingSignalToSend inside the spawnSync implementation and its helper functions, and change the signal handler to verify the PID against an atomic Bun__currentSyncPID before acting (so stray signals are ignored); this ensures handlers/state cannot be clobbered by concurrent spawnSync calls and restores handlers only when the caller that set them is still the owner.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@src/jsc/bindings/c-bindings.cpp`:
- Around line 936-953: The signal-forwarding globals (previous_actions,
Bun__currentSyncPID, Bun__pendingSignalToSend) used by spawnSync are not safe
for overlapping calls; serialize access and make the handler logic robust:
introduce a single shared mutex (e.g., spawnSyncSignalMutex) and lock it around
any code that installs/restores signal handlers and mutates previous_actions,
Bun__currentSyncPID, and Bun__pendingSignalToSend inside the spawnSync
implementation and its helper functions, and change the signal handler to verify
the PID against an atomic Bun__currentSyncPID before acting (so stray signals
are ignored); this ensures handlers/state cannot be clobbered by concurrent
spawnSync calls and restores handlers only when the caller that set them is
still the owner.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: b3b75814-6478-48d0-97e4-fbcdfd357797
📒 Files selected for processing (2)
src/jsc/bindings/c-bindings.cpptest/js/bun/spawn/spawnSync-sigpwr-gc.test.ts
|
Found 1 issue this PR may fix:
🤖 Generated with Claude Code |
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🟡
test/js/bun/spawn/spawnSync-sigpwr-gc.test.ts:54— nit:expect(stderr).not.toContain("AddressSanitizer")is the negative crash-marker pattern that rootCLAUDE.md:126says to NEVER write ("These tests will never fail in CI"). The positive assertions onsignalCode,stdout === "ok", andexitCode === 0already fail if the process dies via SIGPWR or ASAN aborts, so this line adds no signal — just drop it.Extended reasoning...
What the issue is
test/js/bun/spawn/spawnSync-sigpwr-gc.test.ts:54adds:expect(stderr).not.toContain("AddressSanitizer");
This is a negative assertion on a crash-marker string in subprocess output. The repo's root
CLAUDE.md(line 126) explicitly forbids this pattern:NEVER write tests that check for no "panic" or "uncaught exception" or similar in the test output. These tests will never fail in CI.
"AddressSanitizer"is exactly "or similar" — it's a crash-marker string just like"panic"or"uncaught exception".Why this assertion adds no signal — step by step
Consider the two CI configurations:
-
Non-ASAN build (the common case in CI). AddressSanitizer is not compiled in, so
stderrcan never contain"AddressSanitizer"regardless of whether the bug is present. The assertion is vacuously true and cannot fail — this is precisely what the CLAUDE.md rule warns about. -
ASAN build, bug regresses. If the SIGPWR handler gets clobbered and the process is terminated by signal 30, the child does not exit cleanly. In that case:
proc.signalCodeis"SIGPWR", notnull→ line 53expect(proc.signalCode).toBeNull()fails.- The child never reaches
console.log("ok")→ line 55expect(stdout.trim()).toBe("ok")fails. exitCodeis non-zero / null → line 56expect(exitCode).toBe(0)fails.
And if ASAN itself reports a memory error and aborts, it does so with a non-zero exit code (and typically
SIGABRT), so the same three positive assertions catch it before the stderr check matters.
In every scenario where
not.toContain("AddressSanitizer")would fail, at least one of the surrounding positive assertions has already failed. There is no execution path where this line is the sole failing assertion.Why the existing assertions are sufficient
The test already follows the recommended structure: it asserts on
signalCode === null,stdout.trim() === "ok", andexitCode === 0. These are positive assertions on observable process state and they fully cover the regression being tested (process terminated by SIGPWR during GC). The stderr check is pure belt-and-suspenders that violates a documented "NEVER" rule.Fix
Just delete line 54. Replacing it with
expect(stderr).toBe("")(the CLAUDE.md-preferred form) is also an option but may be too strict here — 64 concurrentopenInEditorcalls against a fakecodebinary could plausibly emit incidental noise on stderr without indicating failure, so dropping the line entirely is the safer choice.This is a test-hygiene/convention nit, not a functional bug in the SIGPWR fix itself.
-
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@test/js/bun/spawn/spawnSync-sigpwr-gc.test.ts`:
- Around line 41-49: Change the subprocess spawn options to pipe stderr (replace
stderr: "inherit" with stderr: "pipe"), capture proc.stderr.text() alongside
proc.stdout.text() and proc.exited (e.g. await Promise.all([... ,
proc.stderr.text(), proc.exited])), store that value in a stderr variable, and
insert the CI-friendly assertion if (exitCode !== 0) { expect(stderr).toBe("");
} immediately before expect(exitCode).toBe(0); keep the existing checks on
proc.signalCode and stdout.trim() as-is.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: bea85ffa-50b0-4816-90cc-048b3b916ad6
📒 Files selected for processing (1)
test/js/bun/spawn/spawnSync-sigpwr-gc.test.ts
| stdout: "pipe", | ||
| stderr: "inherit", | ||
| }); | ||
|
|
||
| const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); | ||
|
|
||
| expect(proc.signalCode).toBeNull(); | ||
| expect(stdout.trim()).toBe("ok"); | ||
| expect(exitCode).toBe(0); |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial | ⚡ Quick win
Pipe stderr for better CI failure diagnostics.
With stderr: "inherit", you cannot programmatically assert stderr contents or surface them in the test diff on failure. Per repo conventions for subprocess tests using bunEnv, prefer piping stderr and asserting it's empty (after filtering ASAN noise if needed).
♻️ Suggested change
stdout: "pipe",
- stderr: "inherit",
+ stderr: "pipe",
});
- const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
+ const [stdout, stderr, exitCode] = await Promise.all([
+ proc.stdout.text(),
+ proc.stderr.text(),
+ proc.exited,
+ ]);
expect(proc.signalCode).toBeNull();
expect(stdout.trim()).toBe("ok");
+ if (exitCode !== 0) {
+ expect(stderr).toBe("");
+ }
expect(exitCode).toBe(0);Based on learnings: "In oven-sh/bun Jest/Bun test files under test/js/ that spawn subprocesses using bunEnv from the harness module, it's safe and intentional to assert expect(stderr).toBe("") unconditionally" and "write if (exitCode !== 0) { expect(stderr).toBe(""); } immediately before expect(exitCode).toBe(0)."
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| stdout: "pipe", | |
| stderr: "inherit", | |
| }); | |
| const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); | |
| expect(proc.signalCode).toBeNull(); | |
| expect(stdout.trim()).toBe("ok"); | |
| expect(exitCode).toBe(0); | |
| stdout: "pipe", | |
| stderr: "pipe", | |
| }); | |
| const [stdout, stderr, exitCode] = await Promise.all([ | |
| proc.stdout.text(), | |
| proc.stderr.text(), | |
| proc.exited, | |
| ]); | |
| expect(proc.signalCode).toBeNull(); | |
| expect(stdout.trim()).toBe("ok"); | |
| if (exitCode !== 0) { | |
| expect(stderr).toBe(""); | |
| } | |
| expect(exitCode).toBe(0); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@test/js/bun/spawn/spawnSync-sigpwr-gc.test.ts` around lines 41 - 49, Change
the subprocess spawn options to pipe stderr (replace stderr: "inherit" with
stderr: "pipe"), capture proc.stderr.text() alongside proc.stdout.text() and
proc.exited (e.g. await Promise.all([... , proc.stderr.text(), proc.exited])),
store that value in a stderr variable, and insert the CI-friendly assertion if
(exitCode !== 0) { expect(stderr).toBe(""); } immediately before
expect(exitCode).toBe(0); keep the existing checks on proc.signalCode and
stdout.trim() as-is.
There was a problem hiding this comment.
LGTM — one-line removal of SIGPWR from the Linux forwarding list, clearly correct given JSC's use of SIGPWR for GC thread suspension.
Extended reasoning...
Overview
This PR removes M(SIGPWR) from the FOR_EACH_LINUX_ONLY_SIGNAL macro in src/jsc/bindings/c-bindings.cpp (one deleted line plus a 3-line explanatory comment) and adds a Linux-only regression test in test/js/bun/spawn/spawnSync-sigpwr-gc.test.ts that stresses concurrent openInEditor → spawnSync calls against GC to verify the SIGPWR handler is no longer clobbered.
Security risks
None. The change is purely subtractive — it stops spawnSync from replacing the process-wide SIGPWR handler on Linux. There is no new attack surface, no input handling, and no auth/crypto/permissions involvement. The test only manipulates a temp dir, a symlink to sleep, and PATH for a child Bun process.
Level of scrutiny
Low-to-moderate. Signal-handler registration is process-global state, so changes here deserve a careful read, but this particular change is a strict reduction in scope: one fewer signal is touched. The rationale (JSC uses SIGPWR for GC thread suspend/resume in WTF/wtf/posix/ThreadingPOSIX.cpp) is correct and well-documented, and the npm list this was copied from targets V8 where SIGPWR is unused — so dropping it has no user-visible downside while fixing a real fuzzer-found crash.
Other factors
My two earlier inline comments were explicitly flagged as pre-existing issues (SIGPOLL/SIGIOT aliasing, and the stale main-thread-only comment / broader previous_actions[] race). The author verified the aliasing observation and reasonably scoped both out to a follow-up to keep this PR focused on the fuzzer crash; neither blocks this change. CodeRabbit's stderr-piping suggestion is a test-diagnostics nit, not a correctness issue. No bugs were found by the bug-hunting system on this revision. CI had failures on an earlier commit and a follow-up commit (test: drop negative crash-marker assertion) was pushed; the source change itself is sound regardless.
|
CI status: 68/69 checks pass. The one failure is The new Already re-rolled once for unrelated flakes on build 55633 ( |
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.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/jsc/bindings/c-bindings.cpp (1)
1016-1022:⚠️ Potential issue | 🟠 Major | ⚡ Quick winOnly clear the forwarded PID on the outermost unregister.
Line 1018 resets
Bun__currentSyncPIDbefore the depth check. If twospawnSynccalls overlap, the first inner unregister leavessignalForwardingDepth > 0but disables forwarding for the still-active outer call, so a later signal in that window never reaches the child.Suggested fix
extern "C" void Bun__unregisterSignalsForForwarding() { - Bun__currentSyncPID = 0; - std::lock_guard<std::mutex> lock(signalForwardingLock); if (--signalForwardingDepth != 0) return; + + Bun__currentSyncPID = 0; `#define` UNREGISTER_SIGNAL(SIG) \ if (sigaction(SIG, &previous_actions[SIG], NULL) == -1) { \ }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/jsc/bindings/c-bindings.cpp` around lines 1016 - 1022, The code resets Bun__currentSyncPID before checking signalForwardingDepth, which clears the forwarded PID prematurely for nested spawnSync calls; modify Bun__unregisterSignalsForForwarding so it acquires signalForwardingLock, decrements signalForwardingDepth, and only when signalForwardingDepth reaches 0 does it clear Bun__currentSyncPID and return—leave Bun__currentSyncPID unchanged when depth stays > 0 to ensure outer calls still forward signals.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@src/jsc/bindings/c-bindings.cpp`:
- Around line 1016-1022: The code resets Bun__currentSyncPID before checking
signalForwardingDepth, which clears the forwarded PID prematurely for nested
spawnSync calls; modify Bun__unregisterSignalsForForwarding so it acquires
signalForwardingLock, decrements signalForwardingDepth, and only when
signalForwardingDepth reaches 0 does it clear Bun__currentSyncPID and
return—leave Bun__currentSyncPID unchanged when depth stays > 0 to ensure outer
calls still forward signals.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 465df2fa-68f7-42bb-8adf-a435cfd0251b
📒 Files selected for processing (1)
src/jsc/bindings/c-bindings.cpp
|
CI on 7e2c283 (the reentrancy fix): 73/74 checks pass. The one failure is No test failures on this build; only auto-retried flakes in the warning annotations ( Already used my one retrigger on build 55649; not pushing another. The expired darwin-14-aarch64 job just needs a retry. |
Fuzzer fingerprint:
11551e051c08db2cWhat
Remove
SIGPWRfrom the set of signals thatspawnSyncforwards to the child on Linux.Why
On Linux, JSC uses
SIGPWRfor GC thread suspend/resume (WTF/wtf/posix/ThreadingPOSIX.cpp).Bun__registerSignalsForForwarding()was replacing theSIGPWRhandler (withSA_RESETHAND) and stashing the previous handler in a process-global, unsynchronized array.Bun.openInEditorspawns a detached thread per call that runsspawnSyncinternally. With many concurrent calls, the register/unregister paths race on the global table and can leaveSIGPWRatSIG_DFL. The next time the GC sendsSIGPWRto suspend a thread, the process terminates with signal 30.Minimal repro:
SIGPWRis not a user-visible signal in this context (the npm list it was copied from targets Node/V8, whereSIGPWRis unused), so there is no reason to forward it.Test
Added a Linux-only regression test that points editor auto-detection at a fake
code→sleepbinary, fires many concurrentopenInEditorcalls so theirspawnSyncsignal-forwarding windows overlap, then allocates enough to trigger a concurrent GC. Before this change the child dies withSIGPWR; after, it exits cleanly.