Skip to content

Free the parked Bun.TOML/YAML/JSON5/JSONC/XML.parse arena when a Worker exits - #38999

Open
robobun wants to merge 1 commit into
mainfrom
farm/c79bd2eb/worker-text-format-arena-leak
Open

Free the parked Bun.TOML/YAML/JSON5/JSONC/XML.parse arena when a Worker exits#38999
robobun wants to merge 1 commit into
mainfrom
farm/c79bd2eb/worker-text-format-arena-leak

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Every Worker that calls Bun.TOML.parse, Bun.YAML.parse, Bun.JSON5.parse, Bun.JSONC.parse or Bun.XML.parse leaks 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 canary eabb96de7 shows the same 20 heaps, RSS +40 MB vs +2 MB).
  • Cause: 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_done in mimalloc's init.c) only abandons the thread's theap pages and never destroys heaps created on it. The mi_heap_t, its TLS key slot, and every page still holding a retained block outlive the Worker.
  • Introduced by XML parser: SIMD structural index + tape rows, one-call rows→JS #37146 (Aug 7), which replaced the per-call 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.
  • LSan cannot catch this class of leak (mimalloc blocks bypass it), which is why nothing flagged it.

Fix

  • The parked arena now lives in RuntimeState::text_format_arena (src/runtime/jsc_hooks.rs). RuntimeState is 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 by deinit_runtime_state from VirtualMachine::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.rs takes 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's toString() still ends with one parked heap); with no VM state on the thread the arena is simply dropped.
  • The misleading sentence in ast_memory_allocator.rs is replaced with what mimalloc actually does and when a TLS-parked heap is acceptable.
  • Verification:
    • 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", driving worker-heap-leak-fixture.js: runs a warm-up Worker, records heapStats({ dump: true }).mimallocDump.heaps.length, runs 3 more Workers that exercise all five parsers plus Bun.Transpiler and a data: import, and expects the live heap count to be unchanged. Fails on the unfixed binary with leaked: 3, passes with this change.
    • bun bd test test/js/node/worker_threads/worker_destruction.test.ts: 6 pass.
    • Heap-count probe on the fixed debug build: 6 Workers parsing large TOML/YAML documents leave 0 extra heaps and the page count stays flat (was +33 pages per Worker).

Background

  • bun_alloc::Arena (MimallocArena) wraps one mi_heap_t; its Drop/reset() call mi_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 skip mi_heap_new/mi_heap_destroy on the next call; that is why a parked heap can hold up to 2 MiB of pages.
  • heapStats({ dump: true }) from bun:jsc calls mi_heap_dump_json, which lists every live mi_heap_t in the process regardless of whether it has pages, so the length of mimallocDump.heaps is 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 that bun_jsc cannot name directly; bun_jsc stores it as an opaque pointer, creates it in VirtualMachine::init and reclaims it in VirtualMachine::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.

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

coderabbitai Bot commented Aug 15, 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: 29 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: e4917a3a-a08e-4b3a-b0d3-bbc9ec1a5132

📥 Commits

Reviewing files that changed from the base of the PR and between 732491c and 0c25551.

📒 Files selected for processing (5)
  • src/ast/ast_memory_allocator.rs
  • src/runtime/api.rs
  • src/runtime/jsc_hooks.rs
  • test/js/node/worker_threads/worker-heap-leak-fixture.js
  • test/js/node/worker_threads/worker_destruction.test.ts

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

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review; the diff is green on every lane that ran.

Reproduced on canary eabb96de7 and on a debug build of main by counting live mimalloc heaps (heapStats({ dump: true }).mimallocDump.heaps.length) across Worker lifecycles: each Worker that called Bun.TOML.parse left one heap behind (20 Workers: +20 heaps, RSS +40 MB in release). With this change the count stays flat.

The new test (worker_destruction.test.ts, fixture worker-heap-leak-fixture.js) fails on the unfixed binary with leaked: 3 and passes with the fix; the whole file passes on a debug build.

CI (build 97928): 177 of 179 jobs passed, including worker_destruction.test.ts on every platform that ran. The remaining two are the :darwin: 14 aarch64 - test-bun shards, which never started: they expired in the scheduling queue four times in a row because that lane (six agents) currently has a multi-hour backlog across all builds. Nothing in this PR is macOS-specific; retrying those two jobs once the lane drains is all that is needed for a fully green build.

@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 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 as global_dns_data(); deinit_runtime_state nulls RUNTIME_STATE before dropping the box, so the SAFETY comment holds.
  • Drop order in with_text_format_source_encoded: _ast_scopeast_memory_allocatorrecycle, so the arena is unborrowed when Recycle::drop parks it.
  • Recycle::drop re-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.length after 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.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:05 AM PT - Aug 15th, 2026

@robobun, your commit 0c25551 has some failures in Build #97928 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 38999

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

bun-38999 --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.

2 participants