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.

@robobun

robobun commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

Still reproduces on main as of 1f606e6: bun-debug -e 'require("node:fs")' with detect_leaks=1 and test/leaksan.supp reports the same 4104 byte Binding::new (node_fs_binding.rs:153) leak, no worker involved, and worker-terminate-lifetime.test.ts ("terminate() while dns.lookup() is in flight") is still red on the linux x64 debug ASAN lane because of it. The branch still merges cleanly against current main.

dylan-conway added a commit that referenced this pull request Aug 14, 2026
…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>
@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

Still reproduces on main at 8326d1b (debug+ASAN): bun-debug -e 'require("fs")' with detect_leaks=1 and test/leaksan.supp reports the same single 4104 byte Binding::new (node_fs_binding.rs:153, via create_binding at :427) object, and worker-terminate-lifetime.test.ts "terminate() while dns.lookup() is in flight" is red locally because of it.

One extra datapoint for the per-VM analysis: a program that starts 4 node:worker_threads Workers, each of which requires node:fs and touches process.stdout, still reports exactly one object after all workers exit, so the worker-side boxes are freed by Binding::finalize and the remaining report is the main thread's. Parent programs that only create a Worker trip it too, because new Worker() loads the fs binding on the main thread (internal/trace_events), which is why worker tests are where it shows up. The branch still merges cleanly against current main and the create_binding hunk context is unchanged.

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