Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
24 changes: 24 additions & 0 deletions src/jsc/VirtualMachine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 (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
/// 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
Expand Down
1 change: 1 addition & 0 deletions src/runtime/cli/test/parallel/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand Down
1 change: 1 addition & 0 deletions src/runtime/cli/test_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2952,6 +2952,7 @@ impl TestCommand {
jest::Jest::RUNNER.write(None);
}
drop(reporter);
vm.collect_for_leak_check_at_exit();
Comment thread
robobun marked this conversation as resolved.
{
let vm_ptr: *mut VirtualMachine = vm;
// SAFETY: `vm_ptr` reborrows the live `&mut VirtualMachine`;
Expand Down
56 changes: 55 additions & 1 deletion test/cli/test/isolation.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
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";

// 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
Expand Down Expand Up @@ -868,3 +869,56 @@ 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<string, string> = {};
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); });
`;
}

// 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"]],
] 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);
});
});
7 changes: 7 additions & 0 deletions test/leaksan.supp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading