Spawn the editor from Bun.openInEditor without spawnSync's signal forwarding#31299
Spawn the editor from Bun.openInEditor without spawnSync's signal forwarding#31299robobun wants to merge 4 commits into
Conversation
…warding The detached editor thread went through sync::spawn (bun.spawnSync), whose signal-forwarding setup (Bun__registerSignalsForForwarding / previous_actions / Bun__currentSyncPID) is process-global and only safe on the main thread. Concurrent Bun.openInEditor calls raced on it and flipped the dispositions of unrelated signals process-wide. Use the minimal spawn+wait helper instead, matching the original std.process.Child behavior.
|
Updated 9:49 PM PT - May 23rd, 2026
✅ @robobun, your commit a1fb56409932f38ecef81f48d5e6fb6622b1415e passed in 🧪 To try this PR locally: bunx bun-pr 31299That installs a local version of the PR into your bun-31299 --bun |
|
Found 1 issue this PR may fix:
🤖 Generated with Claude Code |
|
Warning Review limit reached
Your plan currently allows 1 review/hour. Refill in 59 minutes and 47 seconds. Your organization has run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After more review capacity refills, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than trial, open-source, and free plans. In all cases, review capacity refills continuously over time. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
WalkthroughThis PR consolidates editor process spawning in ChangesEditor spawn refactoring and signal handling validation
Possibly related issues
Possibly related PRs
Suggested reviewers
🚥 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.
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/util/open-in-editor-gc.test.ts`:
- Around line 65-67: The test uses stdout.match(/^CHANGED:(.*)$/m) with a
non-null assertion which throws an unhelpful error if the fixture never prints
CHANGED:; replace the assertion with an explicit match variable (e.g. const
match = stdout.match(/^CHANGED:(.*)$/m)), assert match is truthy with a clear
error message mentioning “missing CHANGED: output” (or fail the test), then
parse JSON.parse(match[1]) into changed and continue the existing expect on
changed; update references to stdout, match, changed, and the regex
/^CHANGED:(.*)$/m in open-in-editor-gc.test.ts accordingly.
🪄 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: aed9a9c5-f556-4d56-9ba9-4e7b43139b49
📒 Files selected for processing (2)
src/runtime/cli/open.rstest/js/bun/util/open-in-editor-gc.test.ts
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
There was a problem hiding this comment.
LGTM — thanks for cleaning up the stale porting TODOs as well.
Extended reasoning...
Overview
This PR fixes a Fuzzilli-found crash (#31194) where concurrent Bun.openInEditor() calls from detached threads raced on bun.spawnSync's process-global signal-forwarding state (Bun__registerSignalsForForwarding / previous_actions[]), corrupting signal dispositions and getting the process killed by SIGPWR during GC. The fix replaces ~30 lines of sync::spawn setup (including a Windows MiniEventLoop leak workaround) in auto_close() with a one-line call to the existing bun_core::util::spawn_sync_inherit helper — a plain posix_spawn + waitpid (CreateProcessW on Windows) that touches no process-wide signal state. This matches what the original Zig code did (std.process.Child.spawn() + .wait()). A new Linux regression test samples SigCgt in /proc/self/status while hammering openInEditor and asserts the caught-signal mask never changes.
Security risks
None. The change narrows the spawn surface: it removes signal-handler manipulation and a per-call event-loop allocation, replacing them with a minimal spawn+wait that's already used by bun init and the crash handler. No new inputs, no privilege changes, no new code paths.
Level of scrutiny
Moderate — process spawning is sensitive, but this is a strict simplification that swaps a misused heavyweight primitive for an established lightweight one (spawn_sync_inherit is already exercised by init_command.rs and crash_handler/lib.rs). I read spawn_sync_inherit end-to-end: on Linux it's posix_spawn_bun + an EINTR-safe waitpid loop with no sigaction calls; on Windows it's std::process::Command (CreateProcessW) with inherited stdio and no event loop, which also eliminates the documented MiniEventLoop leak. Argv handling is unchanged (same (ptr, len) reconstruction into &[u8] slices, which spawn_sync_inherit accepts via impl AsRef<[u8]>).
Other factors
Both prior review nits have been addressed: my comment about the stale TODO(port) recommending bun.spawnSync was fixed in d68be24 (the Editor::open() comment now points at spawn_sync_inherit, and the obsolete SpawnedEditorContext TODO was removed), and CodeRabbit's regex-guard nit was fixed in a479191. The new test is well-constructed (warm-up before baselining, overlapping editor threads via sleep 0.15, forced GC, exit/signal assertions) and the PR description confirms it fails on current canary with the expected 14-signal delta. The musl CI entries are scripts/build/ci.ts build-step failures on a479191, not test failures, and the only commit since (d68be24) is comment-only. No CODEOWNERS cover these files.
There was a problem hiding this comment.
LGTM — focused fix that swaps the main-thread-only sync::spawn for the lightweight spawn_sync_inherit helper on the detached editor thread, restoring parity with the original Zig behavior; both earlier review nits have been addressed.
Extended reasoning...
Overview
This PR changes src/runtime/cli/open.rs to stop calling sync::spawn (bun.spawnSync) from the detached editor thread spawned by Bun.openInEditor, replacing it with bun_core::util::spawn_sync_inherit — a plain posix_spawn/CreateProcessW + wait helper with inherited stdio. It also removes the now-unneeded sync import, the Windows MiniEventLoop::init_global workaround (and its FIXME describing the per-call leak), and updates two stale TODO(port) comments that previously recommended the unsafe approach. A new Linux-only regression test in test/js/bun/util/open-in-editor-gc.test.ts hammers openInEditor concurrently while sampling SigCgt from /proc/self/status and asserts the caught-signal mask never changes, then forces GC to confirm the process survives SIGPWR traffic. Net diff in open.rs is roughly −30 lines.
Security risks
None identified. The change removes process-global signal-handler mutation from a non-main thread (a thread-safety hazard) rather than adding any new attack surface. spawn_sync_inherit is an existing, established helper already used elsewhere (e.g., crash_handler's llvm-symbolizer spawn); on Linux/FreeBSD it routes through posix_spawn_bun with explicit dup2 actions for fds 0–2, on macOS it uses posix_spawnp, and on Windows it uses CreateProcessW directly. The argv passed in is the same editor-binary + file-path argv that was previously handed to sync::spawn, so there's no new injection vector. The editor binary is whatever the user configured (or auto-detected) — unchanged from before.
Level of scrutiny
Medium. Process spawning and signal handling are subtle areas, but this is a simplification that restores parity with the original Zig implementation (std.process.Child.spawn() + .wait() on a detached thread, no signal forwarding). I verified spawn_sync_inherit in src/bun_core/util.rs:5088 and confirmed it does no signal-forwarding registration and touches no process-global mutable state — it just builds a NUL-terminated argv, spawns, and waitpids. The PR description's diagnosis of the race on previous_actions[] / Bun__currentSyncPID is accurate and matches the "main thread only" comment in c-bindings.cpp. The behavior difference (no Ctrl-C forwarding to the editor child) matches the pre-port Zig behavior, so this is not a regression.
Other factors
- Both prior review nits were addressed in follow-up commits: my comment about the stale
TODO(port): … calls bun.spawnSyncguidance inEditor::open()was fixed in d68be24, and CodeRabbit's nit about guarding theCHANGED:regex match was fixed in a479191. - The new test is well-designed: it uses an absolute path to
sleepas the editor so dozens of editor threads overlap, includes a warm-up call before baselining to avoid false positives from lazy one-time handler installation, and asserts on the precise observable (SigCgt mask) rather than just "didn't crash". The PR author confirmed it fails on canary and passes with the fix. - The Windows side benefit (eliminating the per-call
MiniEventLoop+uv_loop_tleak documented in the removed FIXME) falls out for free sincespawn_sync_inheritusesCreateProcessWdirectly. - A bot flagged potential duplicate PRs (#31297, #31195) — that's a merge-coordination question for maintainers, not a correctness concern with this PR.
What does this PR do?
Fixes #31194
Fixes a Fuzzilli-found crash (fingerprint
1f89a105f06c0e7e): a script that callsBun.openInEditor()tens of thousands of times (via a recursive constructor that swallows the stack-overflow error and calls it on every unwound frame) gets the process killed by signal 30 (SIGPWR — the signal JSC uses on Linux to suspend/resume threads for GC) with no output.Each
Bun.openInEditor()call spawns a detached editor thread, and that thread ran the fullsync::spawn(bun.spawnSync) machinery. spawnSync's signal-forwarding setup is process-global and written for main-thread-only use (c-bindings.cpp: "Note: We only ever use bun.spawnSync on the main thread"):Bun__registerSignalsForForwarding()/Bun__unregisterSignalsForForwarding()install a one-shot (SA_RESETHAND) forwarding handler over ~14 signals and save/restore the previous dispositions through a shared, unsynchronizedprevious_actions[]array that getsmemsetto zero after each restore, plus the sharedBun__currentSyncPID.With many concurrent
openInEditorcalls, those register/unregister cycles race each other: restores frequently re-install from the zeroed array, so the process-wide dispositions of SIGHUP, SIGINT, SIGQUIT, SIGTRAP, SIGABRT, SIGUSR2, SIGALRM, SIGTERM, SIGSTKFLT, SIGXCPU, SIGXFSZ, SIGVTALRM, SIGIO and SIGSYS flip between "one-shot forwarding handler" andSIG_DFLfor the duration of the storm and can be left corrupted afterwards. Tracing a release build during a 256-call storm shows ~250sigaction(SIGHUP, forwarding handler w/ SA_RESETHAND)installs and ~247 restores of a zeroedsigaction(i.e.SIG_DFL), ending with the handler gone. That is the same failure class as #31183 (which dropped SIGPWR itself from the forwarding list after an earlier fuzzer crash): process-wide signal handling gets scrambled by the editor threads while GC's SIGPWR suspend/resume traffic is in flight.The fix makes the detached editor thread spawn the editor with the minimal
bun_core::util::spawn_sync_inherit(plain spawn +waitpid, inherited stdio) instead ofsync::spawn. That matches what the original implementation did (std.process.Child.spawn()+wait()on the detached thread — no signal forwarding, no job control, noBun__currentSyncPID), so nothing in theopenInEditorpath touches process-wide signal state anymore. It also removes the per-callMiniEventLoopleak on Windows that the old code's FIXME described, sincespawn_sync_inheritusesCreateProcessWdirectly there.Note: I could not reproduce the exact SIGPWR termination locally (it needs the fuzzer machine's timing/parallelism — the crash is deterministic there), but the process-wide disposition corruption from this code path reproduces reliably on current canary and is what this PR removes.
How did you verify your code works?
test/js/bun/util/open-in-editor-gc.test.ts: it hammersBun.openInEditorwith an absolute editor path pointing atsleepso dozens of editor threads overlap, samples the process's caught-signal mask (SigCgtin/proc/self/status) the whole time, and asserts it never changes, then forces GC and asserts the process is still alive.USE_SYSTEM_BUN=1 bun test test/js/bun/util/open-in-editor-gc.test.ts(bun 1.4.0-canary f161e03) fails: the mask changes for signals[1,2,3,5,6,12,14,15,16,24,25,26,29,31].bun bd test test/js/bun/util/open-in-editor-gc.test.tspasses repeatedly with this change; the pre-existing SIGPWR test in the same file still passes.Bun.openInEditor(Bun)calls interleaved withBun.gc) run to completion with exit 0 on the debug build with this change.cargo check -p bun_runtime/cargo clippy -p bun_runtimeare clean for the touched code.