Skip to content

spawn: do not forward SIGPWR on Linux#31121

Closed
robobun wants to merge 1 commit into
mainfrom
farm/f304d21f/fix-sigpwr-signal-forwarding
Closed

spawn: do not forward SIGPWR on Linux#31121
robobun wants to merge 1 commit into
mainfrom
farm/f304d21f/fix-sigpwr-signal-forwarding

Conversation

@robobun

@robobun robobun commented May 20, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

On Linux, JavaScriptCore uses SIGPWR to suspend and resume threads during garbage collection (see ThreadingPOSIX.cpp).

Bun__registerSignalsForForwarding() (used by bun.spawnSync to forward signals like SIGINT/SIGTERM to child scripts) was installing a forwarding handler with SA_RESETHAND over the GC's SIGPWR handler. When a concurrent GC sent SIGPWR to a JS thread during the register/unregister window:

  1. the forwarding handler ran instead of JSC's suspend handler,
  2. SA_RESETHAND reset the disposition to SIG_DFL,
  3. the next SIGPWR (GC resume, or next suspend) terminated the process with "Power failure".

Bun.openInEditor() triggers this reliably because it runs bun.spawnSync on a detached background thread while the JS thread continues executing (and potentially running Bun.gc(true)), so the GC's pthread_kill(thread, SIGPWR) races the background thread's sigaction() calls.

How did you verify your code works?

Minimal repro (crashes 100% before, 0% after in debug builds):

for (let k = 0; k < 50; k++) {
  try { Bun.openInEditor("foo" + k); } catch {}
}
for (let i = 0; i < 200; i++) Bun.gc(true);

Before:

$ bun-debug repro.js
Power failure
exit=158  (128 + SIGPWR)

After: exits 0.

Added a regression test at test/js/bun/util/open-in-editor-gc.test.ts.

Fuzzer fingerprint: fe0542d56ab690b0

JavaScriptCore uses SIGPWR to suspend and resume threads during garbage
collection on Linux. Bun__registerSignalsForForwarding() (used by
bun.spawnSync) was installing a forwarding handler with SA_RESETHAND over
it. When a concurrent GC sent SIGPWR to a JS thread, the forwarding
handler ran instead of JSC's suspend handler, and SA_RESETHAND reset the
disposition to SIG_DFL, so the next SIGPWR terminated the process.

Bun.openInEditor() triggers this reliably because it runs bun.spawnSync
on a detached background thread while the JS thread continues executing
(and potentially collecting garbage).

Remove SIGPWR from the forwarded signal set on Linux.

Fuzzer fingerprint: fe0542d56ab690b0
@robobun

robobun commented May 20, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:53 PM PT - May 19th, 2026

@robobun, your commit 1f45203 has 9 failures in Build #56360 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 31121

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

bun-31121 --bun

@coderabbitai

coderabbitai Bot commented May 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 56b3a012-56a9-43b3-b046-35a0d0c50bba

📥 Commits

Reviewing files that changed from the base of the PR and between 2f9e77b and 1f45203.

📒 Files selected for processing (2)
  • src/jsc/bindings/c-bindings.cpp
  • test/js/bun/util/open-in-editor-gc.test.ts

Walkthrough

JavaScriptCore reserves SIGPWR for internal garbage-collection suspend/resume. This PR removes SIGPWR from the Linux signal-forwarding list in bun.spawnSync with documentation, then adds a regression test confirming that Bun.openInEditor() called during concurrent garbage collection no longer crashes the process.

Changes

SIGPWR Signal Handling Fix

Layer / File(s) Summary
Remove SIGPWR from signal forwarding
src/jsc/bindings/c-bindings.cpp
The FOR_EACH_LINUX_ONLY_SIGNAL macro omits SIGPWR with documentation explaining that JavaScriptCore uses it internally for garbage-collection suspend/resume and must not be forwarded in signal handling.
Regression test for openInEditor during concurrent GC
test/js/bun/util/open-in-editor-gc.test.ts
A Linux-only test spawns a child Bun process that calls Bun.openInEditor() repeatedly while forcing garbage collection, then asserts normal completion without signal termination.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'spawn: do not forward SIGPWR on Linux' directly and accurately summarizes the main change: removing SIGPWR from signal forwarding on Linux systems.
Description check ✅ Passed The description comprehensively covers both required template sections with detailed technical context, root cause analysis, verification steps, and a regression test.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. spawnSync: make signal-forwarding register/unregister safe for concurrent callers #30956 - Also removes SIGPWR from FOR_EACH_LINUX_ONLY_SIGNAL in c-bindings.cpp to fix the same JSC GC crash during spawnSync

🤖 Generated with Claude Code

@robobun

robobun commented May 20, 2026

Copy link
Copy Markdown
Collaborator Author

Duplicate of #30956, which has a more comprehensive fix (also handles the concurrent register/unregister race with a depth counter + mutex, and removes the SIGIOT/SIGPOLL aliases).

Fuzzer fingerprint fe0542d56ab690b0 is the same root cause.

@robobun robobun closed this May 20, 2026
@robobun robobun deleted the farm/f304d21f/fix-sigpwr-signal-forwarding branch May 20, 2026 06:52
Comment on lines +16 to +18
const { exitCode, signalCode } = Bun.spawnSync({
cmd: [bunExe(), "-e", script],
env: { ...bunEnv, EDITOR: undefined, VISUAL: undefined },

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.

🟡 Unsetting EDITOR/VISUAL is not enough to make this test hermetic: bunEnv spreads process.env (including PATH), and when no EDITOR/VISUAL is set EditorContext::detect_editor falls through to Editor::by_fallback, which probes PATH for code/subl/nvim/vim/etc. On a Linux dev box with VS Code installed, running this test will spawn 50 detached code fooN processes (50 editor tabs); on CI it may leave orphaned xdg-open invocations. Add PATH: "/dev/null" to the env override (or pass { editor: "/nonexistent" } as the second arg to Bun.openInEditor) — the SIGPWR race is still exercised either way since the detached spawn thread still runs.

Extended reasoning...

What the bug is

The new regression test attempts to prevent Bun.openInEditor from launching a real editor by setting env: { ...bunEnv, EDITOR: undefined, VISUAL: undefined }. This is insufficient: when neither an explicit editor option nor EDITOR/VISUAL is present, Bun falls back to probing PATH for a list of common editors and will happily launch whichever one it finds — 50 times, on detached background threads.

Code path

  1. bunEnv is defined as { ...process.env, ... } (test/harness.ts:50-51) and never overrides PATH, so the spawned child inherits the host PATH.
  2. The inner script calls Bun.openInEditor("foo" + k) with no second argument, so open_in_editor (src/runtime/api/BunObject.rs:1084-1095) sees no explicit editor and calls edit.auto_detect_editor(env).
  3. detect_editor (src/runtime/cli/open.rs:619-716) finds self.name empty and EDITOR/VISUAL unset, so it falls through to Editor::by_fallback at line 701.
  4. by_fallback (open.rs:221-246) iterates DEFAULT_PREFERENCE_LIST = [Vscode, Sublime, Atom, Neovim, Webstorm, Intellij, Textmate, Vim] (open.rs:392) and calls by_path_for_editor, which runs which() against env.get(b"PATH").
  5. If a match is found, editor.open() spawns a detached std::thread that calls sync::spawn (the same Bun__registerSignalsForForwarding path this PR fixes) with the resolved binary as argv[0].

Why the existing guard doesn't help

The EDITOR: undefined, VISUAL: undefined override only short-circuits the first two branches of detect_editor (the by_name lookups for $EDITOR and $VISUAL). The third branch — by_fallback — is gated solely on PATH, which the test leaves untouched. On Linux, bin_path()'s hardcoded fallback locations are macOS-only (open.rs:424-437), so PATH is the only thing that matters.

Step-by-step example

On a Linux developer machine with VS Code installed (/usr/bin/code in PATH):

  1. bun test test/js/bun/util/open-in-editor-gc.test.ts spawns the child with PATH=/usr/bin:..., EDITOR and VISUAL unset.
  2. For k=0: detect_editorby_fallbackwhich("code") resolves /usr/bin/codeEditor::Vscode, edit.path = "/usr/bin/code".
  3. editor.open() builds argv ["/usr/bin/code", "foo0"] (Vscode uses the binary directly, no xdg-open prefix — open.rs:286) and spawns it on a detached thread.
  4. Repeat 50× → 50 VS Code tabs open on the developer's desktop pointing at non-existent foo0..foo49.
  5. The child process exits; the spawned editor processes are orphaned (not reaped, not killed).

For vim/nvim the argv is prefixed with OPENER = b"xdg-open" (open.rs:20, 276-284), which on headless CI typically fails fast, so the impact there is lower — but it still spawns 50 processes per test run.

Impact

This is a test-hermeticity issue, not a correctness bug. The test itself will not fail or hang: the JS thread never joins the detached spawn threads, and auto_close swallows spawn errors via let _ = sync::spawn(...). The SIGPWR regression is still correctly exercised regardless of whether an editor is found (the detached thread still calls Bun__registerSignalsForForwarding either way). However:

  • On a developer machine with code/subl/idea in PATH, every run of this test pops 50 editor windows.
  • On CI with vim/nvim in PATH (very common on Linux images), it spawns 50 short-lived xdg-open processes per run, adding noise and orphans.

Fix

Add PATH: "/dev/null" (or PATH: "") to the env override:

env: { ...bunEnv, EDITOR: undefined, VISUAL: undefined, PATH: "/dev/null" },

Alternatively, pass an explicit non-existent editor so detect_editor never runs the fallback probe:

try { Bun.openInEditor("foo" + k, { editor: "/nonexistent" }); } catch {}

Either change keeps the regression coverage intact (the detached-thread spawnSync + concurrent GC race still fires) while making the test hermetic.

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