From 70e962b5fbc3c56036bfec676aa7459a05b51761 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 25 May 2026 04:07:10 +0000 Subject: [PATCH 1/3] install: only run the package manager exit-callback cache teardown on the main thread in ASAN builds --- src/install/PackageManager.rs | 22 ++++++++++-- test/cli/install/bun-install-registry.test.ts | 36 +++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index 0225eed350d..bbde39c2118 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -1414,10 +1414,27 @@ pub(crate) fn allocate_package_manager() { let ptr = bun_core::heap::into_raw(Box::::new_uninit()).cast::(); holder::RAW_PTR.store(ptr, core::sync::atomic::Ordering::Release); - bun_core::add_exit_callback(deinit_caches_at_exit); + // The exit callback exists only so LeakSanitizer does not report the + // caches as still reachable at exit. In non-ASAN builds it is wasted work + // at process exit, so don't register it. (`ENABLE_ASAN` is a compile-time + // const, so this branch folds away while keeping `deinit_caches_at_exit` + // referenced.) + if bun_core::Environment::ENABLE_ASAN { + bun_core::add_exit_callback(deinit_caches_at_exit); + } } extern "C" fn deinit_caches_at_exit() { + // Exit callbacks run on whichever thread called `Global::exit()` — e.g. + // the HTTP client thread when CA-file validation fails in + // `http_thread_on_init_error`. The cache's `MimallocArena` heaps are + // created by and belong to the main thread, and the main thread may still + // be mutating the cache concurrently, so off-main this would be both a + // wrong-thread `mi_heap_destroy` and a data race. Skip it and let the OS + // reclaim the memory. + if !bun_crash_handler::cli_state::is_main_thread() { + return; + } if !holder::INITIALIZED.load(core::sync::atomic::Ordering::Acquire) { return; } @@ -1425,7 +1442,8 @@ extern "C" fn deinit_caches_at_exit() { if ptr.is_null() { return; } - // SAFETY: `deinit_caches()` only touches main-thread-owned fields. + // SAFETY: `deinit_caches()` only touches main-thread-owned fields, and the + // main-thread precondition is checked above (not assumed). unsafe { (*ptr).deinit_caches() }; } diff --git a/test/cli/install/bun-install-registry.test.ts b/test/cli/install/bun-install-registry.test.ts index 67ae7845fc2..4296dc08cea 100644 --- a/test/cli/install/bun-install-registry.test.ts +++ b/test/cli/install/bun-install-registry.test.ts @@ -260,6 +260,42 @@ describe("certificate authority", () => { expect(await exited).toBe(1); }); + test("non-existent --cafile with workspaces exits 1 without crashing", async () => { + // The workspace walk in `PackageManager::init()` populates the workspace + // package.json cache before the HTTP thread starts. When the HTTP thread + // then fails CA validation and drives process exit, the exit path must not + // tear down that main-thread-owned cache from the HTTP thread. + await Promise.all([ + write( + packageJson, + JSON.stringify({ + name: "foo", + version: "1.0.0", + workspaces: ["packages/*"], + dependencies: { "no-deps": "1.1.1" }, + }), + ), + ...["a", "b", "c"].map(name => + write( + join(packageDir, "packages", name, "package.json"), + JSON.stringify({ name: `pkg-${name}`, version: "1.0.0" }), + ), + ), + ]); + const { stdout, stderr, exited } = spawn({ + cmd: [bunExe(), "install", "--cafile", "/does/not/exist"], + cwd: packageDir, + stderr: "pipe", + stdout: "pipe", + env, + }); + const out = await stdout.text(); + expect(out).not.toContain("no-deps"); + const err = await stderr.text(); + expect(err).toContain(`HTTPThread: could not find CA file: '/does/not/exist'`); + expect(await exited).toBe(1); + }); + test("cafile from bunfig does not exist", async () => { await Promise.all([ write( From bbb769ef725c59c55b2442e5f046f2d29df739e0 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 25 May 2026 04:16:04 +0000 Subject: [PATCH 2/3] install: compile the exit-callback cache teardown only into asan builds --- src/install/PackageManager.rs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index bbde39c2118..72de761dfec 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -1415,15 +1415,12 @@ pub(crate) fn allocate_package_manager() { bun_core::heap::into_raw(Box::::new_uninit()).cast::(); holder::RAW_PTR.store(ptr, core::sync::atomic::Ordering::Release); // The exit callback exists only so LeakSanitizer does not report the - // caches as still reachable at exit. In non-ASAN builds it is wasted work - // at process exit, so don't register it. (`ENABLE_ASAN` is a compile-time - // const, so this branch folds away while keeping `deinit_caches_at_exit` - // referenced.) - if bun_core::Environment::ENABLE_ASAN { - bun_core::add_exit_callback(deinit_caches_at_exit); - } + // caches as still reachable at exit; non-ASAN builds don't compile it in. + #[cfg(bun_asan)] + bun_core::add_exit_callback(deinit_caches_at_exit); } +#[cfg(bun_asan)] extern "C" fn deinit_caches_at_exit() { // Exit callbacks run on whichever thread called `Global::exit()` — e.g. // the HTTP client thread when CA-file validation fails in From 191db10cae5839d6446b999ca668c01b0bdb2c71 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 25 May 2026 05:26:12 +0000 Subject: [PATCH 3/3] install: drop redundant comments around the exit-callback cache teardown --- src/install/PackageManager.rs | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index 72de761dfec..f89944c6238 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -1414,21 +1414,12 @@ pub(crate) fn allocate_package_manager() { let ptr = bun_core::heap::into_raw(Box::::new_uninit()).cast::(); holder::RAW_PTR.store(ptr, core::sync::atomic::Ordering::Release); - // The exit callback exists only so LeakSanitizer does not report the - // caches as still reachable at exit; non-ASAN builds don't compile it in. #[cfg(bun_asan)] bun_core::add_exit_callback(deinit_caches_at_exit); } #[cfg(bun_asan)] extern "C" fn deinit_caches_at_exit() { - // Exit callbacks run on whichever thread called `Global::exit()` — e.g. - // the HTTP client thread when CA-file validation fails in - // `http_thread_on_init_error`. The cache's `MimallocArena` heaps are - // created by and belong to the main thread, and the main thread may still - // be mutating the cache concurrently, so off-main this would be both a - // wrong-thread `mi_heap_destroy` and a data race. Skip it and let the OS - // reclaim the memory. if !bun_crash_handler::cli_state::is_main_thread() { return; } @@ -1439,8 +1430,7 @@ extern "C" fn deinit_caches_at_exit() { if ptr.is_null() { return; } - // SAFETY: `deinit_caches()` only touches main-thread-owned fields, and the - // main-thread precondition is checked above (not assumed). + // SAFETY: `deinit_caches()` only touches main-thread-owned fields. unsafe { (*ptr).deinit_caches() }; }