Skip to content

mimalloc: collect the page when heap meta data is freed into it - #41100

Closed
robobun wants to merge 1 commit into
mainfrom
robobun/4b8e7416/mimalloc-heap-metadata-leak
Closed

robobun wants to merge 1 commit into
mainfrom
robobun/4b8e7416/mimalloc-heap-metadata-leak

Conversation

@robobun

@robobun robobun commented Sep 1, 2026 •

Copy link
Copy Markdown
Collaborator

Draft until oven-sh/mimalloc#31 merges. The pin points at the head of that PR so CI builds with the fix. Once it merges into bun-dev3-v2, the pin moves to the merge commit and this PR leaves draft. Only the pin and the test change.

Problem

  • new Bun.Transpiler({ define }) leaks about 150 KiB of RSS per instance when 100 or more instances die in one GC. There is no bound: 10,000 instances leave 1.45 GB, and an idle event loop does not give it back.
  • Each instance owns a mimalloc heap (bun_alloc::Arena, JSTranspiler.rs:952). mi_heap_destroy frees the heap's meta data (about 150 KiB) with _mi_free_subproc_safe (heap.c:232), which never collects the page it frees into (free.c:280). In a full, abandoned page the block is stranded for good.

Fix

  • Collect the page when heap meta data is freed into it mimalloc#31: _mi_free_subproc_safe collects when the page belongs to the current sub-process. A free across sub-processes keeps the old behavior. The fork PR carries a C test for it.
  • Correct because a plain mi_free takes this path for a block of another thread: claim the page, fold its thread-free list into used, then free, reclaim, or re-map it.
  • Verified: test/js/bun/jsc/heapStats-mimalloc.test.ts (84 stranded pages on main, 0 with the fix, debug and release). Also the transpiler and bundler suites, test/js/bun/util/, process.test.js, and the fork's ctest suite. Self-reviewed: 7 concerns raised, 7 addressed (see Notes).

Background

  • A mimalloc heap is a set of pages. bun_alloc::Arena is one heap: mi_heap_new on creation, mi_heap_destroy on drop. The heap's own meta data lives in pages of the main heap.
  • mimalloc v3 abandons a page once it is full. Only a free into it brings it back. A page abandoned while full is not in the arena's abandoned bitmap, so no allocation or idle sweep reaches it.
  • heapStats().mimalloc.pages_abandoned.current counts abandoned pages. A stranded page stays abandoned, so the test pins the leak with an exact count.
Notes

Repro on the released binary (bun 1.4.1), define: { A: '"x"' }, RSS per instance after Bun.gc(true) and 1.5 s idle:

GC every unfixed with the fix
1 0.7 KiB
10 0.2 KiB
100 141 KiB 0.8 KiB
1000 145 KiB 2.5 KiB
never (3000 at once) 152 KiB 8.9 KiB

28 live heaps fill one 4 MiB page with mi_arena_pages_t blocks, so a batch of 100 or more finalizers strands almost every heap. The residual with 3000 instances alive at once (about 9 KiB per instance, 25 MB) is proportional to the peak live count, not to the total, and is the same with and without the fix. It is not part of this change.

Stats from heapStats().mimalloc over 4 rounds of 200 instances (the test's fixture), release build:

unfixed:  abandoned 30 -> 58 -> 86 -> 114   rss 53 -> 86 -> 118 -> 141 MB
fixed:    abandoned  3 ->  3 ->  3 ->   3   rss 70 -> 91 ->  96 -> 101 MB

Over 10 rounds the fixed build plateaus at 102 MB; the unfixed one reaches 315 MB (+29 MB per round).

A C harness against Bun's release static.c.o shows the mimalloc side alone: 100 live heaps with one 200-byte block each, created and destroyed in rounds, grow RSS by 150 KiB per destroyed heap without the fix (10 rounds: +146 MB) and plateau at +21 MB with it. 20 live heaps never fill a page and do not leak either way.

Upstream used a plain mi_free for the heap meta data until the sub-process work (microsoft/mimalloc 360d7967b8). Sub-processes (mi_subproc_new) own separate arenas and heaps. _mi_free_subproc_safe exists so that a sub-process teardown can free a block in the pages of another sub-process without collecting a page there. Bun has one sub-process, so every heap teardown now collects. Upstream dev3 still has the same code.

The self-review raised: the .patch carrier that maintainers turned down before (#38168, #38199), so this is a pin bump like #40409 and #40138; an export const MIMALLOC_COMMIT that broke the process.versions test; a hash-keyed workarounds.ts entry that would fail every future pin bump; a read of page->heap on an unowned page against the rule at mi_page_heap (the fork PR now derives the sub-process from the page's arena); and the overlap with #40130, which is closed as superseded. All addressed in this shape.

Related PRs: #40130 was an earlier draft of the same mimalloc change, as a .patch. It concluded that the patch has no effect in Bun because its RSS probe (64 instances alive in a ring) did not show the growth, and it had no test. The repro here shows the growth on every run. #32384 removes the per-instance heap from Bun.Transpiler; that would hide this one trigger but leave the allocator bug for every other bun_alloc::Arena that dies in a batch.

Nothing in this PR is under src/, so the automated fail-before check has nothing to revert. The fail-before run is the released binary (USE_SYSTEM_BUN=1 bun test test/js/bun/jsc/heapStats-mimalloc.test.ts).

Pre-existing failures seen while running the suites, unrelated to this change: test/js/bun/util/fuzzy-wuzzy.test.ts aborts the debug build in js_valkey.rs:1289 (on_valkey_unsubscribe), and four 5 s timeouts in test/js/bun/util/ under debug+ASAN.


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/jsc/heapStats-mimalloc.test.ts

Bump the mimalloc pin to oven-sh/mimalloc#31.

`mi_heap_destroy` frees the `mi_heap_t` and `mi_arena_pages_t` of the heap
with `_mi_free_subproc_safe`, which never collected the page it freed into.
mimalloc abandons a page once it is full, and a block freed into an
abandoned page without a collect is stranded: the page stays abandoned and
resident for good. Once enough heaps are alive at the same time to fill a
page with their meta data, every destroyed heap leaks about 150 KiB.

Bun creates one heap per `bun_alloc::Arena`, for example one per
`Bun.Transpiler`, and the GC destroys them in batches. A release build
leaked about 150 KiB of RSS per `new Bun.Transpiler({ define })` with no
bound (10,000 instances: 1.45 GB).

The test destroys 200 heaps per round and checks that the abandoned page
count and RSS stay flat across rounds.
@robobun

robobun commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author

Status

  • Reproduced on the released binary (bun 1.4.1) and on main: new Bun.Transpiler({ loader: "ts", define: { A: '"x"' } }) in a loop, Bun.gc(true) every 100 or more instances, leaves about 145 KiB of RSS per instance after a 1.5 s idle (3000 instances: 435 MB). Bun.gc(true) after every instance or every 10: flat. The stranded memory is the meta data of each instance's mimalloc heap, freed into a full abandoned page without collecting it.
  • Fix: Collect the page when heap meta data is freed into it mimalloc#31 (_mi_free_subproc_safe collects inside the same sub-process). This PR pins that commit and adds the test. Draft until the fork PR merges; then the pin moves to the merge commit.
  • Test: test/js/bun/jsc/heapStats-mimalloc.test.ts, "destroying many heaps at once does not strand their meta data". Fails on the released binary (USE_SYSTEM_BUN=1: 84 stranded pages, bound 20), passes on debug and release builds with the new pin.
  • Nothing in this PR is under src/, so the automated fail-before check has nothing to revert.

@robobun

robobun commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #41253 and #41261.

@robobun robobun closed this Sep 3, 2026
robobun added a commit that referenced this pull request Sep 3, 2026
Each new Bun.Transpiler({ define }) owns a mimalloc heap. When hundreds
of these heaps die in one GC, mi_heap_destroy frees their meta data into
full, abandoned pages. Before oven-sh/mimalloc#31 it did not collect
those pages, so each page stayed abandoned and resident.

The test creates 200 instances per round for 4 rounds. It checks that
heapStats().mimalloc.pages_abandoned.current and RSS do not grow between
rounds. With the mimalloc pin on main, the abandoned count grows by 28
per round.

The test comes from #41100.
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