-
Notifications
You must be signed in to change notification settings - Fork 4.7k
spawnSync: make signal-forwarding register/unregister safe for concurrent callers #30956
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
base: main
Are you sure you want to change the base?
Changes from all commits
062df75
df6ebae
9179fad
75096d0
db5ce58
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 |
|---|---|---|
|
|
@@ -908,14 +908,23 @@ | |
| #if OS(LINUX) || OS(DARWIN) || OS(FREEBSD) | ||
| #include <signal.h> | ||
| #include <pthread.h> | ||
| #include <mutex> | ||
|
|
||
| // 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,16 +938,15 @@ | |
| 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/posix/ThreadingPOSIX.cpp). Overriding it here breaks GC and the | ||
| // SA_RESETHAND disposition leaves it at SIG_DFL after one delivery, which | ||
| // kills the process on the next collection. | ||
| // SIGPOLL is omitted because it aliases SIGIO (see SIGIOT note above). | ||
| #define FOR_EACH_LINUX_ONLY_SIGNAL(M) \ | ||
| M(SIGPOLL); \ | ||
| M(SIGSTKFLT); | ||
|
claude[bot] marked this conversation as resolved.
|
||
|
|
||
| #endif | ||
|
|
@@ -979,6 +987,10 @@ | |
|
|
||
| extern "C" void Bun__registerSignalsForForwarding() | ||
| { | ||
| std::lock_guard<std::mutex> lock(signalForwardingLock); | ||
| if (signalForwardingDepth++ != 0) | ||
| return; | ||
|
|
||
| Bun__pendingSignalToSend = 0; | ||
| struct sigaction sa; | ||
| memset(&sa, 0, sizeof(sa)); | ||
|
|
@@ -1004,8 +1016,12 @@ | |
|
|
||
| extern "C" void Bun__unregisterSignalsForForwarding() | ||
| { | ||
| Bun__currentSyncPID = 0; | ||
|
|
||
| std::lock_guard<std::mutex> lock(signalForwardingLock); | ||
| if (--signalForwardingDepth != 0) | ||
| return; | ||
|
Check warning on line 1023 in src/jsc/bindings/c-bindings.cpp
|
||
|
Comment on lines
1019
to
+1023
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. 🟡 Minor: Extended reasoning...What this is
Bun__currentSyncPID = 0; // line 1019 — unconditional
std::lock_guard<std::mutex> lock(signalForwardingLock);
if (--signalForwardingDepth != 0)
return; // inner caller: no-opThe depth counter added in db5ce58 is meant to make nested register/unregister pairs no-ops so only the outermost pair touches process-wide state. But the PID reset on line 1019 sits above both the lock and the depth check, so every caller — inner or outer — zeroes Concrete walk-through (the ordering where this bites)
If line 1019 were below the depth check, step 3 would leave Why this isn't already preventedThe new comment at lines 912-916 says "only the outermost pair touches process-wide signal dispositions" — and that's accurate for the Addressing the counter-argumentThere's a reasonable case that the current placement is intentional. In the opposite interleaving (main-thread So neither placement is correct for all interleavings — that's the acknowledged singleton limitation, and a real fix needs per-caller PID tracking (out of scope here). The point of this comment is narrower: the placement looks like an oversight relative to the depth guard added two lines below it, and there is at least one realistic ordering (steps 1-4 above) where it discards a still-valid PID that moving it would preserve. Suggested actionGiven the stale-PID tradeoff, I'd lean toward leaving the code as-is and adding a one-line comment above line 1019 noting that the reset is deliberately outside the depth guard (zero is safer than a possibly-reaped inner PID). Alternatively, drop line 1019 entirely — every caller already does
Collaborator
Author
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. Yes — the reset is intentionally outside the depth guard. With overlapping callers the PID slot is already a last-writer-wins singleton, so by the time an inner unregister runs, the slot usually holds the inner (now-reaped) child anyway. Zeroing it makes the handler fall back to the pending-signal path instead of kill()ing a possibly-recycled PID, which is the safer failure mode. A real fix is per-caller PID tracking, which is out of scope here. Leaving the code as-is; happy to add the one-line comment if a maintainer prefers it inline. |
||
|
|
||
| #define UNREGISTER_SIGNAL(SIG) \ | ||
| if (sigaction(SIG, &previous_actions[SIG], NULL) == -1) { \ | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,50 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { expect, test } from "bun:test"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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: "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); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+41
to
+49
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. 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win Pipe stderr for better CI failure diagnostics. With ♻️ 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 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.