Free the parked Bun.TOML/YAML/JSON5/JSONC/XML.parse arena when a Worker exits - #38999
Free the parked Bun.TOML/YAML/JSON5/JSONC/XML.parse arena when a Worker exits#38999robobun wants to merge 1 commit into
Conversation
…er exits
with_text_format_source_encoded kept the recycled mi_heap in a
#[thread_local], assuming mimalloc destroys heaps with their thread. Our
mimalloc fork's heaps are not thread-bound: thread exit only abandons the
theap pages, so every Worker that parsed a document leaked one mi_heap_t
plus up to 2 MiB of retained pages.
Move the slot onto RuntimeState, which is per JS thread and dropped by
deinit_runtime_state during VM teardown, so the heap dies with the Worker.
Main-thread behaviour is unchanged: one heap is parked and reused across
calls, and a re-entrant call still gets its own arena.
Add a worker_destruction test that counts live mimalloc heaps via
heapStats({ dump: true }) across Worker lifecycles, and correct the
ast_memory_allocator comment the thread-local design was copied from.
|
Warning Review limit reached
Next review available in: 29 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (5)
Comment |
|
Status: ready for review; the diff is green on every lane that ran. Reproduced on canary The new test ( CI (build 97928): 177 of 179 jobs passed, including |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it touches per-VM allocator lifecycle and adds an unsafe accessor into RuntimeState, a human look would still be worthwhile.
What was reviewed:
text_format_arena_slot()follows the same raw-pointer pattern asglobal_dns_data();deinit_runtime_statenullsRUNTIME_STATEbefore dropping the box, so the SAFETY comment holds.- Drop order in
with_text_format_source_encoded:_ast_scope→ast_memory_allocator→recycle, so the arena is unborrowed whenRecycle::dropparks it. Recycle::dropre-fetches the slot rather than holding the entry-time reference; if VM state is gone the arena is dropped (mi_heap_destroy).- Test asserts an exact live-heap count via
mimallocDump.heaps.lengthafter a warm-up worker, not an RSS threshold.
Extended reasoning...
Overview
This PR fixes a per-Worker leak of one mi_heap_t (plus up to 2 MiB of retained pages) introduced by #37146. The parked arena for Bun.{TOML,YAML,JSON5,JSONC,XML}.parse moves from a #[thread_local] static in src/runtime/api.rs into a new RuntimeState::text_format_arena field in src/runtime/jsc_hooks.rs, so it is dropped by deinit_runtime_state on Worker teardown. A new accessor text_format_arena_slot() exposes the Cell slot; the Recycle guard in api.rs takes/parks through it. The comment in ast_memory_allocator.rs is corrected to describe mimalloc's actual behavior (heaps are not destroyed on thread exit) and why TLS parking is still acceptable there. A new fixture and test assert that 3 Workers exercising all five parsers plus Bun.Transpiler and a data: import leave the process-wide live-heap count unchanged.
Security risks
None. This is internal allocator-lifecycle plumbing with no user-facing input handling, auth, or crypto.
Level of scrutiny
Moderate-to-high. The change is small (~45 net LoC in src/) and mirrors the established transpiler_arena pattern in the same struct, but it lives in the "most-blocked category" per REVIEW.md: native memory safety, per-VM state ownership, and an unsafe block returning &'static to a field of a heap box. I traced the invariants: deinit_runtime_state (jsc_hooks.rs:693) nulls RUNTIME_STATE before heap::take drops the box, so text_format_arena_slot()'s null-check guarantees the pointer is live — matching the SAFETY comment and the neighboring global_dns_data() accessor. Drop order in the caller is correct (the ASTMemoryAllocator::borrowing and its Scope drop before recycle, releasing the borrow of arena before it is reset/parked). The re-entrancy story (argument toString() calling parse again) is unchanged from the previous Cell::take behavior and is documented. Re-fetching the slot in Recycle::drop rather than capturing it means a dangling reference is impossible even if VM teardown somehow ran between entry and exit.
Other factors
The test is deterministic (exact heap count via heapStats({ dump: true }).mimallocDump.heaps.length, not an RSS delta), runs a warm-up Worker to establish a baseline, wires error to reject, and follows the harness conventions (bunEnv, bunExe, concurrent pipe drain, exitCode asserted last). The PR description states it fails on the unfixed binary with leaked: 3. The fixture also exercises Bun.Transpiler and data: import to guard the sibling per-VM heaps in the same test. The corrected ast_memory_allocator.rs comment is accurate and points future readers at the right pattern for short-lived threads.
I found nothing wrong. Deferring only because per-VM allocator lifecycle with new unsafe is exactly the category REVIEW.md flags for careful eyes, not because of any specific concern.
|
Updated 10:05 AM PT - Aug 15th, 2026
❌ @robobun, your commit 0c25551 has some failures in 🧪 To try this PR locally: bunx bun-pr 38999That installs a local version of the PR into your bun-38999 --bun |
Problem
Bun.TOML.parse,Bun.YAML.parse,Bun.JSON5.parse,Bun.JSONC.parseorBun.XML.parseleaks one mimalloc heap (mi_heap_t) plus whatever pages it still holds (up to 2 MiB of retained blocks) when the Worker exits. 20 Workers each parsing a 2000-table TOML document: 20 heaps left behind, RSS +55 MB vs +14 MB for 20 Workers that parse nothing (debug build; release canaryeabb96de7shows the same 20 heaps, RSS +40 MB vs +2 MB).with_text_format_source_encoded(src/runtime/api.rs:272) parks the recycled arena in a#[thread_local]and relies on "a parked heap is reclaimed with the thread". That is not how our mimalloc fork behaves: heaps are first-class and not bound to a thread, so thread exit (mi_thread_theaps_donein mimalloc'sinit.c) only abandons the thread's theap pages and never destroys heaps created on it. Themi_heap_t, its TLS key slot, and every page still holding a retained block outlive the Worker.Arena::new()with the per-thread cache.ast_memory_allocator.rs, which that comment cites as precedent, makes the same claim; it is harmless there only because the threads parking into it (bundler/install pool threads, the bundle thread) live as long as the process.Fix
RuntimeState::text_format_arena(src/runtime/jsc_hooks.rs).RuntimeStateis the per-JS-thread box that already owns the other per-VM allocator state for exactly this reason (transpiler_arena, the transpile printer, the AST stores) and is dropped bydeinit_runtime_statefromVirtualMachine::destroy, so the heap is destroyed on the Worker's thread before it exits. The main thread's state lives for the process, as before.api.rstakes the arena out of the slot for the duration of a call and parks it back on exit, re-fetching the slot at park time. Behaviour on a live VM is identical to before (verified with a probe counting live heaps: exactly one parked heap after 150 mixed parse calls, and a re-entrant call made from the argument'stoString()still ends with one parked heap); with no VM state on the thread the arena is simply dropped.ast_memory_allocator.rsis replaced with what mimalloc actually does and when a TLS-parked heap is acceptable.test/js/node/worker_threads/worker_destruction.test.ts, new test "a Worker that used per-thread allocator heaps does not leak them when it exits", drivingworker-heap-leak-fixture.js: runs a warm-up Worker, recordsheapStats({ dump: true }).mimallocDump.heaps.length, runs 3 more Workers that exercise all five parsers plusBun.Transpilerand adata:import, and expects the live heap count to be unchanged. Fails on the unfixed binary withleaked: 3, passes with this change.bun bd test test/js/node/worker_threads/worker_destruction.test.ts: 6 pass.Background
bun_alloc::Arena(MimallocArena) wraps onemi_heap_t; itsDrop/reset()callmi_heap_destroy, which bulk-frees everything allocated from it. The text-format parsers build their AST in one of these and throw the whole thing away per call.reset_retain_with_limit(2 MiB)keeps the heap, including its dead blocks, while it is small, to skipmi_heap_new/mi_heap_destroyon the next call; that is why a parked heap can hold up to 2 MiB of pages.heapStats({ dump: true })frombun:jsccallsmi_heap_dump_json, which lists every livemi_heap_tin the process regardless of whether it has pages, so the length ofmimallocDump.heapsis an exact live-heap count and a cheap way to detect this class of leak for any API.RuntimeState(src/runtime/jsc_hooks.rs) is the high-tier per-VM state thatbun_jsccannot name directly;bun_jscstores it as an opaque pointer, creates it inVirtualMachine::initand reclaims it inVirtualMachine::destroy, which runs on the Worker's own thread during shutdown (web_worker.rs). Worker threads are the only JS threads that exit while the process keeps running, so anything per-thread that must not outlive the thread belongs there.