diff --git a/src/bun_core/lib.rs b/src/bun_core/lib.rs index 2a7bc62a970..7d543ce1317 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(r: &T) { + #[cfg(bun_asan)] + __lsan_ignore_object(core::ptr::from_ref(r).cast()); + #[cfg(not(bun_asan))] + let _ = r; + } } // ──────────────────────────────────────────────────────────────────────────── diff --git a/src/runtime/node/node_fs_binding.rs b/src/runtime/node/node_fs_binding.rs index 42cb4976159..8093762d9f6 100644 --- a/src/runtime/node/node_fs_binding.rs +++ b/src/runtime/node/node_fs_binding.rs @@ -416,6 +416,11 @@ 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: 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); + // `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/node/fs/fs-oom.test.ts b/test/js/node/fs/fs-oom.test.ts index ea453cd1187..f9c079d8b79 100644 --- a/test/js/node/fs/fs-oom.test.ts +++ b/test/js/node/fs/fs-oom.test.ts @@ -177,3 +177,27 @@ 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 }); + }, + // `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, +);