Skip to content

lsan: stop suppressing the frames user code runs under; fix the leaks that hid behind them - #38385

Open
robobun wants to merge 1 commit into
mainfrom
farm/f7150c33/leaksan-module-eval-suppressions
Open

lsan: stop suppressing the frames user code runs under; fix the leaks that hid behind them#38385
robobun wants to merge 1 commit into
mainfrom
farm/f7150c33/leaksan-module-eval-suppressions

Conversation

@robobun

@robobun robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • test/leaksan.supp had leak:Bun::evaluateCommonJSModuleOnce, leak:JSC::JSModuleLoader::evaluateNonVirtual and leak:JSC__JSModuleLoader__loadAndEvaluateModule, all from the original bulk list in ci: instrument being able to run leaksanitizer #21142, undocumented.
  • An LSan suppression matches when it is a substring of any recorded frame of the allocation stack, and the first two functions are on the stack for as long as a module's top level runs. So every leak made by code at the top level of a -e script, a CJS or ESM entry, a require()d or imported module, a worker eval script or a test file was silently suppressed, and most fixtures start their work at top level. Found while fixing Bun.write: carry the promise as the WriteFile job's JS side so a stopped worker releases it #38332: a Bun.write() leak at a worker's top level went unreported, and moving the same call into setImmediate made LSan report it. worker: free the thread's event name table when the worker thread exits #38164 ran into the same thing independently.
  • leak:Zig__GlobalObject__create had the same effect on the main thread for everything that runs from a microtask or process.nextTick: the microtask-tick hook installed in Zig__GlobalObject__create was a lambda, so its symbol is Zig__GlobalObject__create::$_0::operator(), and that frame is on the stack of ESM evaluation, await continuations and nextTick callbacks (found by the new test; LSAN_OPTIONS=print_suppressions=1 attributes such leaks to that entry).
  • Running every LSan-validated test file with the entries removed turned up what they were hiding (table under details). Leaks in the tree today, each attributed to one of the removed entries with the old file:
    • process.on("memoryPressure", ...) left registered at exit leaks the MemoryPressureWatcher box (memory_pressure.rs): the RareData slot is an erased pointer that RareData's drop does not free, and nothing disarms the watcher at teardown.
    • crypto.Certificate.exportChallenge() leaks the buffer ASN1_STRING_to_UTF8 allocates on every call: ncrypto::ExportChallenge returned it as an unowned Buffer<char> and jsCertExportChallenge copied it without freeing it (test-crypto-certificate.js).
    • process.dlopen() builds a file:// name for each napi env (BunProcess.cpp, toFileURI) that only ~NapiEnv frees. A napi_ref the addon never deletes (the usual constructor reference, or a napi_wrap result) holds a Ref to the env, so ~NapiEnv never runs and the name leaks for every such addon (9 napi test files, vitest's addon, 30205.test.ts).
    • Bun.connect() to a host with several addresses allocates a us_connecting_socket_t; if its resolve completion is already queued on the loop when the VM is torn down, us_connecting_socket_close cannot cancel it and leaves the struct to us_internal_socket_after_resolve on the next tick, which never comes (worker_destruction.test.ts, 50 of 50 workers).

Fix

  • Remove the three entries; add a note at the top of the file on why frames that enclose user code must not be listed.
  • Zig__GlobalObject__create installs checkIfNextTickWasCalledDuringMicrotask, the named function a few lines up whose body was identical to the lambda, so the hook's frame no longer carries the creation function's name and leak:Zig__GlobalObject__create is back to covering only what creating a global allocates.
  • memoryPressure: stop_active_handles_for_vm_teardown also disarms the watcher. That hook runs in the teardown stop phase, while RareData.file_polls and the loop are alive, which uninstall needs to unregister the poll (and on Windows to join its thread); the dns channel and the other per-VM handles are stopped at the same point. It reports Stopped only when it found a watcher, so the sweep's second pass is still idle. uninstall takes the VM so the listener-removal path and teardown share it.
  • exportChallenge: ExportChallenge returns an owning DataPointer, what upstream ncrypto returns; it frees with OPENSSL_clear_free, matching the OPENSSL_malloc inside ASN1_STRING_to_UTF8, and the binding's copy is the only thing that outlives the call. An empty challenge's 1-byte buffer, which the old len == 0 return dropped too, is freed the same way.
  • napi: NapiEnv::cleanup() frees filename at its end. Same situation and same place as the m_cleanupHooks release already in that function (~NapiEnv may never run); nothing uses the env after cleanup, and node_api_get_module_file_name already answers "" for a null name. ~NapiEnv still frees it for envs that do die.
  • connect: Loop::drain_closed_sockets, which teardown already calls after closing every group (stop phase) and again after the JSC VM is gone, runs us_internal_handle_dns_results first. Every connecting socket still on that queue has been closed by then, so after_resolve only releases the cache request and moves it to closed_connecting_head, which the existing drain frees; nothing can start a connection from there. The same race on the main thread was only invisible because the never-freed loop still pointed at the struct.
  • 26249.test.ts's fixtures close their cc() library: an unclosed library stays loaded by design (FFI::finalize), and cc.test.ts is already excluded from LSan for that reason (bun:ffi: free the FFI bookkeeping on GC when close() was not called #36070 would change this).
  • broadcast-channel-worker-gc.test.ts and 30205.test.ts go into test/no-validate-leaksan.txt with their reasons: the first reports the per-worker EventNames table (worker: free the thread's event name table when the worker thread exits #38164) and a ShadowRealm ConsoleObject leak, the second a napi env that bun test --parallel workers never clean up; both reported separately. A suppression would not do here: the allocation frames involved (toFileURI, us_socket_group_connect, ...) are the same ones the fixes above are tested through.
  • lsanIntentionalLeak() in bun:internal-for-testing (ASAN builds only) leaks one malloc'd block attributed to jsFunction_lsanIntentionalLeak, so the suppressions file can be tested against a known leak.
  • Why dropping the entries is right: they are not allocation sites, they are the frames all user code runs under, and what module loading itself allocates already has its own entries (Zig::SourceProvider::create, Bun__transpileFile, Zig::GlobalObject::moduleLoaderResolve, Zig::ImportMetaObject::*, the JSC::Parser entries). JSC evaluates the entry module from internal microtasks, so JSC__JSModuleLoader__loadAndEvaluateModule only ever covered the synchronous resolve and fetch of the entry; the survey found nothing attributed to it, and it names the same kind of frame as the other two.
  • Verified (debug ASAN build):
    • test/internal/leaksan-suppressions.test.ts (new): a leak from a -e script, CJS entry, ESM entry, require()d module, imported module, worker eval script, top level of a bun test file, a nextTick callback, an await continuation, and a setImmediate callback as control, each of which must be reported with test/leaksan.supp applied. With the three entries put back the seven module rows fail; before the hook change the two main-thread ESM rows failed with the trimmed file as well.
    • process-memory-pressure.test.ts, node-crypto.test.js, napi-env-cleanup-leak.test.ts (new file, so it does not depend on the rest of napi.test.ts, two of whose cases time out locally), worker-shutdown-post-leak.test.ts: one leak-checked case each, using the fixture the survey found or a reduction of it (the connect case seeds the DNS cache with two addresses through dnsCacheSeed, so the queued completion is deterministic). With src/ stashed and rebuilt each of these fails with the leak named above; with the fix all pass, as do the whole files.
    • The files from the survey that were affected (test-crypto-certificate.js, worker_destruction.test.ts, the napi files, vitest.test.ts, 26249.test.ts) run clean with the final build and file.
    • cargo check --target x86_64-pc-windows-msvc for bun_runtime and bun_uws_sys (the memoryPressure and loop changes have Windows halves).
  • worker-shutdown-post-leak.test.ts's existing case gets the same 90 s timeout as the new one (it runs in 4 to 5 s here against a 5 s default); test: give worker-shutdown-post-leak an explicit 90s timeout #38157 and worker: free the thread's event name table when the worker thread exits #38164 make the same edit, whichever lands second has a one-line conflict.

Background

  • LeakSanitizer runs at process exit and reports heap blocks nothing points to any more, each with the stack recorded when it was allocated. CI runs the ASAN build with detect_leaks=1 and LSAN_OPTIONS=suppressions=test/leaksan.supp (scripts/runner.node.mjs), and bunEnv passes those variables on, so every bun process a test spawns is checked too; a report fails the test. BUN_DESTRUCT_VM_ON_EXIT=1, also set by the runner, makes exit run the VM teardown so that what the VM owns is freed rather than reported.
  • A leak:pattern line suppresses a leak if pattern is a substring of any recorded frame's symbol; malloc_context_size=30 bounds how many frames are recorded. The release build records more meaningful frames per stack than the debug build (less wrapper depth), so anything the debug build attributes to one of these entries is at least as hidden in CI.
  • Bun::evaluateCommonJSModuleOnce calls a CommonJS module's wrapper function (also the -e and worker eval path); JSC::JSModuleLoader::evaluateNonVirtual runs an ES module graph's bodies; the on-each-microtask-tick hook is a VM callback JSC invokes between microtasks, which Bun uses to drain the nextTick queue, so on the main thread the microtask queue (module evaluation included) is often drained from inside it.
  • Teardown stop phase (VirtualMachine::stop_phase_sweep): before the JSC VM is destroyed, every native handle is stopped, repeating until a sweep finds nothing; RareData is the per-VM bag of lazily created subsystems, holding the ones whose types live in higher crates as erased pointers that the owning crate must free.
  • TZone / libpas memory: objects like NapiEnv and NapiRef are allocated by bmalloc's typed heaps, which LSan neither reports nor scans in this build, so a libc allocation owned only by such an object shows up as a direct leak, and a Malloc=1 test makes all of that memory visible (the two excluded tests use it).
Survey: what the trimmed file reports across the LSan-validated test set

Method: the 5603 files scripts/runner.node.mjs would validate (everything minus test/no-validate-leaksan.txt) were run with the debug ASAN build, BUN_DESTRUCT_VM_ON_EXIT=1, detect_leaks=1, malloc_context_size=30 as in CI, and LSAN_OPTIONS=log_path=...:exitcode=0, collecting every report from the test process and all children without changing test outcomes. 29 files hit the survey's 3 minute limit and were not leak-checked to completion. Each of the 23 files that produced a report was then re-run with the old file and print_suppressions=1 to see which entry, if any, had been hiding it. This was done before the hook change; what that additionally exposes is being checked by CI on this PR.

file reported with the old file
process-memory-pressure.test.ts watcher box, 8 B, two fixtures Bun::evaluateCommonJSModuleOnce (fixed)
node/test/parallel/test-crypto-certificate.js ExportChallenge buffer, 2 x 20 B Bun::evaluateCommonJSModuleOnce (fixed)
7 node-napi-tests files, napi.test.ts (4 children), vitest.test.ts, 30205.test.ts toFileURI name, ~100 B per env evaluateCommonJSModuleOnce / evaluateNonVirtual (fixed; 30205's --parallel children additionally skip env cleanup, excluded)
worker_threads/worker_destruction.test.ts us_connecting_socket_t, 104 B x 50 evaluateNonVirtual (fixed)
regression/issue/26249.test.ts FFI struct + symbol tables of an unclosed cc() library evaluateNonVirtual (fixtures close the library)
broadcast-channel-worker-gc.test.ts 174 blocks under Malloc=1: 24 EventNames tables, ShadowRealm ConsoleObjects and their atoms evaluateNonVirtual (#38164 + handed off; excluded meanwhile)
macro-test.test.ts node:fs Binding box in the bun build macro VM (#35159) Bun::generateModule; identical with both files at this stack depth
cli/inspect/test-reporter.test.ts Bun.stderr blob of the inspector's own global built-in Debugger::start_js_debugger_thread entry; identical with both files
bun/plugin/plugins.test.ts the path PluginRunner::on_resolve leaks on purpose Bun__transpileFile; identical
test-no-addons-resolution-condition.js ModuleBufs scratch of exited threads Bun__resolveSync; identical
regression/issue/26851.test.ts test-runner Expect / ScopeFunctions / ParsedSourceMap objects reported identically by both files (pre-existing, cf. #32180 / #38208)
cli/hot/watch-many-dirs.test.ts a heap-use-after-free in the file watcher thread during teardown (handed off) and what ASAN dying mid-teardown leaves behind unrelated to suppressions
create-jsx.test.ts, spawn-pipe-leak.test.ts bundler thread-pool / FilePoll blocks of processes the survey killed on timeout no module frame on those stacks; unrelated

Entries that still sit above some user code, left alone because nothing in the suite needed them removed: Bun::generateModule (builtin module bodies), Bun__transpileFile (macros and onLoad plugins run under it), JSC::arrayProtoFuncSort / stringProtoFuncReplaceUsingRegExp / jsonProtoFuncStringify (user callbacks), jsHTTPParser_execute. JSC::moduleLoaderParseModule and JSC::moduleLoaderModuleDeclarationInstantiation no longer exist in the vendored JSC. Many Zig-era entries (runtime.*, jsc.*) no longer match any symbol either.

… that hid behind them

An LSan suppression matches any frame of the allocation stack, so
leak:Bun::evaluateCommonJSModuleOnce and
leak:JSC::JSModuleLoader::evaluateNonVirtual in test/leaksan.supp hid
every leak made by code running at a module's top level (-e scripts,
entries, require()d and imported modules, worker eval scripts, test
files), which is where most fixtures start their work.
JSC__JSModuleLoader__loadAndEvaluateModule names the same kind of frame.
Remove all three. The microtask-tick hook installed by
Zig__GlobalObject__create was a lambda, so its symbol carried that name
and leak:Zig__GlobalObject__create hid everything that ran from a
nextTick or promise continuation on the main thread too; install the
identical named function that already existed instead.

Running every LSan-validated test file with the trimmed file surfaced
what those entries were hiding:

- process.on("memoryPressure") left registered at exit leaked the
  watcher box: its RareData slot is an erased pointer and nothing
  disarmed it at teardown. Disarm it in the teardown stop phase.
- crypto.Certificate.exportChallenge() leaked the buffer returned by
  ASN1_STRING_to_UTF8 on every call. ncrypto::ExportChallenge returns an
  owning DataPointer, as upstream ncrypto does.
- process.dlopen()'s file name for a napi env that an undeleted napi_ref
  keeps alive forever was never freed, since ~NapiEnv never runs. Free it
  at the end of the env's cleanup, like its hook set.
- A Bun.connect() whose resolve result was already queued when its VM
  went away leaked the us_connecting_socket_t: closing it could not
  cancel the queued completion, and the tick that would have run it never
  came. The teardown drain now runs the dns-ready queue first.
- test/regression/issue/26249.test.ts's fixtures never close()d their
  cc() library, which stays loaded by design; close them.
- broadcast-channel-worker-gc.test.ts (EventNames per worker, #38164, and
  ShadowRealm console objects) and 30205.test.ts (--parallel workers skip
  napi env cleanup) report leaks owned elsewhere; listed in
  no-validate-leaksan.txt with the reason.

bun:internal-for-testing gains lsanIntentionalLeak() (ASAN builds only)
so test/internal/leaksan-suppressions.test.ts can check that a leak made
from each of those contexts is reported with test/leaksan.supp applied.
@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 31 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6283f852-b63b-4269-a910-72bec42def31

📥 Commits

Reviewing files that changed from the base of the PR and between 6855f52 and 3e95858.

📒 Files selected for processing (20)
  • src/js/internal-for-testing.ts
  • src/jsc/VirtualMachine.rs
  • src/jsc/bindings/InternalForTesting.cpp
  • src/jsc/bindings/InternalForTesting.h
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/jsc/bindings/napi.h
  • src/jsc/bindings/ncrypto.cpp
  • src/jsc/bindings/ncrypto.h
  • src/jsc/bindings/node/crypto/node_crypto_binding.cpp
  • src/runtime/jsc_hooks.rs
  • src/runtime/node/memory_pressure.rs
  • src/uws_sys/Loop.rs
  • test/internal/leaksan-suppressions.test.ts
  • test/js/node/crypto/node-crypto.test.js
  • test/js/node/process/process-memory-pressure.test.ts
  • test/js/node/worker_threads/worker-shutdown-post-leak.test.ts
  • test/leaksan.supp
  • test/napi/napi-env-cleanup-leak.test.ts
  • test/no-validate-leaksan.txt
  • test/regression/issue/26249.test.ts

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

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced and fixed; waiting on CI.

Reproduced on a debug ASAN build of main (e697804) with the CI leak settings: a worker calling Bun.write() at its top level (the unfixed #38332 leak) exits clean with Suppressions used: Bun::evaluateCommonJSModuleOnce, and the same call from setImmediate is reported. The new test/internal/leaksan-suppressions.test.ts fails 7 of its 10 rows with the old test/leaksan.supp, and the two main-thread ESM rows also failed against the old microtask hook; the four leak fixes each have a case that fails with src/ stashed and rebuilt.

The survey of the full LSan-validated test set that drove the list of fixes is in the PR description.

@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.

I reviewed this PR and didn't find any bugs. Because it removes broad LSan suppressions and fixes four separate leaks across native teardown paths (napi env cleanup, uws DNS drain, memory-pressure watcher, ncrypto), a human look would still be worthwhile.

What was reviewed:

  • Verified checkIfNextTickWasCalledDuringMicrotask matches the removed lambda body and is already used by resetOnEachMicrotaskTick.
  • Verified us_internal_socket_after_resolve short-circuits on c->closed and that us_socket_group_close_all_ex closes every connecting socket before drain_closed_sockets runs, so the new DNS drain frees rather than reconnects.
  • Verified DataPointer::~DataPointer uses OPENSSL_clear_free, matching ASN1_STRING_to_UTF8's allocator; the size-0 case still frees on scope exit.
  • Verified SweepResult::and semantics and that stop_for_vm_teardown avoids allocating RareData when the watcher was never armed.
Extended reasoning...

Overview

This PR removes three overly-broad LSan suppression entries (Bun::evaluateCommonJSModuleOnce, JSC::JSModuleLoader::evaluateNonVirtual, JSC__JSModuleLoader__loadAndEvaluateModule) that sat on the call stack of all top-level user code and were therefore hiding every leak fixtures produced. It also swaps a lambda in Zig__GlobalObject__create for the pre-existing named checkIfNextTickWasCalledDuringMicrotask so that the retained leak:Zig__GlobalObject__create entry stops matching microtask/nextTick frames. To make removal viable, it fixes the four real leaks the survey uncovered: the MemoryPressureWatcher box at VM teardown, the ASN1_STRING_to_UTF8 buffer in exportChallenge, the napi env filename when ~NapiEnv never runs, and the us_connecting_socket_t whose queued DNS completion outlives the loop's last tick. It adds an lsanIntentionalLeak() test hook, a suppressions regression test, and per-fix leak tests, plus two no-validate-leaksan.txt entries with tracked reasons.

Security risks

None identified. The crypto change is a mechanical ownership fix (unowned Buffer<char> → owning DataPointer) that matches upstream ncrypto and preserves the copy into a JSC ArrayBuffer. No auth, permissions, or input-validation surface changes.

Level of scrutiny

High. Per the repo's own review rules, native memory safety is the most-blocked category, and this touches it in four independent subsystems plus VM teardown ordering. Each fix individually looks correct on inspection: the ncrypto change matches upstream and DataPointer's destructor uses the right free; NapiEnv::cleanup() freeing filename mirrors the existing m_cleanupHooks release and ~NapiEnv still handles it via std::exchange; us_internal_handle_dns_results at teardown is safe because close_all_socket_groups has already set c->closed on every connecting socket, so after_resolve takes the free-only branch; the memory-pressure teardown reads the raw rare_data option to avoid lazy allocation and reuses the existing uninstall. The ZigGlobalObject lambda→named-function swap is behaviorally identical. But the aggregate is 20 files spanning C++, Rust, and CI configuration, and the correctness of the uws change depends on a teardown ordering invariant that a maintainer familiar with stop_phase_sweep should confirm.

Other factors

The PR description is unusually thorough — it documents a full-suite survey, per-fix fails-without/passes-with verification, and Windows cross-check. Tests follow the harness conventions (subprocess + LSan env, skipIf(!isASAN), concurrent drain of stdout/stderr/exited, per-test 90s timeout with a stated reason). The two new no-validate-leaksan.txt entries are documented with issue references rather than silently suppressed. Nothing blocks; the deferral is purely because the scope and subsystem breadth exceed what an automated approval should cover.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 4:40 AM PT - Aug 14th, 2026

@robobun, your commit 3e95858 has 6 failures in Build #95568 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 38385

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

bun-38385 --bun

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