Skip to content

process.exit: don't crash when a native addon's global destructor throws#32569

Open
robobun wants to merge 4 commits into
mainfrom
farm/525b2e0a/exit-skip-dtor-throw
Open

process.exit: don't crash when a native addon's global destructor throws#32569
robobun wants to merge 4 commits into
mainfrom
farm/525b2e0a/exit-skip-dtor-throw

Conversation

@robobun

@robobun robobun commented Jun 21, 2026

Copy link
Copy Markdown
Collaborator

Crash signature

panic: A C++ exception occurred during process.exit() on macOS. 92/92 Sentry events are macOS, always with napi_module_register + process_dlopen in the feature tags. Sentry BUN-390H.

Process_functionReallyExit            BunProcess.cpp:3257
Bun__Process__exit                    node_process.zig:308
jsc.VirtualMachine.globalExit         VirtualMachine.zig:995
bun_core.Global.exit                  Global.zig:128
  libc exit -> __cxa_finalize -> addon global dtor -> __cxa_throw
JSCInitialize::$_0                    ZigGlobalObject.cpp:281  (std::set_terminate handler)
Zig__GlobalObject__onCrash            JSGlobalObject.zig:919

Cause

Global::exit on macOS called libc exit(), which runs atexit handlers and C++ global destructors. Linux uses quick_exit and Windows uses ExitProcess, both of which skip them; macOS was the only platform running third-party teardown after process.exit() had already committed to an exit code. A native addon whose global destructor throws (or calls into a torn-down runtime) reaches std::terminate, and the handler installed at JSCInitialize turned that into a crash report.

Fix

  • bun_core::Global::exit on macOS now runs Bun__onExit() and calls _exit(), matching the Windows shape. ASAN builds keep full exit() so LSan still runs, same as the existing Linux behavior.
  • The std::set_terminate handler in ZigGlobalObject.cpp now checks bun_is_exiting() and _Exit(bun_exit_code()) instead of crashing. This covers the remaining exit() users (ASAN builds, or an addon calling exit() directly).
  • New EXIT_CODE atomic alongside IS_EXITING, exported as bun_exit_code() for the handler.

Verification

Two tests:

  • test/napi/napi-app/throwing_dtor_addon.cpp is a native addon with a file-scope object whose destructor throws. napi.test.ts loads it and calls process.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.js uses a new bun:internal-for-testing helper that registers a std::terminate() atexit handler inside Bun's own C++ runtime and asserts process.exit(42) exits 42. Runs on ASAN builds (the only configuration that still uses full exit() after this change) so the Linux gate exercises the terminate-handler short-circuit.
$ bun bd test test/js/node/process/process.test.js -t "std::terminate"
(pass) process.exit honors the requested code when std::terminate fires from an atexit handler

#30291 addresses a different source of the same panic message (JS exceptions leaking out of napi finalizers during VirtualMachine.onExit, before Global::exit is reached); this PR addresses C++ exceptions from global destructors during libc exit() after Global::exit.

Related

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

robobun commented Jun 21, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:39 AM PT - Jun 21st, 2026

@robobun, your commit de7a6815c02c4c05d9ab4ae719c2f5d2d5e03296 passed in Build #63818! 🎉


🧪   To try this PR locally:

bunx bun-pr 32569

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

bun-32569 --bun

@coderabbitai

coderabbitai Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6c6ad64d-095d-49c1-9ce4-6df55d021308

📥 Commits

Reviewing files that changed from the base of the PR and between 8653f90 and f6e2590.

📒 Files selected for processing (1)
  • src/bun_core/Global.rs

Walkthrough

bun_core::Global gains an EXIT_CODE: AtomicI32 stored before flipping IS_EXITING with Release ordering, exposed via new exit_code()/bun_exit_code() APIs. The macOS exit path switches to _exit via a new libc__exit binding. The std::set_terminate handler in ZigGlobalObject.cpp now calls _Exit(bun_exit_code()) during in-progress exits instead of crashing. Test helpers and regression tests cover the ASAN and native-addon throwing-destructor cases.

Changes

Exit Code Preservation Through Destructors

Layer / File(s) Summary
Rust exit code atomics and macOS _exit path
src/bun_core/Global.rs
Adds EXIT_CODE: AtomicI32, makes is_exiting() public with Acquire ordering, exposes exit_code() and bun_exit_code() as C exports, adds libc__exit binding for macOS _exit, stores EXIT_CODE with Relaxed ordering before flipping IS_EXITING with Release ordering, and switches the macOS branch to call Bun__onExit() then libc__exit; ASAN also calls libc_exit first.
C++ terminate handler: preserve committed exit code
src/jsc/bindings/ZigGlobalObject.cpp
Adds extern "C" declarations for bun_is_exiting() and bun_exit_code(), then updates the std::set_terminate lambda to flush stdout/stderr and call _Exit(bun_exit_code()) when a committed exit is in progress, falling back to Zig__GlobalObject__onCrash() otherwise.
Test helper infrastructure
src/jsc/bindings/InternalForTesting.cpp, src/jsc/bindings/InternalForTesting.h, src/js/internal-for-testing.ts, test/napi/napi-app/throwing_dtor_addon.cpp, test/napi/napi-app/binding.gyp
Adds jsFunction_terminateAtExitForTesting (registers an atexit calling std::terminate) with its header declaration and JS export, plus a native N-API addon fixture throwing_dtor_addon whose file-scope destructor throws 1 during teardown, with a binding.gyp target that enables C++ exceptions.
Regression tests
test/js/node/process/process.test.js, test/napi/napi.test.ts
Adds an ASAN-only test verifying process.exit(42) honors code 42 when terminateAtExitForTesting is armed, and a macOS-only test verifying the same when the throwing_dtor_addon global destructor throws during process.exit(42).

Suggested reviewers

  • Jarred-Sumner
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the primary change: fixing process.exit crashes when native addon destructors throw.
Description check ✅ Passed The description comprehensively covers the crash signature, root cause, fix, verification tests, and related issues, meeting the template's requirements.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. bun test 1.3.13 crashes on shutdown (macOS, 'A C++ exception occurred') and during execution (Linux x86-64, 'Segmentation fault at address 0x1A' + SIGILL) in onnxruntime-node test suite; 1.2.23 is fine #30431 - Exact crash scenario: macOS shutdown panic "A C++ exception occurred" with onnxruntime-node native addon's global destructor throwing during exit()
  2. bun test panics after tests pass when using mock.module() with bun:sqlite and repeated SQLite create/close cycles on macOS #28557 - Same crash signature: macOS post-test panic "A C++ exception occurred" with napi_module_register process_dlopen in features, triggered by native addon destructors during shutdown

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #30431
Fixes #28557

🤖 Generated with Claude Code

Comment thread src/bun_core/Global.rs Outdated
…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.

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

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 Relaxed ordering on IS_EXITING/EXIT_CODE was 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.

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

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 an EXIT_CODE atomic with bun_exit_code() C export, switches is_exiting() to Acquire ordering, and — most significantly — changes the macOS branch of Global::exit() from libc exit() to Bun__onExit() + _exit(), so atexit handlers and C++ global destructors no longer run on macOS release builds.
  • src/jsc/bindings/ZigGlobalObject.cpp: the process-wide std::set_terminate handler now short-circuits to _Exit(bun_exit_code()) when bun_is_exiting() is true, instead of calling Zig__GlobalObject__onCrash().
  • Test plumbing: a new bun:internal-for-testing helper, 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 Relaxed ordering on IS_EXITING/EXIT_CODE was 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.

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