Skip to content

watch: park a thread that crashes while the watcher thread is in execve - #39868

Closed
robobun wants to merge 5 commits into
mainfrom
farm/e8487a8f/watch-reload-keep-crash-handler
Closed

robobun wants to merge 5 commits into
mainfrom
farm/e8487a8f/watch-reload-keep-crash-handler

Conversation

@robobun

@robobun robobun commented Aug 21, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Fix

  • on_before_reload_process_posix installs a handler for SIGABRT, SIGILL, SIGTRAP and SIGFPE, outside the reset loop. It parks every thread except the reloading one in pause(). On the reloading thread it restores SIG_DFL and raises again.
  • Correct because the crashing thread belongs to the image that the execve replaces. The execve does not need it and tears it down with the rest. SIGSEGV and SIGBUS stay with JSC's handlers, as before.
  • Verified: two new cases in test/cli/watch/watch.test.ts. In the first, the JS thread aborts on the failed pthread_create, two more threads abort and trap in the same window, and the reload has to go through all three. It fails on the canary and on an unfixed debug build and passes 24/24 with the fix. The second checks that an abort on the watcher thread itself still ends the process. Other suites: see the notes.

Background

  • --watch restarts by calling execve on its own binary from the watcher thread (reload_process, src/bun_core/util.rs). The other threads run until the execve kills them.
  • JSC creates its GC helper threads when a collection first needs them, on the thread that runs the collection. Here that is the JS thread.
  • The crash handler used to end a thread that crashed during a reload. ASAN builds have none, and SA_RESETHAND limits it to one thread. The new handler has neither limit.
Notes
  • Failing builds: 102050 (alpine 3.23 x64) and 102111 (alpine 3.23 aarch64), both with a core.
  • Other suites run with the fix: test/cli/watch/, test/cli/test/test-changed.test.ts, test/cli/hot/watch.test.ts, test/cli/run/run-crash-handler.test.ts, the node test-watch-mode-kill-signal-* tests. All pass.
  • reload_process: park aborting threads until execve completes #36825 (Aug 3) proposed a handler of this kind inside reload_process, before node compat batch: callback-throw dispatch, Assert class + native deep-equality parity, Intl gate + URL/buffer fallout, compile cache, watch kill-signal, profilers (+98 tests) #34660 landed. Installed there, the handler is removed again by the reset loop that node compat batch: callback-throw dispatch, Assert class + native deep-equality parity, Intl gate + URL/buffer fallout, compile cache, watch kill-signal, profilers (+98 tests) #34660 added to on_before_reload_process_posix, and the branch is based on the old reload_process. This PR installs the handler in the function that does the reset.
  • Kernel behavior, checked on 6.17 with a 40 line C program (one thread calls execve, another calls pthread_create in a loop): pthread_create returned EAGAIN in 26 of 30 runs, up to 7 times in a row, and the execve went through each time. Linux sets fs->in_exec in check_unsafe_exec and copy_fs rejects clone(CLONE_FS) while it is set.
  • The two alpine cores show a plain abort() under WTFCrashWithInfo with no crash handler frames. Other alpine cores from the same days (builds 101261, 101857, 101866) do show crash_handler frames. That is how the reset of the dispositions was identified as the reason the abort is fatal.
  • CI history (annotations of about 1400 builds): 0 hits in 300 builds from Aug 5 to 7 and 300 from Aug 12 to 13. 16 hits between Aug 19 03:25 UTC and Aug 21 (13 on ubuntu 25.04 aarch64, 1 on debian 13 aarch64, 2 on alpine). Don't request a GC before waiting on the entry point #39541 merged Aug 19 00:49 UTC. The glibc lanes pass on retry, so they count as flaky. The alpine lanes upload the core and fail hard.
  • The natural race did not reproduce on this x64 glibc machine: 0 of 60 runs of the test-changed case on the unfixed canary, 0 of 40 with BUN_JSC_numberOfGCMarkers=64. The shim test drives the same path: Bun.gc -> collectInMutatorThread -> ParallelHelperPool -> WTF::Thread::create -> EAGAIN -> abort() on the JS thread, between the reset and the execve. Unfixed release and debug builds both die with SIGABRT there, and the failing pthread_create runs on the thread whose tid is the pid.
  • A handler for SIGABRT alone, or one with SA_RESETHAND, fails the test: the shim's extra threads raise SIGABRT and SIGILL (SIGTRAP on aarch64) after the JS thread has already been parked.
  • The second case covers the other branch of the handler. By hand, __builtin_trap() on the watcher thread ends the process with SIGILL the same way.
  • The shim does not wait for a fixed time before it execs. bun parks a crashing thread in pause(), the shim interposes that too, and it execs once the three crashing threads have called it (about 20 ms after the execve() call on the debug build). Without the fix the process is dead within a few ms of the failed pthread_create. The first version of the test slept 1 s here instead, which the self-review flagged.
  • ASAN builds had no protection here in any version, so the race was always fatal there. The new handler is what lets the test run on the ASAN lanes.
  • maybe_handle_panic_during_process_reload is unchanged. It still covers Rust panics during a reload, which do not go through a signal.

no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/cli/test/test-changed.test.ts test/cli/watch/watch.test.ts

While one thread is inside execve(2), the kernel fails every clone(2)
made by the other threads of the process with EAGAIN. A collection that
starts JSC's marker threads in that window aborts in WTF::Thread::create.
on_before_reload_process_posix reset SIGABRT to SIG_DFL before the
execve, so that abort killed the process instead of the reload going
through.

Install a handler for SIGABRT, SIGILL, SIGTRAP and SIGFPE for the rest
of the reload. It parks every thread other than the reloading one, and
execve tears the parked threads down with the old image. A crash on the
reloading thread itself still terminates the process.

The test makes the race deterministic with an LD_PRELOAD shim that fails
pthread_create from the moment bun calls execve() and lets the script run
its first collection at that point.
@coderabbitai

coderabbitai Bot commented Aug 21, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Your included review limit has been reached.

You’re in a promotional period — use the checkbox below to run this review for free:

  • Run review for free

On-demand reviews are free for the next 30 days. After that, they cost $0.25 per reviewed file.

How can I continue?

Run this review now using the option above, or comment @coderabbitai review --use-credits.

You can also wait for the limit to reset (next review available in 1 minute), then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1fac9067-c926-41f4-bf01-13d79c811979

📥 Commits

Reviewing files that changed from the base of the PR and between 4072840 and 3320916.

📒 Files selected for processing (2)
  • src/jsc/bindings/c-bindings.cpp
  • test/cli/watch/watch.test.ts

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

@robobun

robobun commented Aug 21, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:13 AM PT - Aug 21st, 2026

✅ @robobun, your commit 33209164701bc21fb6ea430c9f97c2f30c1d6a7d passed in Build #102589! 🎉


🧪   To try this PR locally:

bunx bun-pr 39868

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

bun-39868 --bun

@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.

I reviewed this PR and didn't find any bugs. Because it installs a new process-wide signal handler on the reload path (threading + signals + execve across all POSIX targets), a human look would still be worthwhile.

What was reviewed:

  • Handler body is async-signal-safe (pthread_self/pthread_equal/sigaction/raise/pause are all on the POSIX AS-safe list); reloading_thread is written before sigaction installs the handler, and signal delivery goes through the kernel, so other threads observe the write.
  • The reset loop correctly skips the four parked signals via std::find, so the new handler isn't immediately clobbered; SIGSEGV/SIGBUS are left to JSC as before.
  • On execve failure the handler stays installed, but that matches the existing RELOAD_IN_PROGRESS never being cleared — a crashing non-reload thread already exits via maybe_handle_panic_during_process_reload, so this is not a regression.
  • Test: shim state resets in the post-exec image (in_execve is a fresh static), markers live outside the watched dir, stderr is drained so a full pipe can't block the aborting thread, and the final failed-create assert proves the failure path was exercised rather than skipped.
Extended reasoning...

Overview

The PR fixes a CI flake where bun --watch dies with SIGABRT during a reload on alpine/aarch64. Root cause: while the watcher thread is inside execve, the kernel fails clone() from other threads with EAGAIN (fs->in_exec in copy_fs), and JSC's WTF::Thread::create RELEASE_ASSERTs on that when the JS thread starts GC marker threads. #34660 made this fatal by resetting SIGABRT to SIG_DFL before execve. The fix installs a handler for SIGABRT/SIGILL/SIGTRAP/SIGFPE in on_before_reload_process_posix that parks non-reloading threads in pause() (execve tears them down anyway) and re-raises with SIG_DFL on the reloading thread. Two files: ~40 lines in c-bindings.cpp, ~130 lines of test in watch.test.ts.

Security risks

None. This is internal signal-handling glue on the --watch reload path. No user input flows into it, no auth/crypto/network surface is touched. The handler only runs in the narrow window between on_before_reload_process_posix() and execve().

Level of scrutiny

High. Signal handlers + multi-threading + execve is one of the most subtle areas in POSIX programming, and this code runs on every --watch reload across Linux, macOS, and FreeBSD. That said, the diff is small, the reasoning in the PR description is unusually thorough (kernel behavior verified with a standalone C repro, CI history correlated to the triggering merges, both handler branches manually exercised), and every function called from the handler is on the POSIX async-signal-safe list. The placement is correct: installing the handler inside on_before_reload_process_posix and excluding those signals from its reset loop is the only spot that survives #34660's reset.

Other factors

  • The test follows the existing LD_PRELOAD-shim pattern already in this file, is Linux-gated with skipIf(!isLinux || !cc), keeps marker files outside the watched directory to avoid spurious file events, drains stderr to prevent pipe-full deadlock, and asserts the failed-create marker exists so the test can't pass by routing around the failure. The 30s timeout comfortably covers the shim's ~11s worst-case wait.
  • I checked the execve-failure path: reloading_thread and the park handler stay live if execve returns, but so does the pre-existing RELOAD_IN_PROGRESS flag — the process was already in a "park anything that crashes" mode via maybe_handle_panic_during_process_reload, so this is consistent, not a new hazard.
  • Cross-thread visibility of reloading_thread: written before the sigaction calls that install the handler; both the syscall and kernel signal delivery act as full barriers, so a thread entering the handler observes the write.
  • Deferring rather than approving because this is a design decision about process-level signal disposition that a maintainer should sign off on — particularly the set of signals chosen (parking SIGABRT/SIGILL/SIGTRAP/SIGFPE while leaving SIGSEGV/SIGBUS to JSC's chained handlers).

@robobun

robobun commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

On the choice of signals, for whoever takes the human look: the four parked signals are the ones the crash handler installs (update_posix_segfault_handler in src/crash_handler/lib.rs) minus SIGSEGV and SIGBUS. Those two already stayed out of the reset before this PR because JSC's own handlers sit on them, and a non-JSC fault chains from there to the crash handler, which steps aside during a reload. So after this PR every crash signal behaves the same way in the window: the thread gets out of the way and the execve goes through. The only difference is that the four parked ones now also do so in ASAN builds and for more than one thread.

The shim now also starts one thread that calls abort() and one that
traps (SIGILL on x64, SIGTRAP on aarch64) before it execs for real, and
the test checks that the reload went through all three crashes. A
handler that parks one thread only (SA_RESETHAND), or one that parks
SIGABRT only, fails the test. The previous version passed with both.
Comment thread src/jsc/bindings/c-bindings.cpp Outdated
Comment thread src/jsc/bindings/c-bindings.cpp Outdated
Comment thread src/jsc/bindings/c-bindings.cpp Outdated
Comment thread src/jsc/bindings/c-bindings.cpp Outdated
Comment thread src/jsc/bindings/c-bindings.cpp

@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.

I reviewed this PR and didn't find any bugs. Because it changes process-wide signal dispositions on the --watch reload path — async-signal-safe handler code, a new parked-thread state, and interaction with the existing SIG_DFL reset loop — a human look is still worthwhile.

Checked: the handler uses only async-signal-safe calls (pthread_equal/pthread_self/sigaction/raise/pause); reloading_thread is written before the handler is installed; the raise(sig) on the reloading thread is correctly deferred by the in-handler mask; and the reset loop's new std::find skip keeps the park handler in place. The LD_PRELOAD shim test drives all three crash paths and asserts the marker files, so it can't pass by skipping the race.

Extended reasoning...

Overview

The PR fixes a race in --watch reloads: while the watcher thread is inside execve, the kernel fails clone() on other threads with EAGAIN, and WTF::Thread::create aborts on that. Since #34660 resets SIGABRT to SIG_DFL before execve, that abort is now fatal instead of being absorbed by the crash handler. The fix installs a small handler for SIGABRT/SIGILL/SIGTRAP/SIGFPE in on_before_reload_process_posix (src/jsc/bindings/c-bindings.cpp) that parks non-reloading threads in pause() and re-raises with SIG_DFL on the reloading thread. The reset loop is updated to skip these four signals. A new Linux-only LD_PRELOAD shim test in test/cli/watch/watch.test.ts deterministically reproduces the race with three concurrent crashing threads.

Security risks

None identified. The change is confined to the pre-execve window on a process that is about to replace its own image; no new external input is parsed and no privilege boundary is crossed.

Level of scrutiny

High. This is native signal-handling code on a process-lifecycle path that already has a history of subtle interactions (#34660, #36825, #39541). The correctness argument depends on kernel in_exec behavior, POSIX signal-mask semantics inside a handler, JSC's lazy marker-thread creation, and the existing crash handler's chaining for SIGSEGV/SIGBUS. Getting it wrong could turn a crash into a hang (parked reloading thread) or silently swallow a real crash on the watcher thread. The PR description is unusually thorough — kernel repro, CI history, manual verification of both handler branches — which raises confidence, but a maintainer familiar with reload_process and the crash-handler chain should confirm the design choice (park vs. some other approach) and the signal set.

Other factors

  • The comment-cop bot fired several times on the handler's comment; the author iterated and the current comment is short and states the invariant rather than justifying a workaround. Whether the last fire (line 307) is now satisfied is a mechanical check that will re-run on the current head.
  • The test is well-constructed: markers live outside the watched directory, stderr is drained to avoid a pipe-full deadlock, RLIMIT_CORE is zeroed so an unfixed build's abort doesn't trip CI's crash scan, and the final readdirSync assertion proves the reload went through all three crashes rather than around them. It follows the existing LD_PRELOAD shim pattern already in this file.
  • No prior review from me on this PR.

@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.

I reviewed this PR and the bug-hunting pass found no issues. Because it changes process-wide signal dispositions on the --watch reload path — parking crashing threads across the execve window — a human sign-off on the design is still worthwhile.

What was reviewed:

  • Handler is async-signal-safe (pthread_equal/pthread_self/sigaction/sigemptyset/raise/pause are all on the POSIX list).
  • parked_crash_signals matches update_posix_segfault_handler's set minus SIGSEGV/SIGBUS, and the reset loop correctly skips them.
  • Re-raise on the reloading thread: the signal is masked during its own handler (no SA_NODEFER), so raise queues and delivers with SIG_DFL on return — no hang.
  • Test follows the existing LD_PRELOAD-shim pattern in this file; markers dir is outside the watched dir; stderr is drained; afterEach SIGKILLs the watchee.
Extended reasoning...

Overview

The PR modifies on_before_reload_process_posix in src/jsc/bindings/c-bindings.cpp to install a signal handler for SIGABRT/SIGILL/SIGTRAP/SIGFPE just before the --watch reload's execve. On any thread other than the one performing the reload, the handler parks in pause() so the thread stops interfering and is torn down by the execve; on the reloading thread itself it restores SIG_DFL and re-raises so a crash there is still fatal. The reset loop that follows now skips these four signals so it does not overwrite the handler with SIG_DFL. A new Linux-only LD_PRELOAD-shim test in test/cli/watch/watch.test.ts deterministically reproduces the race (failed pthread_create → WTF::Thread::create abort on the JS thread, plus two extra crashing threads) and asserts the reload still goes through.

Security risks

None identified. The handler is only installed immediately before execve on the watcher thread's reload path, is process-local, and does not touch any external input. The parked signals are the crash-handler set (verified against src/crash_handler/lib.rs:1630-1638) minus SIGSEGV/SIGBUS, which the existing code already leaves for JSC.

Level of scrutiny

High. This is process-wide POSIX signal disposition on a path that affects every --watch user, and the correctness argument depends on kernel behavior (Linux failing clone(CLONE_FS) with EAGAIN while another thread is in execve), async-signal-safety, and the interaction with JSC's own SIGSEGV/SIGBUS handlers and the crash handler's SA_RESETHAND. The reasoning in the PR description is thorough and the mechanism is well-argued, but this is not a simple or mechanical change — a maintainer should confirm the design choice (park-in-pause() vs. alternatives) and the signal set.

Other factors

The comment-cop bot flagged comment length; the author shortened to two lines and all threads are resolved. The test mirrors an existing LD_PRELOAD-shim test in the same file, uses tempDir, keeps markers outside the watched dir, drains stderr, and relies on the file-level afterEach SIGKILL for cleanup. I verified the handler uses only async-signal-safe calls, that the re-raise-on-reloading-thread branch cannot hang (signal masked during its own handler, delivered on return), and that the four parked signals exactly match the crash handler's set minus SIGSEGV/SIGBUS. No prior claude[bot] reviews on this PR.

…reloading thread

The shim interposes pause(), which is where bun parks a crashing thread,
and execs as soon as the three crashing threads have called it instead of
sleeping for a second. A second case checks that an abort on the watcher
thread itself, after the handler is installed, still ends the process.
The shim compile is shared by the three shim tests.
@robobun

robobun commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

Self-review came back with two points on the test, both about the fixed 1 s sleep in the shim. 3320916 replaces it: the shim interposes pause(), which is where bun parks a crashing thread, and execs as soon as the three crashing threads have called it. That takes about 1 s off the test. The same commit adds a case for the other branch of the handler (an abort on the watcher thread itself still ends the process) and shares the shim compile between the three shim tests. No change to the fix itself since 965b296.

@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.

I reviewed this PR and the automated bug hunt found no issues. The analysis is thorough and the fix is small, but it changes process-wide signal dispositions in the reload path — a human look at the signal-handling semantics would still be worthwhile.

What was reviewed:

  • The park handler uses only async-signal-safe calls (pthread_self, pthread_equal, sigaction, raise, pause) and the reset loop correctly skips the four parked signals.
  • The reloading-thread branch re-raises with SIG_DFL so a crash on the watcher thread itself stays fatal — covered by the second new test.
  • The shim test awaits observable conditions (interposed pause() + marker files) rather than sleeping, drains stderr, and asserts all four markers so the reload can't pass by going around the crashes.
Extended reasoning...

Overview

The PR modifies on_before_reload_process_posix in src/jsc/bindings/c-bindings.cpp to install a signal handler for SIGABRT/SIGILL/SIGTRAP/SIGFPE before the existing disposition-reset loop. The handler parks any non-reloading thread in pause() and re-raises with SIG_DFL on the reloading thread. The reset loop is updated to skip these four signals. Two new Linux-only LD_PRELOAD shim tests are added to test/cli/watch/watch.test.ts, and the existing shim-compile logic is factored into a shared compileShim helper.

Security risks

None identified. The change is scoped to the --watch reload window and only affects how crash signals are handled between the disposition reset and execve. It does not touch auth, crypto, network input, or user-controlled data.

Level of scrutiny

High. This is low-level POSIX signal handling on a production-critical path (process reload). The handler runs in async-signal context on arbitrary threads, interacts with JSC's own SIGSEGV/SIGBUS handlers, and changes the behavior of every crash signal in the reload window across all POSIX platforms (Linux, macOS, FreeBSD). While the fix itself is ~25 lines and the reasoning in the PR description is exceptionally detailed (kernel source references, CI history, empirical verification with a C reproducer), the correctness depends on subtle invariants: that execve will always follow shortly after this function, that parking a thread mid-abort cannot deadlock the reloading thread (e.g. via a lock held by the parked thread that execve setup needs), and that the four-signal set matches the crash handler's set going forward.

Other factors

  • The author's own timeline comment explicitly flags this "for whoever takes the human look", indicating a human review is expected.
  • The tests are elaborate and well-constructed (deterministic via LD_PRELOAD, await real conditions, cover both handler branches, verified to fail on unfixed builds), but they only run on Linux with a C compiler present — the macOS/FreeBSD paths are covered by the same code but not exercised by the new tests.
  • The comment-cop bot flagged the code comments repeatedly; the author shortened them and the remaining comments are now two lines each, which seems reasonable given the non-obvious kernel behavior being documented.
  • No prior human review on the PR.

@robobun

robobun commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

On the one open question in the latest review, whether a parked thread can hold a lock that the reloading thread still needs: after on_before_reload_process_posix the reloading thread only duplicates argv and environ (malloc), reads the cached exe path and calls execve (src/bun_core/util.rs, reload_process). The thread that crashes in WTF::Thread::create holds no allocator lock at that point (pthread_create has returned), and mimalloc allocates from per-thread heaps, so the duplication does not take a lock the parked thread can own. The lock abort() holds on newer glibc only blocks other callers of abort() and sigaction(SIGABRT), and the reloading thread calls neither afterwards. The exposure is the same one the crash handler's pthread_exit had before #34660. The kernel then kills the parked threads inside execve, so nothing they hold matters after that point.

@robobun

robobun commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator Author

#40978 takes a different route to the same crash: on Linux it links with --wrap=execve and --wrap=pthread_create, and the pthread_create wrapper retries an EAGAIN that overlaps an exec of the process. The failed spawn never happens, so nothing aborts, and process.execve and the non-crashing failures (a Worker that fails to start) are covered too. Jarred asked for that approach in the thread where this came up. If #40978 lands, this PR can close, or be re-scoped to other crashes in the exec window.

@github-actions github-actions Bot closed this Aug 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Superseded by #40978.

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