Fix effectively every native-code memory leak in Bun
Summary
Bun's native code is now effectively leak-free: ASAN CI runs the entire test
suite under LeakSanitizer with a curated suppression list, and every leak it
reported has been fixed. Getting here took two steps — first making the leak
detector able to see Bun's allocations at all, then fixing everything it found.
Why LeakSanitizer was blind before
The Zig build had a comptime switch (bun.use_mimalloc) selecting between the
mimalloc-backed default allocator and std.heap.c_allocator (libc malloc).
ASAN builds set it to false, so the default allocator routed through plain
malloc/free — which ASAN's interceptor wraps directly with redzones, a free
quarantine, and heap-origin tracking. The Rust port flattened this to an
unconditional #[global_allocator] static ALLOC: bun_alloc::Mimalloc, so ASAN
builds ran mimalloc with MI_TRACK_ASAN=1 shadow annotations instead of ASAN's
own interceptor allocator.
MI_TRACK_ASAN only annotates mimalloc's redzone / use-after-free shadow
state. It does not get the interceptor's free quarantine, full heap-origin
recording, or LeakSanitizer coverage — so most leaks were simply invisible.
This PR gates the #[global_allocator] on cfg(bun_asan) (set by
scripts/build/rust.ts for ASAN builds): non-ASAN builds keep
bun_alloc::Mimalloc; ASAN builds get std::alloc::System (libc
malloc/free on Unix, HeapAlloc on Windows). ASAN's interceptor and
LeakSanitizer now see every heap allocation.
MimallocArena — the parser/AST bump arena — is unaffected and stays on
mimalloc heaps in all configurations; MI_TRACK_ASAN/MI_UBSAN remain in the
mimalloc build config so those allocations are still annotated. USE_MIMALLOC
consumers (bun_alloc::USE_MIMALLOC, bun_core::USE_MIMALLOC) become
cfg!(not(bun_asan)) so the mi_collect/mi_option_set/mi_process_info
gates skip mimalloc-as-global-allocator hooks when it isn't the global
allocator, matching the original Zig gating. The src/jsc/bindgen.rs fallback
that relied on Vec::drop reaching mi_free via the global allocator now
calls mi_free directly, since the C++ side (ExternVectorTraits.h) always
allocates that buffer with mi_malloc.
What got fixed
With LSan finally able to see Bun's heap, it reported leaks across essentially
every native subsystem. They're fixed here — a non-exhaustive map (full detail
in the commit history):
- Bundler & linker — graph columns on every
generate_from_cliexit path,
chunk data, CSS rule slabs, sourcemap / line-offset tables, AST string
clones, parser arena-stranded heap fields,OutputFilesource paths - Parser / AST / CSS — arena-back the heap-owning CSS condition / selector
/ stylesheet nodes that were stranding allocations on arena reset; free TS
namespace maps on parser teardown - Package manager / install —
Taskrequest/data union arms on re-pool,
package-manifest cache (move instead of clone + leak), workspace
package.json cache, semver query chains, isolated-install Store columns - Dev server (bake) — transpilers, arena-stored chunks and framework
projections,StaticRouterefs held by route bundles - Runtime / webcore —
FetchTasklet,FileReader,ReadableStream
sources, subprocess / socket refcount imbalances, H2 frame parser callback
refs, DNS, S3 request headers,StaticPipeWriter - VM teardown — RareData stdio blob stores, transpiler heap fields, timer
draining, finalizers onprocess.exit()
Legitimate non-leaks
Some allocations live for the whole process and are reclaimed by the OS at
exit, or are owned by an arena, the JS GC, or thread-local storage — not leaks.
Those are scoped out via test/leaksan.supp and the per-test
no-validate-leaksan.txt list, each entry annotated with why it isn't a leak.
A thread-local PathBuffer scratch pool that genuinely was stranded per worker
thread is now freed on thread exit, and its suppression dropped.
Result
ASAN CI runs the full test suite with detect_leaks=1 and the curated
suppression list, and it passes. Every leak LeakSanitizer reports across the
suite is either fixed or a documented, intentional process-lifetime
allocation. Bun's native code is LeakSanitizer-clean, and it now stays that way
— any new leak fails CI.
Verification
cargo check --workspace clean; RUSTFLAGS="--cfg bun_asan" cargo check for
bun_alloc/bun_core/bun_jsc/bun_bin clean; bun run rust:check-all
10/10 targets pass; debug build links and runs.