node:fs: mark the per-VM Binding box as LSan-ignored (fixes worker-terminate-lifetime.test.ts on main) - #35159
node:fs: mark the per-VM Binding box as LSan-ignored (fixes worker-terminate-lifetime.test.ts on main)#35159robobun wants to merge 6 commits into
Conversation
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.
WalkthroughChangesThe 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 NodeFS LSAN leak handling
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Updated 2:25 PM PT - Jul 22nd, 2026
❌ @robobun, your commit cb30474 has 3 failures in
🧪 To try this PR locally: bunx bun-pr 35159That installs a local version of the PR into your bun-35159 --bun |
|
Reproduced by running Self-review surfaced a real CI (builds 77980/77996/78010/78031) is green on everything this diff touches, including |
There was a problem hiding this comment.
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 ~VM → lastChanceToFinalize → Binding::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_objectis 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.
There was a problem hiding this comment.
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
📒 Files selected for processing (3)
src/bun_core/lib.rssrc/runtime/node/node_fs_binding.rstest/js/web/workers/worker-terminate-lifetime.test.ts
There was a problem hiding this comment.
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 ofregister_root_regionimmediately above it (samesafe fnextern block, same#[cfg(bun_asan)]gating, same no-op fallback).&*module as *const Bindingyields the boxed data address before ownership transfers viato_js_boxed;__lsan_ignore_objectis 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 isskipIf(!isASAN). - Checked that this narrows (not widens) suppression scope vs. the pre-existing
leak:Bun::generateModuleentry 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 ~VM → lastChanceToFinalize → Binding::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 fnextern,.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_boxedcall 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, andfs.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.
…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.
There was a problem hiding this comment.
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:
bunEnvspread,.skipIf(!isASAN), concurrent pipe drain, combined{stdout, stderr, exitCode}assertion, points at the checked-intest/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.
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.
There was a problem hiding this comment.
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_objectgets the heap pointer, not the stackBoxslot.- 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_regionpattern in the same module. - Test follows harness conventions:
bunEnvspread, 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.
|
Still reproduces on main as of 1f606e6: |
…rocess.exit() / uncaught error) (#38483) ### Problem - In a `worker_threads` Worker (or Web `Worker`) whose loop is kept alive by something like a `parentPort` `'message'` listener, `process.exit()` called from a `setImmediate` callback (or from a `nextTick`/microtask that callback queued), and likewise an uncaught exception thrown from one, does not end the worker promptly. The parent's `'exit'` event arrives about 680ms later on 1.4.0 release (about 900ms on a debug build); the same exit from a `setTimeout` callback or a message listener takes ~3ms. - The delay is "until something unrelated wakes the worker's loop": by default that is the idle GC timer (1s period, 30s once it has backed off after 30 idle fires). With `BUN_GC_TIMER_DISABLE=1` the worker never exits at all; the parent process hangs. - Cause: the worker's two self-stop sites, `WebWorker::exit()` (`src/jsc/web_worker.rs:1088`, reached from `Bun__Process__exit`) and the uncaught-error hook `on_unhandled_rejection` (`src/jsc/web_worker.rs:1282`), set `requested_terminate`, stop the VM handle and arm the JSC termination trap, but do not wake the loop. A parent's `worker.terminate()` goes through `VmHandle::request_termination()` (`src/jsc/VmHandle.rs:387`), which does the same three things plus `wakeup()`. - Why only immediates: `spin()` (`web_worker.rs:933`) checks `requested_terminate` after `tick()` and after `auto_tick_active()`. Immediates run at the top of `auto_tick_active()` (`src/runtime/jsc_hooks.rs:1134`), which then computes the poll timeout from the timer heaps and blocks in `tick_with_timeout()`; the flag is only re-read once that poll returns. Timer and I/O callbacks run after the poll, so their exits are seen right away. With a keep-alive and an empty timer heap, `get_timeout()` asks for an unbounded wait. ### Fix - Both self-stop sites call `handle_ref().request_termination()`, the same request a parent's `terminate()` makes: stop the handle, arm the trap, wake the loop. The wake is a write to the loop's wakeup fd (eventfd / mach port / `uv_async_send`), so the poll that follows returns at once and `spin()` sees the flag. Nothing else about the stop changes: `stop()` and `notify_need_termination()` are what these sites already did, in the same order. - Correct because the wake goes through the mechanism the parent-side stop has always used (the worker's loop is already woken from another thread on `terminate()`); waking the loop from its own thread is the same eventfd write, and a wake that turns out to be unnecessary (no keep-alive, so the poll was non-blocking anyway) is harmless, exactly as it is today for a `terminate()` that lands while the worker is idle. - Debug build, `BUN_GC_TIMER_DISABLE=1`: exit from an immediate went from never exiting to ~165ms (the same as an exit from a timer callback, i.e. plain teardown cost); with the GC timer on, ~900ms to ~165ms. Release numbers in the details below. - Verified: - `test/js/node/worker_threads/worker_threads.test.ts`, new `describe` "a worker that stops itself from an immediate exits right away" (3 rows: `process.exit()`, `process.exit()` from a `nextTick` the immediate queued, an uncaught exception). Each runs with `BUN_GC_TIMER_DISABLE=1`, so without the `src/` change the worker never exits and all three rows time out (confirmed on a build without it); with it they pass. Whole file: 132 pass. - `test/js/web/workers/worker.test.ts`, `worker-terminate-lifetime.test.ts`, `worker-terminate-funnels.test.ts`, `worker-late-completion.test.ts`, `worker_destruction.test.ts`, `worker-top-level-await.test.ts`, `worker-async-dispose.test.ts`, `worker-shutdown-post-leak.test.ts`: the only failures are identical on a build without this change (the fs `Binding` LSan report that #35159 addresses, and 5s timeouts of tests that boot many workers on a debug/ASAN build). - 24 upstream `test-worker-*` exit / uncaught / terminate tests under `test/js/node/test/parallel`: all pass. - Not covered here: a promise rejected (not thrown) inside an immediate is still only reported after the next loop wakeup, on the main thread as well. That has a different cause (rejections are not examined between the immediate phase and the poll) and is tracked separately; #37981 is adjacent to it. - #38016 edits the same two sites for a different bug (discarding queued work after the stop); the two changes compose, whichever lands second just calls both. ### Background - Worker run loop: `WebWorker::spin()` loops `tick()` (runs queued tasks and microtasks) then `auto_tick_active()` (runs the immediates queued for this turn, computes how long the loop may sleep from the timer heaps, blocks in the uSockets/libuv poll, then runs due timers). It breaks out and tears the VM down when `requested_terminate` is set; that flag is checked between those two calls, never inside them. - `requested_terminate` and `VmHandle`: a worker is stopped by setting the `WebWorker`'s `requested_terminate` flag (what `spin()` polls) and making the VM stop running script: `VmHandle::stop()` closes the gate every native-to-JS entry consults, and `notify_need_termination()` arms JSC's trap so script already on the stack unwinds with a `TerminationException`. `VmHandle::request_termination()` bundles those two with a loop wakeup, and is what the parent thread's `terminate()` calls; this PR makes the worker's own exit paths call it too. - Loop wakeup: every uSockets loop has an async handle (an eventfd on Linux, a mach port on macOS, a `uv_async_t` on Windows) registered in its poll set. `wakeup()` signals it, so a blocked poll returns immediately and a poll entered afterwards returns without blocking. It works the same whether signalled from another thread or from the loop's own thread before it polls. - Idle GC timer (`GarbageCollectionController`): a per-VM repeating timer that runs a GC every 1s (30s after the heap has been stable for 30 fires); `BUN_GC_TIMER_DISABLE=1` turns it off. It is normally what bounded this bug at about a second, which is why the new tests disable it: without it a worker stuck in the poll stays stuck, so the tests hang rather than pass slowly on a build without the fix. <details> <summary>Measurements (time from the worker's postMessage right before the stop to the parent's 'exit' event)</summary> Release 1.4.0, linux x64, worker kept alive by a `parentPort` listener, before this change: | shape | default | `BUN_GC_TIMER_DISABLE=1` | |---|---|---| | `process.exit()` from an immediate scheduled 300ms in | 679ms | never exits | | `throw` from that immediate | 679ms (code 1) | never exits | | `process.exit()` from a nextTick queued by that immediate | 680ms | never exits | | `process.exit()` from a microtask queued by that immediate | 682ms | never exits | | `process.exit()` from a `setTimeout` callback | 4ms | 3ms | | `throw` from a `setTimeout` callback | 4ms | 5ms | | `process.exit()` from a `parentPort` message listener | 4ms | 4ms | | immediate scheduled synchronously at startup | ~104ms | ~103ms (a JSC startup timer happens to fire then) | Scanning the immediate's scheduling delay with the GC timer disabled (release): 0ms to 100ms after startup the exit piggybacks on a one-shot startup timer (106ms, 79ms, 54ms, 32ms, 4ms), from 150ms on it never exits. The debug build behaves the same (last startup timer at about 150ms), which is what the 300ms in the new tests is sized against. Debug build after this change, all immediate shapes: 162ms to 195ms, the same as the timer shapes (161ms to 168ms) on that build. The `Promise.reject` shape is unchanged (910ms / never), see the last Fix bullet. </details> --------- Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
|
Still reproduces on main at 8326d1b (debug+ASAN): One extra datapoint for the per-VM analysis: a program that starts 4 |
What
test/js/web/workers/worker-terminate-lifetime.test.ts"terminate() while dns.lookup() is in flight" is red on main:The report is the
Box<Binding>thatcreate_binding()hands to theJSNodeJSFSGC wrapper wheninternal/fs/binding.tsfirst 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 thatrequire("node:fs")still report exactly one "leak"), and the main thread keeps it for process lifetime because it does not destroy its JSC VM. Afterto_js_boxedthe only pointer to the box lives in the wrapper'sm_ctxslot, 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::generateModuleintest/leaksan.supp. Atmalloc_context_size=60that frame sits at depth 31. Thenightly-2026-07-20Rust bump in #34782 added allocator-internal frames (frames 1-10 of the report are all libstd alloc plumbing) and pushedBun::generateModuleone past the default 30-framemalloc_context_size, so the suppression stopped matching.Fix
Call
__lsan_ignore_objecton 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. Addsbun_core::asan::ignore_object<T>(&T)alongside the existingregister_root_regionhelpers; taking&T(rather than*const T) keeps theptr::from_refat the definition so callers do not each trip the workspace-deniedclippy::borrow_as_ptrlint.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
test/js/node/fs/fs-oom.test.tsis 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.node:fsthen 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