-
Notifications
You must be signed in to change notification settings - Fork 4.7k
Don't forward SIGPWR in spawnSync signal handling on Linux #31104
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -933,10 +933,12 @@ | |
| M(SIGIO); | ||
|
|
||
| #if OS(LINUX) | ||
| // SIGPWR is intentionally excluded: JSC uses it for GC thread suspend/resume | ||
| // on Linux (see WTF ThreadingPOSIX.cpp), so replacing its handler here would | ||
| // break the collector and can terminate the process with signal 30. | ||
|
Check notice on line 938 in src/jsc/bindings/c-bindings.cpp
|
||
| #define FOR_EACH_LINUX_ONLY_SIGNAL(M) \ | ||
| M(SIGPOLL); \ | ||
| M(SIGPWR); \ | ||
| M(SIGSTKFLT); | ||
|
Check notice on line 941 in src/jsc/bindings/c-bindings.cpp
|
||
|
Comment on lines
939
to
941
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟣 Pre-existing, but since this PR is auditing this exact list: Extended reasoning...What the bug isOn Linux, several signal names are aliases for the same numeric signal. From
Code path
sigaction(SIG, &sa, &previous_actions[SIG]);When this runs twice for the same numeric
Why nothing prevents itThis is the same npm-derived signal list the PR is fixing. npm's JS-level loop is order-insensitive because Node deduplicates by signal number internally, but here the C++ macro expansion calls Step-by-step proof (SIGIO/SIGPOLL, signal 29)
The identical sequence applies to signal 6 via ImpactAny user-installed FixDrop the redundant aliases:
Alternatively, guard |
||
|
|
||
| #endif | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| import { expect, test } from "bun:test"; | ||
| import { bunEnv, bunExe } from "harness"; | ||
|
|
||
| // Bun.openInEditor spawns a detached thread that runs sync::spawn, which | ||
| // installs signal-forwarding handlers. On Linux the forwarding list used to | ||
| // include SIGPWR, which JSC uses for GC thread suspend/resume. Overlapping | ||
| // register/unregister calls from multiple editor threads could leave the | ||
| // SIGPWR disposition at SIG_DFL, so the next GC-driven SIGPWR killed the | ||
| // process with signal 30. | ||
| test.skipIf(process.platform !== "linux")( | ||
| "Bun.openInEditor does not clobber the GC thread-suspend signal handler", | ||
| async () => { | ||
| const script = ` | ||
| for (let i = 0; i < 30; i++) { | ||
| try { Bun.openInEditor("/tmp/bun-open-in-editor-gc-" + i); } catch {} | ||
| } | ||
| await Bun.sleep(50); | ||
| for (let i = 0; i < 100; i++) { | ||
| new Uint8Array(10000); | ||
| Bun.gc(true); | ||
| } | ||
| `; | ||
| await using proc = Bun.spawn({ | ||
| cmd: [bunExe(), "-e", script], | ||
| env: { ...bunEnv, PATH: "/nonexistent" }, | ||
| stdout: "ignore", | ||
| stderr: "ignore", | ||
| }); | ||
| const exitCode = await proc.exited; | ||
| expect(proc.signalCode).toBeNull(); | ||
| expect(exitCode).toBe(0); | ||
|
Check warning on line 31 in test/js/bun/util/openInEditor-gc.test.ts
|
||
|
Comment on lines
+26
to
+31
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 nit: Consider piping Extended reasoning...What the issue isThe new test spawns a subprocess with await using proc = Bun.spawn({
cmd: [bunExe(), "-e", script],
env: { ...bunEnv, PATH: "/nonexistent" },
stdout: "ignore",
stderr: "ignore",
});
const exitCode = await proc.exited;
expect(proc.signalCode).toBeNull();
expect(exitCode).toBe(0);The repo's testing convention (root How it would manifestWalk through a concrete failure: suppose a future change introduces a debug-build assertion inside
That's the entire output. Whoever triages it has to reproduce locally to find out what actually happened, because stderr was sent to Why existing assertions don't cover itThe Why
|
||
| }, | ||
| ); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟣 Worth noting as a follow-up: the PR description correctly identifies that
Bun.openInEditor()runssync::spawnon detached threads, which means the "We only ever use bun.spawnSync on the main thread" comment at line 912 is now known to be false, and the underlying race onprevious_actions[]+ the unconditionalmemsetstill applies to every other signal inFOR_EACH_SIGNAL(e.g. theSIGINT/SIGTERM→onExitSignalhandler that restores TTY state on Ctrl-C, or any userprocess.on('SIGHUP'/...)handler, can still be left atSIG_DFL). Removing SIGPWR is the right call regardless and fixes the crash, so this is a pre-existing issue and not a blocker — but it'd be good to at least update the stale comment, and ideally track a follow-up to add locking/refcounting aroundprevious_actions[]or stopopenInEditorfrom callingsync::spawnoff the main thread.Extended reasoning...
What this is
The PR's own root-cause analysis (in the description) establishes two things that the code change doesn't fully address:
c-bindings.cpp:912—// Note: We only ever use bun.spawnSync on the main thread.— is the stated justification forprevious_actions[]andBun__currentSyncPIDbeing unsynchronized process-globals, and it is now known to be false.previous_actions[]that the description spells out applies to every signal inFOR_EACH_SIGNAL, not justSIGPWR. RemovingSIGPWReliminates the GC-driven crash but leaves the race intact for the remaining ~15 signals.Code path
Bun.openInEditor→Editor::open(src/runtime/cli/open.rs:378) doesstd::thread::Builder::spawn(auto_close)— a detached thread.auto_close()(open.rs:518) callssync::spawn(...).sync::spawn→spawn_posix(src/spawn/process.rs:3133) constructsSignalForwarding::register(), which callsBun__registerSignalsForForwarding(); itsDropimpl callsBun__unregisterSignalsForForwarding()thencrash_handler::reset_on_posix().reset_on_posix()only re-installs the crash-class handlers (SIGSEGV/SIGILL/SIGBUS/SIGFPE), none of which are inFOR_EACH_SIGNAL, so it does not repair damage toSIGINT/SIGTERM/SIGHUP/etc.So
previous_actions[NSIG]and thememset(previous_actions, 0, ...)inBun__unregisterSignalsForForwarding()are reached from multiple detached threads concurrently with no locking — exactly what the line-912 comment says cannot happen.Why existing code doesn't prevent it
There is no mutex, refcount, or thread check around
Bun__registerSignalsForForwarding/Bun__unregisterSignalsForForwarding. The only "protection" is the comment asserting main-thread-only use, which the PR description itself disproves. Thereset_on_posix()call inSignalForwarding::Dropcovers a disjoint set of signals fromFOR_EACH_SIGNAL, so it doesn't help here.Step-by-step proof (remaining race)
Take
SIGINTas the example.bun_initialize_process()installsonExitSignal(which callsbun_restore_stdio()) forSIGINT/SIGTERMwhen any stdio is a TTY.Bun.openInEditor()calls spawn detached threads A and B.register—sigaction(SIGINT, forwarder, &previous_actions[SIGINT])savesonExitSignalintoprevious_actions[SIGINT].register— saves the forwarder (now the current handler) intoprevious_actions[SIGINT], overwritingonExitSignal.unregister—sigaction(SIGINT, &previous_actions[SIGINT], NULL)installs the forwarder (from step 3), thenmemset(previous_actions, 0, sizeof(previous_actions))zeroes the whole array.unregister—sigaction(SIGINT, &previous_actions[SIGINT], NULL)installs a zeroedstruct sigaction, i.e.sa_handler = 0 = SIG_DFL.After this settles,
SIGINTis atSIG_DFL. The next Ctrl-C terminates the process without runningbun_restore_stdio(), leaving the terminal in whatever state Bun (or a child) put it in. The same applies to user-installedprocess.on('SIGHUP'/'SIGUSR2'/...)handlers.Impact
Far less severe than the SIGPWR crash this PR fixes — GC fires SIGPWR during normal operation, whereas the remaining signals only matter on external delivery, and "multiple concurrent
Bun.openInEditor()calls" is a fuzzer-pathological pattern. The likely worst case is a missed TTY restore on Ctrl-C or a silently dropped user signal handler. So: real, pre-existing, strictly improved by this PR, and reasonable to defer.Suggested fix / follow-up
previous_actions[]/ thememsetwith a mutex + nesting refcount, or stopEditor::openfrom callingsync::spawnon a detached thread (use a non-signal-forwarding spawn for the editor process).