From 1b00955e5e93f4df1142bf32d93e5ae532a8753b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:52:34 +0000 Subject: [PATCH 1/5] bun test: free test-runner finalizer-owned allocations before exit so LSan lanes don't abort green runs At exit of a bun test run, the final file's expect() wrapper boxes are freed only by GC finalizers (Expect::finalize derefs the RefData that pins the file's BunTestCell), and no collection runs between the last test and exit(). The ASAN CI lane runs subprocesses with ASAN_OPTIONS=detect_leaks=1:abort_on_error=1, so LeakSanitizer reports those boxes and SIGABRTs (exit 134) an otherwise green run. Add VirtualMachine::collect_for_leak_check_at_exit, called from the test runner's exit path and the --parallel worker's exit path. It is a no-op in non-ASAN builds. Under ASAN it marks the cleanup-hook list as never-drained (these exit paths never call on_exit(), so napi finalizers deferred by the sweep would otherwise be parked there forever) and runs one synchronous full collection so the finalizers free their Rust boxes before LSan scans. The final global's ScopeFunctions boxes stay alive at exit (rooted by the still-protected global's bun:test exports); LSan cannot scan the JSC heap cells holding the only pointers to them, so suppress that allocation site in test/leaksan.supp. Fixes #32176 --- src/jsc/VirtualMachine.rs | 24 +++++++++++ src/runtime/cli/test/parallel/runner.rs | 1 + src/runtime/cli/test_command.rs | 1 + test/cli/test/isolation.test.ts | 53 +++++++++++++++++++++++++ test/leaksan.supp | 7 ++++ 5 files changed, 86 insertions(+) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index a103d9dd0b28..9657bd2d2527 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1503,6 +1503,30 @@ impl VirtualMachine { self.has_run_cleanup_hooks = true; } + /// Final collection for `bun test`'s exit paths, which never run + /// `on_exit()`. ASAN lanes leak-check at exit (LSAN_OPTIONS + /// `detect_leaks=1` + `abort_on_error=1`), and without a collection + /// between the last test and `exit()`, the final file's GC-finalizer-owned + /// Rust boxes (bun:test `Expect` wrappers and the `RefData` pinning that + /// file's `BunTestCell`) are still malloc-live when LSan scans, aborting a + /// green run. No-op in non-ASAN builds, which skip all teardown for exit + /// speed. https://github.com/oven-sh/bun/issues/32176 + pub fn collect_for_leak_check_at_exit(&mut self) { + debug_assert!(self.is_shutting_down()); + if !bun_core::env::ENABLE_ASAN { + return; + } + // Deferred napi finalizers enqueued by the sweep below (non- + // experimental addons route GC finalizers through + // `NapiFinalizerTask::schedule`) would be parked on + // `RareData::cleanup_hooks`, which this exit path never drains. + // Marking the list as done routes them to schedule()'s + // drop-immediately branch instead, same as finalizers deferred + // during `destructOnExit()`'s collection after `on_exit()` ran. + self.has_run_cleanup_hooks = true; + let _ = self.garbage_collect(true); + } + pub fn global_exit(&mut self) -> ! { debug_assert!(self.is_shutting_down()); // FIXME: we should be doing this, but we're not, but unfortunately diff --git a/src/runtime/cli/test/parallel/runner.rs b/src/runtime/cli/test/parallel/runner.rs index d264444c0314..dff17cf24fd7 100644 --- a/src/runtime/cli/test/parallel/runner.rs +++ b/src/runtime/cli/test/parallel/runner.rs @@ -710,6 +710,7 @@ pub fn run_as_worker( // (lastChanceToFinalize) runs; bypassing it leaks JSC-owned native state. vm_ref.exit_handler.exit_code = 0; vm_ref.is_shutting_down = true; + vm_ref.collect_for_leak_check_at_exit(); vm_ref.run_with_api_lock(|| { // SAFETY: caller guarantees `vm` is a valid live VM pointer for the worker's lifetime. unsafe { (*vm).global_exit() } diff --git a/src/runtime/cli/test_command.rs b/src/runtime/cli/test_command.rs index 150537464980..bdd295b5cbd9 100644 --- a/src/runtime/cli/test_command.rs +++ b/src/runtime/cli/test_command.rs @@ -2952,6 +2952,7 @@ impl TestCommand { jest::Jest::RUNNER.write(None); } drop(reporter); + vm.collect_for_leak_check_at_exit(); { let vm_ptr: *mut VirtualMachine = vm; // SAFETY: `vm_ptr` reborrows the live `&mut VirtualMachine`; diff --git a/test/cli/test/isolation.test.ts b/test/cli/test/isolation.test.ts index 52d498982637..b42679d513f5 100644 --- a/test/cli/test/isolation.test.ts +++ b/test/cli/test/isolation.test.ts @@ -2,6 +2,7 @@ import { describe, expect, setDefaultTimeout, test } from "bun:test"; import { bunEnv, bunExe, isASAN, normalizeBunSnapshot, tempDir } from "harness"; import fs from "node:fs"; import net from "node:net"; +import { join } from "node:path"; // Every case spawns at least one full `bun test --isolate` child; the heavy // ones (8-file leak fixtures, 500-2000-export module_info modules) exceed the @@ -868,3 +869,55 @@ test.concurrent("--isolate: require(esm) caches a BunTranspiledModule SourceProv expect(exitCode, `run ${run}`).toBe(0); } }); + +// At exit, the final file's `expect()` wrapper boxes (and the `RefData` / +// `BunTestCell` they pin) are freed only by GC finalizers, and no collection +// used to run between the last test and `exit()`. With LeakSanitizer active +// (the ASAN CI lane runs subprocesses with `detect_leaks=1:abort_on_error=1`), +// the exit scan reported those boxes and SIGABRT'd (exit 134) an otherwise +// green run. https://github.com/oven-sh/bun/issues/32176 +describe.concurrent("exit is leak-clean under LeakSanitizer", () => { + const FILE_COUNT = 4; + const files: Record = {}; + for (let i = 0; i < FILE_COUNT; i++) { + files[`f${i}.test.js`] = ` + import { test, expect } from "bun:test"; + test("f${i}", () => { expect(1 + 1).toBe(2); }); + `; + } + + const cases: [label: string, args: string[]][] = [ + ["serial", []], + ["--isolate", ["--isolate"]], + ["--parallel=2", ["--parallel=2"]], + ]; + for (const [label, args] of cases) { + // LeakSanitizer only exists in ASAN builds. + test.skipIf(!isASAN)(label, async () => { + using dir = tempDir(`test-exit-lsan-${args.length ? args[0].replace(/[^a-z0-9]/g, "") : "serial"}`, files); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", ...args, "."], + env: { + ...bunEnv, + // The CI runner sets this for outer test processes; the exit path + // must be leak-clean without the full destruct-on-exit teardown too. + BUN_DESTRUCT_VM_ON_EXIT: undefined, + BUN_TEST_PARALLEL_SCALE_MS: "0", + ASAN_OPTIONS: "allow_user_segv_handler=1:disable_coredump=0:detect_leaks=1:abort_on_error=1", + LSAN_OPTIONS: `malloc_context_size=30:print_suppressions=0:suppressions=${join(import.meta.dir, "..", "..", "leaksan.supp")}`, + }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // A --parallel worker's at-exit abort does not change the coordinator's + // exit code (its results were already collected by then), but the + // report still prints to inherited stderr, so assert on the report + // text too. On failure, not.toContain prints the whole report. + expect(stderr).not.toContain("LeakSanitizer"); + expect(stderr).toContain(`${FILE_COUNT} pass`); + expect(exitCode).toBe(0); + }); + } +}); diff --git a/test/leaksan.supp b/test/leaksan.supp index ab713edd3ec9..6cabf49f4365 100644 --- a/test/leaksan.supp +++ b/test/leaksan.supp @@ -127,3 +127,10 @@ leak:WTF::RunLoop::dispatchAfter # LSAN's conservative stack scan no longer finds the pointers. leak:bun_runtime::cli::filter_run::run_scripts_with_filter leak:bun_runtime::cli::multi_run::run +# bun test at exit: the test/describe/expect function wrappers of the final +# (still gcProtected) global are alive, but their native ScopeFunctions boxes +# are pointed to only from JSC heap cells, which LSan cannot scan, so it +# reports them as leaks. They are freed by GC finalizers whenever a global +# dies (isolation swap, BUN_DESTRUCT_VM_ON_EXIT teardown). Per-global, bounded. +# https://github.com/oven-sh/bun/issues/32176 +leak:scope_functions::ScopeFunctions From 4457dbeeddf34bf268016946f2220fe74e5b3637 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:55:59 +0000 Subject: [PATCH 2/5] Gate the LSan exit tests on linux; detect_leaks=1 errors at startup where LSan is unsupported --- test/cli/test/isolation.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/cli/test/isolation.test.ts b/test/cli/test/isolation.test.ts index b42679d513f5..20f99db06dab 100644 --- a/test/cli/test/isolation.test.ts +++ b/test/cli/test/isolation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, setDefaultTimeout, test } from "bun:test"; -import { bunEnv, bunExe, isASAN, normalizeBunSnapshot, tempDir } from "harness"; +import { bunEnv, bunExe, isASAN, isLinux, normalizeBunSnapshot, tempDir } from "harness"; import fs from "node:fs"; import net from "node:net"; import { join } from "node:path"; @@ -892,8 +892,10 @@ describe.concurrent("exit is leak-clean under LeakSanitizer", () => { ["--parallel=2", ["--parallel=2"]], ]; for (const [label, args] of cases) { - // LeakSanitizer only exists in ASAN builds. - test.skipIf(!isASAN)(label, async () => { + // LeakSanitizer only exists in ASAN builds, and `detect_leaks=1` is a + // startup error on platforms without LSan (macOS arm64); the CI lane + // that leak-checks is linux x64-asan. + test.skipIf(!isASAN || !isLinux)(label, async () => { using dir = tempDir(`test-exit-lsan-${args.length ? args[0].replace(/[^a-z0-9]/g, "") : "serial"}`, files); await using proc = Bun.spawn({ cmd: [bunExe(), "test", ...args, "."], From eb4630c472ff1d8c3f0fd8be654bc2c32eb20556 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:34:12 +0000 Subject: [PATCH 3/5] Use test.each for the LSan exit test matrix --- test/cli/test/isolation.test.ts | 63 ++++++++++++++++----------------- 1 file changed, 31 insertions(+), 32 deletions(-) diff --git a/test/cli/test/isolation.test.ts b/test/cli/test/isolation.test.ts index 20f99db06dab..1415366a07dc 100644 --- a/test/cli/test/isolation.test.ts +++ b/test/cli/test/isolation.test.ts @@ -886,40 +886,39 @@ describe.concurrent("exit is leak-clean under LeakSanitizer", () => { `; } - const cases: [label: string, args: string[]][] = [ + // LeakSanitizer only exists in ASAN builds, and `detect_leaks=1` is a + // startup error on platforms without LSan (macOS arm64); the CI lane + // that leak-checks is linux x64-asan. + const leakCheckEnv = { + ...bunEnv, + // The CI runner sets this for outer test processes; the exit path + // must be leak-clean without the full destruct-on-exit teardown too. + BUN_DESTRUCT_VM_ON_EXIT: undefined, + BUN_TEST_PARALLEL_SCALE_MS: "0", + ASAN_OPTIONS: "allow_user_segv_handler=1:disable_coredump=0:detect_leaks=1:abort_on_error=1", + LSAN_OPTIONS: `malloc_context_size=30:print_suppressions=0:suppressions=${join(import.meta.dir, "..", "..", "leaksan.supp")}`, + }; + + test.skipIf(!isASAN || !isLinux).each([ ["serial", []], ["--isolate", ["--isolate"]], ["--parallel=2", ["--parallel=2"]], - ]; - for (const [label, args] of cases) { - // LeakSanitizer only exists in ASAN builds, and `detect_leaks=1` is a - // startup error on platforms without LSan (macOS arm64); the CI lane - // that leak-checks is linux x64-asan. - test.skipIf(!isASAN || !isLinux)(label, async () => { - using dir = tempDir(`test-exit-lsan-${args.length ? args[0].replace(/[^a-z0-9]/g, "") : "serial"}`, files); - await using proc = Bun.spawn({ - cmd: [bunExe(), "test", ...args, "."], - env: { - ...bunEnv, - // The CI runner sets this for outer test processes; the exit path - // must be leak-clean without the full destruct-on-exit teardown too. - BUN_DESTRUCT_VM_ON_EXIT: undefined, - BUN_TEST_PARALLEL_SCALE_MS: "0", - ASAN_OPTIONS: "allow_user_segv_handler=1:disable_coredump=0:detect_leaks=1:abort_on_error=1", - LSAN_OPTIONS: `malloc_context_size=30:print_suppressions=0:suppressions=${join(import.meta.dir, "..", "..", "leaksan.supp")}`, - }, - cwd: String(dir), - stdout: "pipe", - stderr: "pipe", - }); - const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - // A --parallel worker's at-exit abort does not change the coordinator's - // exit code (its results were already collected by then), but the - // report still prints to inherited stderr, so assert on the report - // text too. On failure, not.toContain prints the whole report. - expect(stderr).not.toContain("LeakSanitizer"); - expect(stderr).toContain(`${FILE_COUNT} pass`); - expect(exitCode).toBe(0); + ] as [label: string, args: string[]][])("%s", async (label, args) => { + using dir = tempDir(`test-exit-lsan-${label.replace(/[^a-z0-9]/g, "")}`, files); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", ...args, "."], + env: leakCheckEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", }); - } + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // A --parallel worker's at-exit abort does not change the coordinator's + // exit code (its results were already collected by then), but the + // report still prints to inherited stderr, so assert on the report + // text too. On failure, not.toContain prints the whole report. + expect(stderr).not.toContain("LeakSanitizer"); + expect(stderr).toContain(`${FILE_COUNT} pass`); + expect(exitCode).toBe(0); + }); }); From ac954c500c728b8be4312f0a6143816e3fc3c3c1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:51:34 +0000 Subject: [PATCH 4/5] Fix doc comment: detect_leaks/abort_on_error are ASAN_OPTIONS flags --- src/jsc/VirtualMachine.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 9657bd2d2527..074e37d7d342 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1504,7 +1504,7 @@ impl VirtualMachine { } /// Final collection for `bun test`'s exit paths, which never run - /// `on_exit()`. ASAN lanes leak-check at exit (LSAN_OPTIONS + /// `on_exit()`. ASAN lanes leak-check at exit (ASAN_OPTIONS /// `detect_leaks=1` + `abort_on_error=1`), and without a collection /// between the last test and `exit()`, the final file's GC-finalizer-owned /// Rust boxes (bun:test `Expect` wrappers and the `RefData` pinning that From 57e361b1f5aa53887c65a6a921bf4b5d30c6e093 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:26:42 +0000 Subject: [PATCH 5/5] ci: retrigger