From ec36bbcd1d821b1dad590ebe270aa991bf2741a2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:41:53 +0000 Subject: [PATCH 1/6] node:fs: mark the per-VM Binding box as LSan-ignored The Box that create_binding() hands to the JSNodeJSFS wrapper is a per-VM singleton: workers free it via ~VM -> lastChanceToFinalize, and the main thread keeps it for process lifetime. After to_js_boxed the only pointer to it lives in the GC wrapper's m_ctx slot inside the JSC heap, and LSan does not scan bmalloc/libpas pages as roots. This was masked by leak:Bun::generateModule in test/leaksan.supp until the nightly-2026-07-20 Rust bump (#34782) added allocator-internal frames and pushed that entry to frame 31, past the default 30-frame malloc_context_size; worker-terminate-lifetime.test.ts then started reporting a 4104-byte "leak" for require("node:fs") on the main thread. Call __lsan_ignore_object on the box so the main-thread singleton is not reported, regardless of stack depth. Workers still free it normally. --- src/bun_core/lib.rs | 13 ++++++++++ src/runtime/node/node_fs_binding.rs | 11 ++++++++ .../workers/worker-terminate-lifetime.test.ts | 26 +++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/src/bun_core/lib.rs b/src/bun_core/lib.rs index 2a7bc62a9702..6cea11e2eb84 100644 --- a/src/bun_core/lib.rs +++ b/src/bun_core/lib.rs @@ -2813,6 +2813,7 @@ pub mod asan { safe fn __asan_describe_address(ptr: *const c_void); safe fn __lsan_register_root_region(ptr: *const c_void, size: usize); safe fn __lsan_unregister_root_region(ptr: *const c_void, size: usize); + safe fn __lsan_ignore_object(ptr: *const c_void); } #[inline] @@ -2858,6 +2859,18 @@ pub mod asan { #[cfg(not(bun_asan))] let _ = (ptr, size); } + /// Tell LSAN this heap allocation is intentionally process-lifetime and + /// must not be reported as a leak. Use only for per-VM singletons whose + /// sole anchor lives inside the JSC heap (bmalloc/libpas pages are not + /// scanned as roots, so LSAN cannot see the pointer). Freeing the object + /// later is fine; the ignore is per-allocation, not per-address. + #[inline] + pub fn ignore_object(ptr: *const T) { + #[cfg(bun_asan)] + __lsan_ignore_object(ptr.cast()); + #[cfg(not(bun_asan))] + let _ = ptr; + } } // ──────────────────────────────────────────────────────────────────────────── diff --git a/src/runtime/node/node_fs_binding.rs b/src/runtime/node/node_fs_binding.rs index 42cb49761599..87639fd8b3fd 100644 --- a/src/runtime/node/node_fs_binding.rs +++ b/src/runtime/node/node_fs_binding.rs @@ -416,6 +416,17 @@ pub(crate) fn create_binding(global: &JSGlobalObject) -> JSValue { // trivially un-aliased (sole owner of the fresh `Box`). module.node_fs.with_mut(|nfs| nfs.vm = NonNull::new(vm)); + // Per-VM singleton: created once for `internal/fs/binding.ts`, freed by + // the GC wrapper's finalizer at `~VM` in workers, and kept for process + // lifetime on the main thread (which does not destroy its JSC VM). After + // `to_js_boxed` the only pointer to this box lives in the wrapper's + // `m_ctx`, inside the JSC heap; LSan does not scan bmalloc/libpas pages + // as roots, so without this it reports the main-thread singleton as a + // leak once the allocation stack is deep enough that the + // `Bun::generateModule` suppression in `test/leaksan.supp` falls outside + // `malloc_context_size`. + bun_core::asan::ignore_object(&*module as *const Binding); + // `module` was `Box::new`-allocated; ownership transfers to the GC // wrapper, which calls `Binding::finalize` to reclaim it. Binding::to_js_boxed(module, global) diff --git a/test/js/web/workers/worker-terminate-lifetime.test.ts b/test/js/web/workers/worker-terminate-lifetime.test.ts index 9d476d1f3d53..0c0a7f49e74d 100644 --- a/test/js/web/workers/worker-terminate-lifetime.test.ts +++ b/test/js/web/workers/worker-terminate-lifetime.test.ts @@ -122,6 +122,32 @@ test( ); // Regression: the per-VM c-ares channel was destroyed in deinit_runtime_state +// The per-VM `node:fs` native binding (a Box created once by +// internal/fs/binding.ts) is anchored only by the GC wrapper's m_ctx slot, +// which lives in the JSC heap (bmalloc). LSan does not scan bmalloc pages as +// roots, so without __lsan_ignore_object it reports the main-thread singleton +// as a 4104-byte leak once Rust's allocator internals push Bun::generateModule +// past malloc_context_size. Covered by the dns.lookup test below too, but this +// is the minimal repro and runs in a fraction of the time. +test.skipIf(!isASAN)( + "main-thread node:fs binding is not a false-positive LSan leak", + async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", `require("node:fs");`], + env: { + ...bunEnv, + ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "detect_leaks=1"].filter(Boolean).join(":"), + LSAN_OPTIONS: `print_suppressions=0:suppressions=${join(import.meta.dirname, "../../../leaksan.supp")}`, + }, + 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: "", stderr: "", exitCode: 0 }); + }, + timeout, +); + // (RuntimeState drop) AFTER JSC teardown and RareData.file_polls drop. // ares_destroy() synchronously fires EDESTRUCTION query callbacks and socket- // state callbacks, which then dereferenced the freed JSGlobalObject and the From ed71d53ce3e9feea3f17be24a15ff16f8a34d431 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:51:18 +0000 Subject: [PATCH 2/6] test: keep c-ares regression comment contiguous with its test --- test/js/web/workers/worker-terminate-lifetime.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/web/workers/worker-terminate-lifetime.test.ts b/test/js/web/workers/worker-terminate-lifetime.test.ts index 0c0a7f49e74d..ba7f14f7c51f 100644 --- a/test/js/web/workers/worker-terminate-lifetime.test.ts +++ b/test/js/web/workers/worker-terminate-lifetime.test.ts @@ -121,7 +121,6 @@ test( timeout, ); -// Regression: the per-VM c-ares channel was destroyed in deinit_runtime_state // The per-VM `node:fs` native binding (a Box created once by // internal/fs/binding.ts) is anchored only by the GC wrapper's m_ctx slot, // which lives in the JSC heap (bmalloc). LSan does not scan bmalloc pages as @@ -148,6 +147,7 @@ test.skipIf(!isASAN)( timeout, ); +// Regression: the per-VM c-ares channel was destroyed in deinit_runtime_state // (RuntimeState drop) AFTER JSC teardown and RareData.file_polls drop. // ares_destroy() synchronously fires EDESTRUCTION query callbacks and socket- // state callbacks, which then dereferenced the freed JSGlobalObject and the From 62c5a147b6c9146b874c1d1c6ad796228f365a22 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:06:44 +0000 Subject: [PATCH 3/6] review: tighten ignore_object call-site comments --- src/runtime/node/node_fs_binding.rs | 12 +++--------- .../js/web/workers/worker-terminate-lifetime.test.ts | 10 +++------- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/src/runtime/node/node_fs_binding.rs b/src/runtime/node/node_fs_binding.rs index 87639fd8b3fd..00313dedfcba 100644 --- a/src/runtime/node/node_fs_binding.rs +++ b/src/runtime/node/node_fs_binding.rs @@ -416,15 +416,9 @@ pub(crate) fn create_binding(global: &JSGlobalObject) -> JSValue { // trivially un-aliased (sole owner of the fresh `Box`). module.node_fs.with_mut(|nfs| nfs.vm = NonNull::new(vm)); - // Per-VM singleton: created once for `internal/fs/binding.ts`, freed by - // the GC wrapper's finalizer at `~VM` in workers, and kept for process - // lifetime on the main thread (which does not destroy its JSC VM). After - // `to_js_boxed` the only pointer to this box lives in the wrapper's - // `m_ctx`, inside the JSC heap; LSan does not scan bmalloc/libpas pages - // as roots, so without this it reports the main-thread singleton as a - // leak once the allocation stack is deep enough that the - // `Bun::generateModule` suppression in `test/leaksan.supp` falls outside - // `malloc_context_size`. + // Per-VM singleton: freed at `~VM` in workers, process-lifetime on the + // main thread. After `to_js_boxed` the only pointer lives in the GC + // wrapper's `m_ctx` inside the JSC heap, which LSan does not scan. bun_core::asan::ignore_object(&*module as *const Binding); // `module` was `Box::new`-allocated; ownership transfers to the GC diff --git a/test/js/web/workers/worker-terminate-lifetime.test.ts b/test/js/web/workers/worker-terminate-lifetime.test.ts index ba7f14f7c51f..d5a9fd6fb658 100644 --- a/test/js/web/workers/worker-terminate-lifetime.test.ts +++ b/test/js/web/workers/worker-terminate-lifetime.test.ts @@ -121,13 +121,9 @@ test( timeout, ); -// The per-VM `node:fs` native binding (a Box created once by -// internal/fs/binding.ts) is anchored only by the GC wrapper's m_ctx slot, -// which lives in the JSC heap (bmalloc). LSan does not scan bmalloc pages as -// roots, so without __lsan_ignore_object it reports the main-thread singleton -// as a 4104-byte leak once Rust's allocator internals push Bun::generateModule -// past malloc_context_size. Covered by the dns.lookup test below too, but this -// is the minimal repro and runs in a fraction of the time. +// The per-VM node:fs Binding box is anchored only by the GC wrapper's m_ctx +// inside the JSC heap, which LSan does not scan; without __lsan_ignore_object +// the main-thread singleton is reported as a leak. test.skipIf(!isASAN)( "main-thread node:fs binding is not a false-positive LSan leak", async () => { From 7d3fc82536c29b4ea77fab8acf8b00a780d2495c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:33:22 +0000 Subject: [PATCH 4/6] review: take &T in ignore_object (fixes borrow_as_ptr lint); move test to node/fs - ignore_object(&T) absorbs the ptr::from_ref at the definition so callers don't each have to work around the workspace-denied borrow_as_ptr lint. - The LSan test exercises node_fs_binding.rs with no workers involved, so it belongs under test/js/node/fs/ per the test-organization convention. worker-terminate-lifetime.test.ts is restored to main; the dns.lookup test there still covers the worker path. --- src/bun_core/lib.rs | 6 ++--- src/runtime/node/node_fs_binding.rs | 2 +- test/js/node/fs/fs-oom.test.ts | 18 +++++++++++++++ .../workers/worker-terminate-lifetime.test.ts | 22 ------------------- 4 files changed, 22 insertions(+), 26 deletions(-) diff --git a/src/bun_core/lib.rs b/src/bun_core/lib.rs index 6cea11e2eb84..7d543ce13174 100644 --- a/src/bun_core/lib.rs +++ b/src/bun_core/lib.rs @@ -2865,11 +2865,11 @@ pub mod asan { /// scanned as roots, so LSAN cannot see the pointer). Freeing the object /// later is fine; the ignore is per-allocation, not per-address. #[inline] - pub fn ignore_object(ptr: *const T) { + pub fn ignore_object(r: &T) { #[cfg(bun_asan)] - __lsan_ignore_object(ptr.cast()); + __lsan_ignore_object(core::ptr::from_ref(r).cast()); #[cfg(not(bun_asan))] - let _ = ptr; + let _ = r; } } diff --git a/src/runtime/node/node_fs_binding.rs b/src/runtime/node/node_fs_binding.rs index 00313dedfcba..8093762d9f66 100644 --- a/src/runtime/node/node_fs_binding.rs +++ b/src/runtime/node/node_fs_binding.rs @@ -419,7 +419,7 @@ pub(crate) fn create_binding(global: &JSGlobalObject) -> JSValue { // Per-VM singleton: freed at `~VM` in workers, process-lifetime on the // main thread. After `to_js_boxed` the only pointer lives in the GC // wrapper's `m_ctx` inside the JSC heap, which LSan does not scan. - bun_core::asan::ignore_object(&*module as *const Binding); + bun_core::asan::ignore_object::(&module); // `module` was `Box::new`-allocated; ownership transfers to the GC // wrapper, which calls `Binding::finalize` to reclaim it. diff --git a/test/js/node/fs/fs-oom.test.ts b/test/js/node/fs/fs-oom.test.ts index ea453cd11874..d783b6e01bb7 100644 --- a/test/js/node/fs/fs-oom.test.ts +++ b/test/js/node/fs/fs-oom.test.ts @@ -177,3 +177,21 @@ describe.skipIf(!isASAN)("utf8 to utf16 output buffer allocation failure is catc expect(exitCode).toBe(0); }); }); + +// The per-VM node:fs Binding box is anchored only by the GC wrapper's m_ctx +// inside the JSC heap, which LSan does not scan; without __lsan_ignore_object +// the main-thread singleton is reported as a leak. +test.skipIf(!isASAN)("require('node:fs') is LSan-clean on the main thread", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", `require("node:fs");`], + env: { + ...bunEnv, + ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "detect_leaks=1"].filter(Boolean).join(":"), + LSAN_OPTIONS: `print_suppressions=0:suppressions=${join(import.meta.dirname, "../../../leaksan.supp")}`, + }, + 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: "", stderr: "", exitCode: 0 }); +}, 60_000); diff --git a/test/js/web/workers/worker-terminate-lifetime.test.ts b/test/js/web/workers/worker-terminate-lifetime.test.ts index d5a9fd6fb658..9d476d1f3d53 100644 --- a/test/js/web/workers/worker-terminate-lifetime.test.ts +++ b/test/js/web/workers/worker-terminate-lifetime.test.ts @@ -121,28 +121,6 @@ test( timeout, ); -// The per-VM node:fs Binding box is anchored only by the GC wrapper's m_ctx -// inside the JSC heap, which LSan does not scan; without __lsan_ignore_object -// the main-thread singleton is reported as a leak. -test.skipIf(!isASAN)( - "main-thread node:fs binding is not a false-positive LSan leak", - async () => { - await using proc = Bun.spawn({ - cmd: [bunExe(), "-e", `require("node:fs");`], - env: { - ...bunEnv, - ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "detect_leaks=1"].filter(Boolean).join(":"), - LSAN_OPTIONS: `print_suppressions=0:suppressions=${join(import.meta.dirname, "../../../leaksan.supp")}`, - }, - 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: "", stderr: "", exitCode: 0 }); - }, - timeout, -); - // Regression: the per-VM c-ares channel was destroyed in deinit_runtime_state // (RuntimeState drop) AFTER JSC teardown and RareData.file_polls drop. // ares_destroy() synchronously fires EDESTRUCTION query callbacks and socket- From c3725826cb4148adf4bcef4b0e0840bcc236a538 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:39:00 +0000 Subject: [PATCH 5/6] [autofix.ci] apply automated fixes --- test/js/node/fs/fs-oom.test.ts | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/test/js/node/fs/fs-oom.test.ts b/test/js/node/fs/fs-oom.test.ts index d783b6e01bb7..ac0f244fe119 100644 --- a/test/js/node/fs/fs-oom.test.ts +++ b/test/js/node/fs/fs-oom.test.ts @@ -181,17 +181,21 @@ describe.skipIf(!isASAN)("utf8 to utf16 output buffer allocation failure is catc // The per-VM node:fs Binding box is anchored only by the GC wrapper's m_ctx // inside the JSC heap, which LSan does not scan; without __lsan_ignore_object // the main-thread singleton is reported as a leak. -test.skipIf(!isASAN)("require('node:fs') is LSan-clean on the main thread", async () => { - await using proc = Bun.spawn({ - cmd: [bunExe(), "-e", `require("node:fs");`], - env: { - ...bunEnv, - ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "detect_leaks=1"].filter(Boolean).join(":"), - LSAN_OPTIONS: `print_suppressions=0:suppressions=${join(import.meta.dirname, "../../../leaksan.supp")}`, - }, - 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: "", stderr: "", exitCode: 0 }); -}, 60_000); +test.skipIf(!isASAN)( + "require('node:fs') is LSan-clean on the main thread", + async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", `require("node:fs");`], + env: { + ...bunEnv, + ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "detect_leaks=1"].filter(Boolean).join(":"), + LSAN_OPTIONS: `print_suppressions=0:suppressions=${join(import.meta.dirname, "../../../leaksan.supp")}`, + }, + 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: "", stderr: "", exitCode: 0 }); + }, + 60_000, +); From cb3047495c88e5d28d67e203c6af692dbda07e5f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:22:51 +0000 Subject: [PATCH 6/6] test: document why the LSan scan test needs a per-test timeout Without it the test times out at the 5s default: detect_leaks=1 runs LSan's reachability scan at child exit, which alone takes ~5-6s under debug+ASAN. The sibling ASAN subprocess tests in this file set detect_leaks=0, so they don't pay that cost. Reduced from 60s to 30s. --- test/js/node/fs/fs-oom.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/js/node/fs/fs-oom.test.ts b/test/js/node/fs/fs-oom.test.ts index ac0f244fe119..f9c079d8b794 100644 --- a/test/js/node/fs/fs-oom.test.ts +++ b/test/js/node/fs/fs-oom.test.ts @@ -197,5 +197,7 @@ test.skipIf(!isASAN)( const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect({ stdout, stderr, exitCode }).toEqual({ stdout: "", stderr: "", exitCode: 0 }); }, - 60_000, + // `detect_leaks=1` runs LSan's reachability scan at child exit, which alone + // takes ~5-6s under debug+ASAN; the sibling tests above set detect_leaks=0. + 30_000, );