node:fs: mark the per-VM Binding box as LSan-ignored (fixes worker-terminate-lifetime.test.ts on main)

RO
3mo ago
+42-0

What

test/js/web/workers/worker-terminate-lifetime.test.ts "terminate() while dns.lookup() is in flight" is red on main:

==19406==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 4104 byte(s) in 1 object(s) allocated from:
    ...
    #12 bun_runtime::node::node_fs_binding::Binding::new node_fs_binding.rs:153
    #13 bun_runtime::node::node_fs_binding::create_binding node_fs_binding.rs:412
    ...
    #23 Bun::JS2Native::jsDollarLazy JS2Native.cpp:31
    ...
    #30 JSC::profiledCall CallData.cpp:91
SUMMARY: AddressSanitizer: 4104 byte(s) leaked in 1 allocation(s).

The report is the Box<Binding> that create_binding() hands to the JSNodeJSFS GC wrapper when internal/fs/binding.ts first evaluates.

Cause

This is not a leak and not worker-related. The box is a per-VM singleton: workers free it via ~VM -> lastChanceToFinalize -> Binding::finalize (8 workers that require("node:fs") still report exactly one "leak"), and the main thread keeps it for process lifetime because it does not destroy its JSC VM. After to_js_boxed the only pointer to the box lives in the wrapper's m_ctx slot, inside the JSC heap; LSan does not scan bmalloc/libpas pages as roots, so it cannot find the pointer.

This was always masked by leak:Bun::generateModule in test/leaksan.supp. At malloc_context_size=60 that frame sits at depth 31. The nightly-2026-07-20 Rust bump in #34782 added allocator-internal frames (frames 1-10 of the report are all libstd alloc plumbing) and pushed Bun::generateModule one past the default 30-frame malloc_context_size, so the suppression stopped matching.

Fix

Call __lsan_ignore_object on the box before transferring it to the GC wrapper. The ignore is per-allocation: workers still free it normally, and the main-thread singleton is no longer reported regardless of allocator stack depth. Adds bun_core::asan::ignore_object<T>(&T) alongside the existing register_root_region helpers; taking &T (rather than *const T) keeps the ptr::from_ref at the definition so callers do not each trip the workspace-denied clippy::borrow_as_ptr lint.

A suppression on Bun::JS2Native::jsDollarLazy (frame 23) would also work and covers the whole $rust() lazy-init class, but is broader than needed; this keeps it to the one allocation that is known to be VM-lifetime.

Verification

  • New ASAN-only test "require('node:fs') is LSan-clean on the main thread" in test/js/node/fs/fs-oom.test.ts is the minimal repro; fails on main, passes with the fix.
  • bun bd test test/js/web/workers/worker-terminate-lifetime.test.ts: 4 pass (was 3 pass / 1 fail).
  • bun bd test test/js/node/fs/fs-oom.test.ts: 14 pass.
  • 8 workers each requiring node:fs then terminated: 0 leaks (worker teardown still frees the box).
  • cargo clippy -p bun_runtime -p bun_core --no-deps: clean.
  • test/js/node/fs/fs.test.ts: 427 pass / 6 skip.

no test proof · iteration 1 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/node/fs/fs-oom.test.ts