Skip to content

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

Open
robobun wants to merge 6 commits into
mainfrom
farm/fae20db5/node-fs-binding-lsan-false-positive
Open

node:fs: mark the per-VM Binding box as LSan-ignored (fixes worker-terminate-lifetime.test.ts on main)#35159
robobun wants to merge 6 commits into
mainfrom
farm/fae20db5/node-fs-binding-lsan-false-positive

Conversation

@robobun

@robobun robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

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

The Box<Binding> 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.
@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The ASAN module adds an LSAN object-ignore helper. NodeFS marks its per-VM binding singleton as ignored, and an ASAN-only subprocess test verifies clean node:fs loading under leak detection.

NodeFS LSAN leak handling

Layer / File(s) Summary
ASAN ignore-object helper
src/bun_core/lib.rs
The asan module declares __lsan_ignore_object and adds the public ignore_object helper with ASAN and non-ASAN behavior.
NodeFS singleton integration and regression test
src/runtime/node/node_fs_binding.rs, test/js/web/workers/worker-terminate-lifetime.test.ts
The NodeFS binding singleton is marked for LSAN exclusion, and an ASAN-only subprocess test checks leak-detection output and exit status.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: marking the per-VM node:fs Binding box as LSan-ignored to fix the failing test.
Description check ✅ Passed The description covers the change and verification, though it uses custom headings instead of the exact template sections.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 2:25 PM PT - Jul 22nd, 2026

@robobun, your commit cb30474 has 3 failures in Build #78031 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35159

That installs a local version of the PR into your bun-35159 executable, so you can run:

bun-35159 --bun

@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced by running require("node:fs") under detect_leaks=1 with test/leaksan.supp on current main (no workers needed). Not a worker-terminate issue: the box is a main-thread module-init singleton whose only anchor is in the JSC heap, which LSan does not scan. Fail-before/pass-after verified with src/ at origin/main.

Self-review surfaced a real clippy::borrow_as_ptr failure (workspace-denied) at the original call site; fixed in 7d3fc82 by making ignore_object take &T. Test moved to test/js/node/fs/fs-oom.test.ts per the module-organization convention. clippy and both test files pass locally; all review threads resolved.

CI (builds 77980/77996/78010/78031) is green on everything this diff touches, including fs-oom.test.ts and worker-terminate-lifetime.test.ts on the ASAN lane. The only non-retry failures across all four are the common/gc.js onGC()-based tests (test-net-connect-memleak.js, test-gc-http-client-connaborted.js) on linux-x64 glibc, a release-build GC-timing race tracked in #33044 / #34176 / #34631, plus a darwin-aarch64 VM-limit infra failure on 78031. This change is #[cfg(bun_asan)]-only and compiles to let _ = r; in release builds, so it cannot affect those tests. Ready for a maintainer to merge past those lanes.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM modulo the inline nit — the new test was inserted one line into the c-ares regression comment, splitting it mid-sentence. Worth fixing before merge but not blocking.

Extended reasoning...

Overview

Fixes a red test on main caused by the nightly-2026-07-20 Rust toolchain bump: extra allocator-internal frames pushed Bun::generateModule past LSan's default malloc_context_size, so the existing suppression stopped matching and the main-thread node:fs Box<Binding> singleton (whose only anchor lives in the JSC/bmalloc heap, which LSan doesn't scan) started reporting as a leak. The fix adds bun_core::asan::ignore_object<T> (a thin wrapper over __lsan_ignore_object, mirroring the existing register_root_region helpers) and calls it on the box in create_binding() before ownership transfers to the GC wrapper. A new ASAN-only minimal-repro test is added.

Security risks

None. The only production-code change is an ASAN-only annotation that compiles to let _ = ptr; under #[cfg(not(bun_asan))]. No behavioral change to node:fs, no memory-management change (workers still free the box via ~VMlastChanceToFinalizeBinding::finalize; the ignore is per-allocation and doesn't prevent freeing).

Level of scrutiny

Low-to-medium. This is a CI-greenness fix with a well-documented root cause (the PR description traces the exact frame depth and explains why the suppression stopped matching). The new ignore_object helper follows the exact pattern of the neighboring register_root_region/unregister_root_region helpers in src/bun_core/lib.rs. The alternative (broadening the suppression file) was considered and rejected in favor of the narrower per-allocation ignore, which is the right call per REVIEW.md ("Never disable sanitizers or weaken CI verification to get green" — this is targeted, not a blanket suppression).

Other factors

  • Fail-before/pass-after verified with git stash -- src/ per the robobun comment.
  • The one finding is a nit: the new test block was inserted between lines 1 and 2 of the pre-existing c-ares regression comment, so the c-ares comment now starts above the wrong test and the dns.lookup test's comment begins mid-sentence. Real but cosmetic — no runtime effect.
  • The extern declaration for __lsan_ignore_object is inside the #[cfg(bun_asan)]-gated extern block (same block as the other __lsan_* symbols), so non-ASAN builds don't reference an undefined symbol.

Comment thread test/js/web/workers/worker-terminate-lifetime.test.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/node/node_fs_binding.rs`:
- Around line 419-427: Shorten the inline comment near the per-VM singleton in
src/runtime/node/node_fs_binding.rs:419-427 to three lines or fewer, retaining
only the ownership and LSan-rooting invariant. Also shorten the comment in
test/js/web/workers/worker-terminate-lifetime.test.ts:124-130 to three lines or
fewer, retaining only the regression intent and removing stack-depth history.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0164a475-d0b2-417e-ae90-93ab5f21daf3

📥 Commits

Reviewing files that changed from the base of the PR and between 47597ab and ed71d53.

📒 Files selected for processing (3)
  • src/bun_core/lib.rs
  • src/runtime/node/node_fs_binding.rs
  • test/js/web/workers/worker-terminate-lifetime.test.ts

Comment thread src/runtime/node/node_fs_binding.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the earlier comment-splitting nit is fixed in ed71d53, and the rest is a narrow, well-justified LSan false-positive suppression.

What was reviewed:

  • ignore_object<T> follows the exact pattern of register_root_region immediately above it (same safe fn extern block, same #[cfg(bun_asan)] gating, same no-op fallback).
  • &*module as *const Binding yields the boxed data address before ownership transfers via to_js_boxed; __lsan_ignore_object is per-allocation so worker-VM finalize still frees it normally.
  • New test mirrors the neighboring dns.lookup test's ASAN/LSAN env setup, drains pipes concurrently, asserts the combined {stdout, stderr, exitCode} object, and is skipIf(!isASAN).
  • Checked that this narrows (not widens) suppression scope vs. the pre-existing leak:Bun::generateModule entry it effectively replaces for this allocation.
Extended reasoning...

Overview

Three files: (1) src/bun_core/lib.rs adds __lsan_ignore_object to the existing ASAN extern block plus a thin ignore_object<T> wrapper; (2) src/runtime/node/node_fs_binding.rs calls it once on the freshly-boxed Binding in create_binding() before to_js_boxed hands the box to the GC wrapper; (3) test/js/web/workers/worker-terminate-lifetime.test.ts gains an ASAN-only minimal repro (require("node:fs") under detect_leaks=1).

Security risks

None. This is diagnostic tooling only — the new code is entirely #[cfg(bun_asan)]-gated and compiles to let _ = ptr in release builds. No user-facing behavior, no auth/crypto/permissions surface.

Level of scrutiny

Moderate, because REVIEW.md flags "never disable sanitizers or weaken CI verification to get green." The PR clears that bar: the root-cause analysis shows this is a genuine LSan false positive (the box's only anchor is m_ctx in the JSC heap; bmalloc/libpas pages aren't LSan roots), demonstrates it's per-VM-singleton (8 workers → still exactly one report; workers free theirs via ~VMlastChanceToFinalizeBinding::finalize), and explains the trigger (Rust nightly-2026-07-20 added allocator frames pushing Bun::generateModule past malloc_context_size, so the existing suppression stopped matching). The fix is narrower than the suppression it replaces — one specific allocation vs. every module-init frame — and __lsan_ignore_object is the canonical API for exactly this case.

Other factors

  • The new helper matches neighboring code byte-for-byte in structure (safe fn extern, .cast(), cfg-gated no-op), so there's no novel FFI risk.
  • The author considered and documented the alternative (suppress Bun::JS2Native::jsDollarLazy) and chose the more targeted fix.
  • Other to_js_boxed call sites exist (SecureContext, filesystem_router, output_file_jsc) but aren't touched — reasonable, since only this one is a confirmed process-lifetime singleton currently tripping LSan; widening would be speculative.
  • Verification listed in the PR body covers the failing test, the new repro (fail-before/pass-after via git stash -- src/), the worker-teardown-still-frees case, and fs.test.ts.
  • My earlier inline nit (new test inserted mid-sentence into the c-ares regression comment) was fixed in ed71d53 — the current diff shows the c-ares comment block contiguous above its own test, and the thread is resolved.

robobun and others added 3 commits July 22, 2026 17:06
…t to node/fs

- ignore_object<T>(&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.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the inline timeout nit is non-blocking. Prior feedback (split c-ares comment, comment length) is addressed; the test now lives in fs-oom.test.ts and worker-terminate-lifetime.test.ts is out of the diff.

Extended reasoning...

Overview

Adds bun_core::asan::ignore_object<T>(&T) wrapping __lsan_ignore_object, calls it once in node_fs_binding.rs::create_binding on the Box<Binding> before ownership transfers to the GC wrapper, and adds an ASAN-only subprocess regression test in test/js/node/fs/fs-oom.test.ts. ~13 lines of Rust + ~22 lines of test.

Security risks

None. The helper is #[cfg(bun_asan)]-gated and compiles to let _ = r; in release builds; it only affects LeakSanitizer's leak-report bookkeeping in debug/ASAN, with no effect on allocation, freeing, or runtime behavior.

Level of scrutiny

Low. The new helper is a byte-for-byte sibling of the existing register_root_region / unregister_root_region wrappers directly above it (same extern block, same #[cfg] shape, same #[inline]). At the call site, module: Box<Binding> deref-coerces to &Binding, so ptr::from_ref yields the heap address of the boxed Binding — the correct argument for __lsan_ignore_object. The call is placed before to_js_boxed, while the box is still Rust-owned. The root-cause analysis (JSC-heap-anchored singleton invisible to LSan; suppression stopped matching after allocator frames pushed Bun::generateModule past malloc_context_size) is coherent and the fix is narrower than a blanket suppression.

Other factors

  • All prior inline threads are resolved: my earlier nit about the split c-ares comment was fixed in ed71d53, and the test was subsequently moved to fs-oom.test.ts (7d3fc82); CodeRabbit's comment-length nit was addressed in 62c5a14.
  • The new test follows harness conventions: bunEnv spread, .skipIf(!isASAN), concurrent pipe drain, combined {stdout, stderr, exitCode} assertion, points at the checked-in test/leaksan.supp.
  • CI failures (test-net-connect-memleak.js, test-gc-http-client-connaborted.js) are release-build GC-timing flakes unrelated to an ASAN-cfg-only change; the author already cross-referenced #33044.
  • The one remaining inline finding (60s per-test timeout) is a style nit with no runtime impact and doesn't warrant blocking.

Comment thread test/js/node/fs/fs-oom.test.ts Outdated
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.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — my two earlier nits are resolved (test moved to fs-oom.test.ts so the c-ares comment is untouched; the 30s timeout is justified with measured evidence and a comment).

What was reviewed:

  • ignore_object::<Binding>(&module) — turbofish forces deref-coercion so __lsan_ignore_object gets the heap pointer, not the stack Box slot.
  • Non-ASAN builds: the new call compiles to let _ = r;, so no release-build behavior change.
  • New extern + wrapper match the sibling __lsan_register_root_region pattern in the same module.
  • Test follows harness conventions: bunEnv spread, both pipes drained concurrently, combined {stdout, stderr, exitCode} assertion, skipIf(!isASAN).
Extended reasoning...

Overview

Three-file change: adds __lsan_ignore_object to the existing bun_core::asan extern block plus a thin ignore_object<T>(&T) wrapper (src/bun_core/lib.rs), calls it once on the freshly-boxed Binding in create_binding before ownership transfers to the GC wrapper (src/runtime/node/node_fs_binding.rs), and adds an ASAN-only subprocess regression test (test/js/node/fs/fs-oom.test.ts). The root cause — a per-VM singleton whose only anchor lives in JSC-heap pages that LSan doesn't scan, previously masked by a suppression that stopped matching after a Rust bump added allocator frames — is well-argued in the PR body and matches the code.

Security risks

None. The only runtime effect is a call into the LSan interface under #[cfg(bun_asan)]; release builds see let _ = r;. No user-facing surface, no data handling, no auth/crypto.

Level of scrutiny

Low. This is sanitizer-tooling plumbing gated behind cfg(bun_asan), following the exact pattern of the four sibling helpers in the same module (poison, unpoison, register_root_region, unregister_root_region). The one subtle point — that &module on a Box<Binding> must deref-coerce to &Binding so the heap address (not the stack-local Box) reaches LSan — is handled by the explicit ::<Binding> turbofish, and the PR body explains the &T signature was chosen deliberately to keep ptr::from_ref at the definition and avoid the workspace-denied clippy::borrow_as_ptr at call sites.

Other factors

I reviewed this PR twice previously. Both nits are resolved: (1) the split c-ares comment — the test was subsequently moved out of worker-terminate-lifetime.test.ts entirely into fs-oom.test.ts per module-organization convention, so that file is no longer in the diff; (2) the per-test timeout — the author demonstrated the 5s default fails (LSan's exit-time reachability scan alone takes ~6s under debug+ASAN), reduced 60s→30s, and added a comment naming why no shorter bound works, satisfying REVIEW.md's "a literal sleep/timeout … needs a comment naming why". The CodeRabbit comment-length nit was also addressed. CI is green on the touched paths per the author's summary; the one unrelated flake is a known release-build GC-timing race and this change is cfg-gated out of release builds.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant