process.exit: don't crash when a native addon's global destructor throws#32569
process.exit: don't crash when a native addon's global destructor throws#32569robobun wants to merge 4 commits into
Conversation
On macOS, Global::exit called libc exit(), which runs atexit handlers and C++ global destructors. When a native addon's global destructor throws (after the VM is already torn down), std::terminate fires and Bun's terminate handler reports a crash instead of honoring the exit code the user requested. Linux and Windows already skip global destructors via quick_exit / ExitProcess. Two changes: Global::exit on macOS now runs Bun__onExit() and calls _exit(), matching the other platforms. ASAN builds keep using full exit() so leak detection still runs. The std::set_terminate handler checks bun_is_exiting() and, if set, _Exit()s with the stored code instead of crashing. This covers the remaining paths that still use full exit() (ASAN builds, or an addon that calls exit() directly). The terminate-handler path is exercised on Linux/ASAN via a bun:internal-for-testing helper that registers a std::terminate() atexit handler inside Bun's own C++ runtime. A real native addon can't be used for this on Linux: Bun statically links its C++ runtime while addons link the system libstdc++, so an addon's throw reaches libstdc++'s terminate handler rather than Bun's. On macOS both share the system libc++, so the new napi throwing_dtor_addon test exercises the real path there. Sentry BUN-390H
|
Updated 11:39 AM PT - Jun 21st, 2026
✅ @robobun, your commit de7a6815c02c4c05d9ab4ae719c2f5d2d5e03296 passed in 🧪 To try this PR locally: bunx bun-pr 32569That installs a local version of the PR into your bun-32569 --bun |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (1)
Walkthrough
ChangesExit Code Preservation Through Destructors
Suggested reviewers
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
|
Found 2 issues this PR may fix:
🤖 Generated with Claude Code |
…CODE The std::set_terminate handler is process-wide and can fire on any thread; Relaxed ordering on two distinct atomics provides no inter-thread happens-before, so a worker thread observing IS_EXITING == true could still read EXIT_CODE == 0.
There was a problem hiding this comment.
Thanks for addressing the memory-ordering nit. I didn't find any further issues, but switching macOS from exit() to _exit() is a deliberate semantic change to process teardown (native-addon atexit/global-dtor no longer run), so I'd like a human to confirm that trade-off before merge.
Extended reasoning...
Overview
This PR fixes a macOS-specific crash (panic: A C++ exception occurred, Sentry BUN-390H) where a native addon's global destructor throws during process.exit(). It does two things: (1) changes bun_core::Global::exit on macOS to call Bun__onExit() + _exit() instead of libc exit() (release builds only; ASAN keeps full exit() for LSan), and (2) makes the process-wide std::set_terminate handler in ZigGlobalObject.cpp call _Exit(bun_exit_code()) when bun_is_exiting() is true rather than reporting a crash. Supporting changes add an EXIT_CODE atomic with Release/Acquire ordering on IS_EXITING, a bun:internal-for-testing helper that registers a std::terminate() atexit hook, a throwing-destructor N-API fixture, and platform-gated regression tests.
Security risks
None identified. No untrusted input is parsed; the change only affects how the process terminates after a code has already been committed. The new C exports (bun_exit_code) and the _exit binding are straightforward.
Level of scrutiny
High. This rewrites the macOS process-termination path. Moving from exit() to _exit() means atexit handlers and C++ global destructors registered by native addons (or anything else) no longer run on macOS in release builds. That aligns macOS with Linux (quick_exit) and Windows (ExitProcess), and the rationale is clearly documented — but it is a behavioral change to a core runtime path, and the platform-specific reasoning about shared libc++ vs statically-linked libstdc++ deserves a maintainer's eye. The terminate-handler change is also process-wide and will now silently swallow any C++ exception that reaches std::terminate once exit has begun (intentional, but worth confirming).
Other factors
- My earlier inline comment about
Relaxedordering onIS_EXITING/EXIT_CODEwas addressed in f6e2590 with a proper Release/Acquire pair; the comment thread is resolved and the current code is correct. - The bug-hunting pass found no issues on the latest revision.
- Test coverage is thoughtful: a macOS-only real-addon test plus an ASAN-only internal test that exercises the terminate-handler short-circuit on Linux. Both tests are gated to the configurations where they're meaningful.
- The diff is small and focused, with good in-code comments explaining each platform branch.
Given the scope (process exit semantics on a primary platform), I'm deferring rather than auto-approving.
There was a problem hiding this comment.
Thanks for the Release/Acquire fix — that resolves my earlier concern. No further issues found, but since this changes macOS process-termination semantics (skipping atexit/global destructors that previously ran) and rewires the process-wide std::set_terminate handler, I'd like a human to sign off.
Extended reasoning...
Overview
This PR touches the core process-exit path across three layers:
src/bun_core/Global.rs: adds anEXIT_CODEatomic withbun_exit_code()C export, switchesis_exiting()to Acquire ordering, and — most significantly — changes the macOS branch ofGlobal::exit()from libcexit()toBun__onExit() + _exit(), so atexit handlers and C++ global destructors no longer run on macOS release builds.src/jsc/bindings/ZigGlobalObject.cpp: the process-widestd::set_terminatehandler now short-circuits to_Exit(bun_exit_code())whenbun_is_exiting()is true, instead of callingZig__GlobalObject__onCrash().- Test plumbing: a new
bun:internal-for-testinghelper, a throwing-destructor N-API addon fixture, and two regression tests (ASAN-only and macOS-only).
Security risks
None identified. No auth, crypto, permissions, or untrusted-input parsing is involved; the change only affects how the process tears itself down after process.exit() has already committed to a code.
Level of scrutiny
High. Global::exit() is on every process-termination path, and the macOS change is a deliberate behavioral shift: third-party native addons' atexit handlers and global destructors that previously ran on macOS will now be skipped. The PR argues this aligns macOS with Linux (quick_exit) and Windows (ExitProcess), which is sound reasoning, but it's a platform-semantics change that a maintainer should consciously accept rather than have rubber-stamped. The terminate-handler rewrite also changes when crash reports are suppressed.
Other factors
- My earlier inline comment about
Relaxedordering onIS_EXITING/EXIT_CODEwas addressed in f6e2590 (now Release/Acquire), and the comment thread is resolved. - The bug-hunting pass found no issues on the current revision.
- CI shows 4 failures (grpc-js SIGTRAP, tls-client-destroy-soon, two next-pages tests) that look like known flakes unrelated to this diff, but a human should confirm.
- Tests are well-targeted but narrowly gated (ASAN-only / macOS-only), which is correct given the fix's scope but means most CI lanes don't exercise the new code paths.
Crash signature
panic: A C++ exception occurredduringprocess.exit()on macOS. 92/92 Sentry events are macOS, always withnapi_module_register+process_dlopenin the feature tags. Sentry BUN-390H.Cause
Global::exiton macOS called libcexit(), which runs atexit handlers and C++ global destructors. Linux usesquick_exitand Windows usesExitProcess, both of which skip them; macOS was the only platform running third-party teardown afterprocess.exit()had already committed to an exit code. A native addon whose global destructor throws (or calls into a torn-down runtime) reachesstd::terminate, and the handler installed atJSCInitializeturned that into a crash report.Fix
bun_core::Global::exiton macOS now runsBun__onExit()and calls_exit(), matching the Windows shape. ASAN builds keep fullexit()so LSan still runs, same as the existing Linux behavior.std::set_terminatehandler inZigGlobalObject.cppnow checksbun_is_exiting()and_Exit(bun_exit_code())instead of crashing. This covers the remainingexit()users (ASAN builds, or an addon callingexit()directly).EXIT_CODEatomic alongsideIS_EXITING, exported asbun_exit_code()for the handler.Verification
Two tests:
test/napi/napi-app/throwing_dtor_addon.cppis a native addon with a file-scope object whose destructor throws.napi.test.tsloads it and callsprocess.exit(42), asserting exit code 42. macOS-only: on macOS, Bun and addons share the system libc++ so the addon's throw reaches Bun's terminate handler. On Linux, Bun statically links its C++ runtime while the addon links system libstdc++, so the addon's throw reaches libstdc++'s handler instead and the assertion cannot hold.test/js/node/process/process.test.jsuses a newbun:internal-for-testinghelper that registers astd::terminate()atexit handler inside Bun's own C++ runtime and assertsprocess.exit(42)exits 42. Runs on ASAN builds (the only configuration that still uses fullexit()after this change) so the Linux gate exercises the terminate-handler short-circuit.#30291 addresses a different source of the same panic message (JS exceptions leaking out of napi finalizers during
VirtualMachine.onExit, beforeGlobal::exitis reached); this PR addresses C++ exceptions from global destructors during libcexit()afterGlobal::exit.Related
std::terminateafterbun testfinishes). The Linux segfault in the same issue is a separate crash during execution, not addressed here.VirtualMachine.onExit; if the throw instead originates from a global destructor afterGlobal::exit, this PR covers it. The two fixes are complementary.