Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 39 additions & 5 deletions src/bun_core/Global.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#![allow(non_upper_case_globals, non_snake_case)]

use core::ffi::{c_char, c_int};
use core::sync::atomic::{AtomicBool, Ordering};
use core::sync::atomic::{AtomicBool, AtomicI32, Ordering};

use const_format::{concatcp, formatcp};

Expand Down Expand Up @@ -610,14 +610,30 @@ pub(crate) fn run_exit_callbacks() {
}

static IS_EXITING: AtomicBool = AtomicBool::new(false);
static EXIT_CODE: AtomicI32 = AtomicI32::new(0);

#[unsafe(no_mangle)]
pub(crate) extern "C" fn bun_is_exiting() -> c_int {
is_exiting() as c_int
}

pub(crate) fn is_exiting() -> bool {
IS_EXITING.load(Ordering::Relaxed)
pub fn is_exiting() -> bool {
// Acquire pairs with the Release store in `exit()` so a thread that
// observes `true` here also observes the `EXIT_CODE` written just
// before it. The std::set_terminate handler is process-wide and can
// fire on any thread.
IS_EXITING.load(Ordering::Acquire)
}

/// The code passed to [`exit`]. Only meaningful after [`is_exiting`] has
/// returned true (which provides the Acquire edge that makes this visible).
pub fn exit_code() -> c_int {
EXIT_CODE.load(Ordering::Relaxed)
}

#[unsafe(no_mangle)]
pub(crate) extern "C" fn bun_exit_code() -> c_int {
exit_code()
}

// libc process-termination entry points used by `exit` /
Expand All @@ -633,13 +649,21 @@ unsafe extern "C" {
#[cfg(unix)]
#[link_name = "exit"]
safe fn libc_exit(code: c_int) -> !;
#[cfg(target_os = "macos")]
#[link_name = "_exit"]
safe fn libc__exit(code: c_int) -> !;
#[cfg(all(unix, not(target_os = "macos")))]
safe fn quick_exit(code: c_int) -> !;
}

/// Flushes stdout and stderr (in exit/quick_exit callback) and exits with the given code.
pub fn exit(code: u32) -> ! {
IS_EXITING.store(true, Ordering::Relaxed);
// Publish the code before flipping IS_EXITING. The Release here pairs
// with the Acquire load in `is_exiting()`, so a cross-thread observer
// (the process-wide std::set_terminate handler may run on any thread)
// that sees IS_EXITING == true also sees EXIT_CODE.
EXIT_CODE.store(code as i32, Ordering::Relaxed);
IS_EXITING.store(true, Ordering::Release);
// MOVE_DOWN: bun_analytics::features → bun_core (move-in pass).
crate::features::EXITED.fetch_add(1, Ordering::Relaxed);

Expand All @@ -657,7 +681,17 @@ pub fn exit(code: u32) -> ! {

#[cfg(target_os = "macos")]
{
libc_exit(code as i32)
// ASAN's leak detector runs from an atexit handler, so use full
// exit() there. Otherwise match Linux (quick_exit) / Windows
// (ExitProcess) and skip global destructors: a native addon's
// throwing static destructor would otherwise reach std::terminate
// and turn a clean process.exit() into a crash report. macOS libc
// has no quick_exit, so run our own at-exit callbacks and _exit.
if env::ENABLE_ASAN {
libc_exit(code as i32);
}
Bun__onExit();
libc__exit(code as i32);
}
#[cfg(windows)]
{
Expand Down
6 changes: 6 additions & 0 deletions src/js/internal-for-testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,12 @@ export const lsanDoLeakCheck = $newCppFunction("InternalForTesting.cpp", "jsFunc

export const isASANEnabled: () => boolean = $newCppFunction("InternalForTesting.cpp", "jsFunction_isASANEnabled", 0);

export const terminateAtExitForTesting: () => void = $newCppFunction(
"InternalForTesting.cpp",
"jsFunction_terminateAtExitForTesting",
0,
);

export const BunString_toThreadSafeRefCountDelta: () => number = $newCppFunction(
"InternalForTesting.cpp",
"jsFunction_BunString_toThreadSafeRefCountDelta",
Expand Down
16 changes: 16 additions & 0 deletions src/jsc/bindings/InternalForTesting.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
#include "webcore/HTTPHeaderMap.h"
#include <wtf/text/StringImpl.h>
#include <wtf/text/WTFString.h>
#include <cstdlib>
#include <exception>

#if ASAN_ENABLED
#include <sanitizer/lsan_interface.h>
Expand Down Expand Up @@ -101,4 +103,18 @@ JSC_DEFINE_HOST_FUNCTION(jsFunction_BunString_toThreadSafeRefCountDelta, (JSC::J
return JSValue::encode(jsNumber(static_cast<int32_t>(after) - static_cast<int32_t>(before)));
}

// Registers an atexit handler that calls std::terminate(), simulating a native
// addon whose global destructor throws during libc exit(). This binary is
// built with -fno-exceptions, so we call std::terminate() directly rather than
// throwing; the observable behavior (the std::set_terminate handler runs while
// bun_is_exiting() is true) is the same. Lets the Linux/ASAN lane exercise the
// terminate handler's is-exiting short-circuit, which a real addon cannot: on
// Linux an addon links the system libstdc++ and so reaches that runtime's
// terminate handler, not the one Bun installed in its statically-linked copy.
JSC_DEFINE_HOST_FUNCTION(jsFunction_terminateAtExitForTesting, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame))
{
std::atexit([] { std::terminate(); });
return encodedJSUndefined();
}

}
1 change: 1 addition & 0 deletions src/jsc/bindings/InternalForTesting.h
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ JSC_DECLARE_HOST_FUNCTION(jsFunction_lsanDoLeakCheck);
JSC_DECLARE_HOST_FUNCTION(jsFunction_isASANEnabled);
JSC_DECLARE_HOST_FUNCTION(jsFunction_BunString_toThreadSafeRefCountDelta);
JSC_DECLARE_HOST_FUNCTION(jsFunction_lowercaseHeaderNameSIMD);
JSC_DECLARE_HOST_FUNCTION(jsFunction_terminateAtExitForTesting);

}
16 changes: 15 additions & 1 deletion src/jsc/bindings/ZigGlobalObject.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -271,14 +271,28 @@ extern "C" unsigned getJSCBytecodeCacheVersion()
extern "C" void Bun__REPRL__registerFuzzilliFunctions(Zig::GlobalObject*);
#endif

extern "C" int bun_is_exiting();
extern "C" int bun_exit_code();

extern "C" void JSCInitialize(const char* envp[], size_t envc, void (*onCrash)(const char* ptr, size_t length), bool evalMode, bool oneShotStartup)
{
static std::once_flag jsc_init_flag;
// NOLINTBEGIN
std::call_once(jsc_init_flag, [evalMode, oneShotStartup, envp, envc, onCrash]() {
JSC::Config::enableRestrictedOptions();

std::set_terminate([]() { Zig__GlobalObject__onCrash(); });
std::set_terminate([]() {
// A global destructor or atexit handler (typically from a native
// addon) can throw while libc exit() is tearing the process down.
// By that point process.exit() has already committed to a code;
// honor it instead of reporting a crash.
if (bun_is_exiting()) {
fflush(stdout);
fflush(stderr);
::_Exit(bun_exit_code());
}
Zig__GlobalObject__onCrash();
});
WTF::initializeMainThread();

// Use JSC::initialize with a callback to set Options during initialization.
Expand Down
30 changes: 29 additions & 1 deletion test/js/node/process/process.test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { spawnSync, which } from "bun";
import { describe, expect, it } from "bun:test";
import { familySync } from "detect-libc";
import { bunEnv, bunExe, isMacOS, isWindows, tempDir, tmpdirSync } from "harness";
import { bunEnv, bunExe, isASAN, isMacOS, isWindows, tempDir, tmpdirSync } from "harness";
import { basename, join, resolve } from "path";

const process_sleep = resolve(import.meta.dir, "process-sleep.js");
Expand Down Expand Up @@ -397,6 +397,34 @@ describe("process.exitCode", () => {
});
});

// Sentry BUN-390H: a native addon's global destructor threw during libc exit()
// after process.exit(), and the std::set_terminate handler turned that into a
// crash report instead of honoring the requested exit code. On POSIX the atexit
// callback registered here only runs when the process uses full libc exit(),
// which after the fix is only the ASAN configuration; release builds skip
// global destructors via _exit / quick_exit / ExitProcess, making this
// assertion vacuous there.
it.skipIf(!isASAN)(
"process.exit honors the requested code when std::terminate fires from an atexit handler",
async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`require("bun:internal-for-testing").terminateAtExitForTesting();` +
`console.log("armed");` +
`process.exit(42);`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect({ stdout, stderr, exitCode }).toEqual({ stdout: "armed\n", stderr: "", exitCode: 42 });
expect(proc.signalCode).toBeNull();
},
);

it("process exitCode range (#6284)", () => {
const { exitCode, stdout } = spawnSync({
cmd: [bunExe(), join(import.meta.dir, "process-exitCode-fixture.js"), "255"],
Expand Down
11 changes: 11 additions & 0 deletions test/napi/napi-app/binding.gyp
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,17 @@
"NAPI_DISABLE_CPP_EXCEPTIONS",
],
},
{
"target_name": "throwing_dtor_addon",
"sources": ["throwing_dtor_addon.cpp"],
"cflags!": ["-fno-exceptions"],
"cflags_cc!": ["-fno-exceptions"],
"xcode_settings": { "GCC_ENABLE_CPP_EXCEPTIONS": "YES" },
"msvs_settings": { "VCCLCompilerTool": { "ExceptionHandling": "1" } },
"include_dirs": ["<!@(node -p \"require('node-addon-api').include\")"],
"libraries": [],
"dependencies": ["<!(node -p \"require('node-addon-api').gyp\")"],
},
{
"target_name": "reentrant_register_addon",
"sources": ["reentrant_register_addon.cpp"],
Expand Down
17 changes: 17 additions & 0 deletions test/napi/napi-app/throwing_dtor_addon.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// A native addon whose file-scope object throws from its destructor. The
// destructor runs from __cxa_finalize when libc exit() is used (macOS, and
// ASAN builds on other POSIX platforms). It models a real-world addon whose
// teardown fails after the VM has been torn down. Bun should honor the
// process.exit() code rather than reporting a crash from std::terminate.
#include <node_api.h>

struct ThrowsOnDestruct {
~ThrowsOnDestruct() noexcept(false) { throw 1; }
};

static ThrowsOnDestruct instance;

NAPI_MODULE_INIT(/* napi_env env, napi_value exports */) {
(void)env;
return exports;
}
20 changes: 20 additions & 0 deletions test/napi/napi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,26 @@ describe.concurrent.skipIf(!canBuildNodeAddons())("napi", () => {
expect(exitCode).toBe(0);
});

// Sentry BUN-390H. On macOS, Bun and native addons share the system libc++,
// so an exception thrown from an addon's global destructor during libc
// exit() reaches Bun's std::set_terminate handler. On Linux, Bun statically
// links its C++ runtime and an addon's throw reaches the system libstdc++
// instead (which aborts), so this assertion cannot hold there; the
// terminate-handler guard is exercised separately via bun:internal-for-testing
// in test/js/node/process/process.test.js.
it.skipIf(!isMacOS)("honors process.exit() code when a native addon's global destructor throws", async () => {
const addonPath = join(__dirname, "napi-app", "build", "Debug", "throwing_dtor_addon.node");
await using proc = spawn({
cmd: [bunExe(), "-e", `require(${JSON.stringify(addonPath)}); console.log("loaded"); process.exit(42);`],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect({ stdout, stderr, exitCode }).toEqual({ stdout: "loaded\n", stderr: "", exitCode: 42 });
expect(proc.signalCode).toBeNull();
});

it("behaves as expected when performing operations with an exception pending", async () => {
await checkSameOutput("test_deferred_exceptions", []);
});
Expand Down
Loading