Skip to content

process: guard JSC's GC suspend signal against unsolicited SIGPWR on Linux - #33777

Open
robobun wants to merge 8 commits into
mainfrom
farm/387ebfac/sigpwr-guard
Open

process: guard JSC's GC suspend signal against unsolicited SIGPWR on Linux#33777
robobun wants to merge 8 commits into
mainfrom
farm/387ebfac/sigpwr-guard

Conversation

@robobun

@robobun robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

What

Delivering SIGPWR (signal 30) to a Bun process on Linux segfaults the whole runtime:

process.on("SIGPWR", () => console.log("SIGPWR handler ran"));
process.kill(process.pid, 30);
setTimeout(() => console.log("survived"), 150);
panic(main thread): Segmentation fault at address 0x58
AddressSanitizer: SEGV on unknown address 0x000000000058 ... pc = WTF::Thread::signalHandlerSuspendResume (wtf/posix/ThreadingPOSIX.cpp:123)

Reachable three ways: process.kill(pid, 30), subprocess.kill("SIGPWR"), or kill -PWR <pid> from outside the process. The last one matters because container managers (lxc stop, systemd) send SIGPWR to PID 1 on power events, so a Bun server running as PID 1 segfaults instead of shutting down cleanly. Under Node the same code runs the listener and exits 0.

Cause

On Linux JSC uses SIGPWR to suspend and resume JS threads for conservative stack scanning. WTF::Thread::signalHandlerSuspendResume loads a file-static targetThread pointer and immediately dereferences thread->m_suspendCount. targetThread is only set by JSC's own Thread::suspend()/resume() right before they pthread_kill the target, so any SIGPWR delivered from outside that path sees targetThread == nullptr and reads offset 0x58 of null.

src/jsc/bindings/c-bindings.cpp already documents that SIGPWR is reserved for JSC's GC, but nothing protects the delivery path.

Fix

Install a thin wrapper over WTF's handler immediately after WTF::initializeMainThread(). JSC's suspend/resume always delivers via pthread_kill from our own process, which the kernel reports as si_code == SI_TKILL with si_pid == getpid(); that path is passed through unchanged. Anything else (all three repros above use kill(2), which is SI_USER) is forwarded into the JS signal queue instead of reaching WTF.

Alongside that:

  • SIGPWR is added to the process signal name tables so process.kill(pid, "SIGPWR") and process.on("SIGPWR", fn) are wired up.
  • The listener add/remove path in onDidChangeListeners no longer touches the sigaction for g_wtfConfig.sigThreadSuspendResume; the wrapper is permanent and already feeds Bun__onPosixSignal.

The one intentional divergence from Node: with no listener registered, an unsolicited SIGPWR is ignored rather than terminating the process, because JSC still owns the disposition.

Verification

bun bd test test/js/node/process/process-sigpwr.test.ts
  7 pass / 0 fail

Covers self-kill by number and by name, external process.kill on a child, subprocess.kill("SIGPWR"), no-listener survival, listener add/remove not resetting the disposition, and a GC stress loop (Bun.gc(true) interleaved with process.kill(pid, 30)) to prove the SI_TKILL gate still lets JSC's suspend/resume through.

On Linux, JSC uses SIGPWR to suspend and resume JS threads for
conservative stack scanning. WTF::Thread::signalHandlerSuspendResume
unconditionally dereferences a file-static targetThread pointer that
only JSC's own suspend() populates, so any SIGPWR delivered from
outside that path (process.kill(pid, 30), subprocess.kill('SIGPWR'),
or an external kill -PWR) null-dereferenced at offset 0x58 and
segfaulted the whole process.

Install a thin wrapper over WTF's handler right after
WTF::initializeMainThread: signals that arrive via pthread_kill from
our own process (si_code == SI_TKILL, si_pid == getpid()) are passed
through to JSC unchanged; everything else is forwarded to the JS
signal queue so process.on('SIGPWR', ...) fires like it does under
Node. SIGPWR is also added to the process signal name tables so it
can be used with process.kill and process.on, and the listener
add/remove path is taught not to touch the sigaction for JSC's
reserved signal.
@github-actions github-actions Bot added the claude label Jul 8, 2026
@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:59 PM PT - Jul 8th, 2026

@robobun, your commit 533775d has 1 failures in Build #70632 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33777

That installs a local version of the PR into your bun-33777 executable, so you can run:

bun-33777 --bun

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. Event loop permanently dies when Go CGo .so + WASM coexist on Linux (SIGPWR storm) #31158 - SIGPWR storm when Go CGo + WASM coexist on Linux; this PR's SIGPWR guard discriminates internal vs external SIGPWR delivery
  2. Segfault in JSC thread suspension on 1.3.14 when started via docker exec (fine as main process) - regression since 1.3.11 #31832 - Segfault in JSC thread suspension when started via docker exec; this PR guards against external SIGPWR interfering with JSC's suspension mechanism in container environments

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #31158
Fixes #31832

🤖 Generated with Claude Code

@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

Neither of those is closed by this change.

This PR only changes what happens when SIGPWR arrives via kill(2) (SI_USER), which is the null deref at 0x58 in signalHandlerSuspendResume when targetThread has never been set.

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. process: accept SIGSTKFLT/SIGPOLL/SIGPWR by name and fire aliased signal listeners #33776 - Both PRs add SIGPWR to signal name/number tables in BunProcess.cpp and modify signal listener registration; process: accept SIGSTKFLT/SIGPOLL/SIGPWR by name and fire aliased signal listeners #33776 also covers SIGSTKFLT/SIGPOLL aliases while process: guard JSC's GC suspend signal against unsolicited SIGPWR on Linux #33777 adds the GC suspend guard

🤖 Generated with Claude Code

@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

Partial overlap, not a duplicate.

#33776 adds SIGSTKFLT/SIGPOLL/SIGPWR to the process signal name tables and teaches Bun__onSignalForJS to emit every alias for a given number. It does not touch the SIGPWR disposition, so on its own process.kill(pid, "SIGPWR") starts resolving and then segfaults the target instead of throwing ERR_UNKNOWN_SIGNAL.

This PR's load-bearing change is the si_code guard that wraps WTF::Thread::signalHandlerSuspendResume; the name-table additions here are only the minimum needed to let the listener fire. That part overlaps and will merge-conflict on the signalNames[] indices (this PR puts SIGPWR at index 32, #33776 puts it at 34 after SIGSTKFLT/SIGPOLL). Whichever lands second needs a small rebase; happy to do that here once #33776 is in.

@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 25 seconds

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 46fadbfa-7223-4f75-b161-3a78c9431cee

📥 Commits

Reviewing files that changed from the base of the PR and between 08b822b and 533775d.

📒 Files selected for processing (2)
  • src/jsc/bindings/BunProcess.cpp
  • test/js/node/process/process-sigpwr.test.ts

Walkthrough

Adds SIGPWR mapping support, installs a Linux guard around JSC suspend/resume signal handling, wires that guard into JSC initialization, and adds Linux-only tests for SIGPWR delivery and listener behavior.

Changes

SIGPWR support and suspend/resume guard

Layer / File(s) Summary
SIGPWR signal name and number registration
src/jsc/bindings/BunProcess.cpp
Adds SIGPWR to the static signal name list and registers its numeric mapping in the signal maps.
Suspend/resume sigaction guard implementation
src/jsc/bindings/BunProcess.cpp
Adds a Linux forwarding guard for g_wtfConfig.sigThreadSuspendResume that handles expected SI_TKILL deliveries and forwards unexpected ones to Bun__onPosixSignal.
Signal listener install/uninstall wiring
src/jsc/bindings/BunProcess.cpp
Updates POSIX listener install/uninstall flow to skip Linux replacement and restoration of g_wtfConfig.sigThreadSuspendResume while still managing other signals.
Guard installation at JSC initialization
src/jsc/bindings/ZigGlobalObject.cpp
Declares and calls Bun__installSigThreadSuspendResumeGuard() on Linux during JSCInitialize.
SIGPWR test coverage
test/js/node/process/process-sigpwr.test.ts
Adds Linux-only tests for SIGPWR listeners, unsolicited delivery, GC stress, and listener removal behavior.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly states the Linux SIGPWR/JSC signal guard change and is concise and specific.
Description check ✅ Passed The description covers the PR's purpose and includes a concrete verification section with test results.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 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/node/process/process-sigpwr.test.ts`:
- Around line 146-173: The GC stress test in the SIGPWR case is too expensive
for debug/ASAN lanes and can time out before it proves anything. Update the test
body around the concurrent case in process-sigpwr.test.ts to reduce the loop
count and/or workload when isDebug is true, while keeping the full 50-iteration
stress path for release runs. Use the existing test name and the Bun.gc(true),
process.kill, and setImmediate loop as the place to apply the conditional
scaling.
- Around line 11-38: The SIGPWR test file repeats the same
Bun.spawn/drain/assert pattern across multiple cases, so extract a shared helper
around Bun.spawn, Promise.all([proc.stdout.text(), proc.stderr.text(),
proc.exited]), and the returned stdout/stderr/exitCode/signalCode shape. Update
the individual test cases to call that helper with only the script and expected
output differences, using the existing test names and the shared runScript-style
helper to keep the assertions centralized.
- Line 18: The SIGPWR signal is hardcoded as the bare number 30 in multiple
places, which should be replaced with a named constant for clarity and
portability. Update the process-sigpwr tests to define and use a single SIGPWR
constant (preferably sourced from os.constants.signals.SIGPWR, with a local
fallback if needed) and replace all direct process.kill(..., 30) usages in the
relevant test cases with that constant.
- Around line 65-98: The helper spawnAndSignalAfterReady is only reading
proc.stdout in its loop and waits until the end to call proc.stderr.text(),
which can deadlock if stderr fills first. Update the test to drain proc.stderr
concurrently while the stdout reader loop is running, and keep the existing
ready-detection/deliver(proc) flow intact so the child cannot block on an unread
pipe.
🪄 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: 101aa7a4-3f32-4779-b397-3027a96ed350

📥 Commits

Reviewing files that changed from the base of the PR and between 332f744 and 725f62c.

📒 Files selected for processing (3)
  • src/jsc/bindings/BunProcess.cpp
  • src/jsc/bindings/ZigGlobalObject.cpp
  • test/js/node/process/process-sigpwr.test.ts

Comment thread test/js/node/process/process-sigpwr.test.ts Outdated
Comment thread test/js/node/process/process-sigpwr.test.ts Outdated
Comment thread test/js/node/process/process-sigpwr.test.ts
Comment thread test/js/node/process/process-sigpwr.test.ts
Extract a shared runScript helper, drain stderr concurrently with the
stdout reader in spawnAndSignalAfterReady, and scale the GC stress loop
down under debug builds.
Comment thread src/jsc/bindings/BunProcess.cpp Outdated
getpid() is async-signal-safe, and a cached PID goes stale after
fork-without-exec. The inline call is simpler and fork-correct.
Comment thread test/js/node/process/process-sigpwr.test.ts Outdated
With only the main thread registered in MachineThreads, Bun.gc(true) has
nothing to suspend and never issues an internal pthread_kill(SIGPWR), so
the SI_TKILL passthrough was not actually exercised. A Worker gives the
collector a second thread to suspend on every iteration.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/node/process/process-sigpwr.test.ts`:
- Around line 108-124: The Worker readiness wait in the GC suspend/resume test
only resolves on the first message and can hang if the Worker fails before
posting it. Update the promise around the `new Worker(...)` setup in
`process-sigpwr.test.ts` so it rejects on Worker `error` and `close` (and any
other failure/termination signal used in this test harness) instead of waiting
indefinitely. Keep the change localized to the `test.concurrent` block and the
`w.addEventListener("message", ...)` readiness logic.
🪄 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: b713c19b-5cc4-4414-b4ac-71d63fec3bd2

📥 Commits

Reviewing files that changed from the base of the PR and between a0d588d and 08b822b.

📒 Files selected for processing (2)
  • src/jsc/bindings/BunProcess.cpp
  • test/js/node/process/process-sigpwr.test.ts

Comment thread test/js/node/process/process-sigpwr.test.ts
Comment thread test/js/node/process/process-sigpwr.test.ts Outdated
Workers get their own VM/Heap/MachineThreads in Bun, so the main VM's
collector still had nothing to suspend. BUN_JSC_collectContinuously runs
a dedicated collector thread in the same VM that suspends the main
mutator via pthread_kill(SIGPWR), so a broken passthrough would hang.
Comment thread src/jsc/bindings/BunProcess.cpp
SigintWatcher::install (reached via vm.runInNewContext with
breakOnSigint) primes the signal ring buffer without ever populating
signalNumberToNameMap, so a signal drained from the ring in that state
would null-deref. Return early when the map has not been built; there
is no listener to emit to anyway. Adds a SIGPWR test for this path.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All my earlier feedback has been addressed and I found nothing further, but this interposes a process-wide signal handler on JSC's GC suspend/resume path — worth a human sanity-check on the si_code == SI_TKILL discriminator and the onDidChangeListeners sigaction skip.

Extended reasoning...

Overview

This PR wraps WTF's signalHandlerSuspendResume (SIGPWR on Linux) with a thin guard installed during JSCInitialize. The guard passes through deliveries with si_code == SI_TKILL && si_pid == getpid() to WTF unchanged and diverts everything else into Bun__onPosixSignal, so an external kill -PWR no longer null-derefs targetThread inside WTF. Alongside that: SIGPWR is added to the signal name/number tables, onDidChangeListeners now skips touching the sigaction for g_wtfConfig.sigThreadSuspendResume on both add and remove, and Bun__onSignalForJS gained a null-check on signalNumberToNameMap. A new 8-case Linux-only test file covers self-kill by number and name, external delivery, subprocess.kill, no-listener survival, breakOnSigint priming, listener add/remove, and a collectContinuously stress loop.

Security risks

None identified. The change narrows an existing crash into a handled event; the discriminator only widens what reaches WTF's handler when SI_TKILL from self, and everything else is routed to the existing JS signal queue. No new attack surface, no auth/crypto/permissions.

Level of scrutiny

High. The guard runs inside a signal handler on every GC thread suspension for every Linux process. Its correctness depends on (a) JSC continuing to deliver via pthread_kill (so the kernel reports SI_TKILL), (b) Bun__onPosixSignal being async-signal-safe, and (c) the onDidChangeListeners skip not leaving any path that resets the disposition to SIG_DFL. These are the kind of assumptions a maintainer familiar with the WebKit threading model and prior SIGPWR issues (#31158, #31832) should confirm — particularly whether the SI_TKILL-from-self gate is robust across the WebKit upgrade cadence.

Other factors

I left four inline comments on earlier revisions (cached PID → inline getpid(), single-threaded GC test not exercising the passthrough, per-VM MachineThreads meaning a Worker doesn't help, and the signalNumberToNameMap null-deref via SigintWatcher); all were addressed in 2f0d2d2, 08b822b/a87266e, and 533775d respectively. All CodeRabbit threads are resolved. The bug-hunting system found nothing on the current head. Test coverage looks thorough and the collectContinuously case now genuinely exercises the SI_TKILL passthrough. There is a known partial overlap with #33776 on the signal-name-table indices that will need a small rebase whichever lands second.

@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

CI on 533775d: 283 passed, 1 failed. The only failure is test/js/sql/postgres-binary-array-bounds.test.ts on the windows 2019 x64-baseline lane with ERR_POSTGRES_CONNECTION_REFUSED (the Postgres service did not come up on that runner). Same infra issue hit build 70599 on the windows 2019 x64 lane.

This diff is Linux-only: the guard and its call site are under #if OS(LINUX), the name-table additions are under #ifdef SIGPWR (undefined on Windows), the new test is describe.skipIf(!isLinux), and the signalNumberToNameMap null check is platform-agnostic but unrelated to SQL. process-sigpwr.test.ts passed on every Linux lane.

Ready for review; the Windows Postgres failure is infra, not this change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant