Conversation
The JSTranspiler constructor was creating a dedicated MimallocArena
(mi_heap_new()) per instance and passing it to the inner Transpiler.
The reference implementation passes bun.default_allocator instead and
only uses a thin bump arena for config parsing.
Use Arena::borrowing_default() (wraps mi_heap_main()) so the inner
transpiler allocates from the process-global heap, matching the VM
transpiler in jsc_hooks.rs. Every parse operation already swaps in a
fresh per-call Arena::new() via TranspilerStateGuard, so the stored
arena is only the resting-state handle plus configure_defines' bump.
5000 new Bun.Transpiler({loader:'ts'}) instances in release:
before: 232 MB retained, 5000 live mi_heaps
after: 205 MB retained, 0 new mi_heaps
|
Updated 4:23 AM PT - Jun 16th, 2026
✅ @robobun, your commit f08a2d32069f504fd327c5c0f0616869b456ce8e passed in 🧪 To try this PR locally: bunx bun-pr 32384That installs a local version of the PR into your bun-32384 --bun |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
Walkthrough
ChangesJSTranspiler arena allocator fix and heap-count test
Suggested reviewers
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
With the resting-state arena now wrapping mi_heap_main(), its Drop is a no-op, so arena.alloc_slice_copy() for exports.replace string values would strand those bytes for the process lifetime. The reference implementation frees them via the per-instance bump arena on finalize. Store each string value as a Box<[u8]> in Config.replace_exports_bufs instead and borrow from there; they drop with the JSTranspiler. Drops the arena parameter from Config::from_js since nothing else used it, and updates the stale arena field comment.
The configure_defines bump is intentionally routed through the borrowing_default arena to match the reference (Transpiler.Transpiler.init(bun.default_allocator, ...)) and the VM transpiler in jsc_hooks.rs. Auto-injected defaults hit the const_default_define_value fast path and never touch the arena. Drop the toEqual block whose sub-checks are tautological; the toBeLessThan assertion carries the delta in its failure output.
There was a problem hiding this comment.
Thanks — 4eaecf8 addresses both remaining comments (vacuous toEqual dropped, configure_defines trade-off now explicitly documented as reference-matching). No further issues from me, but deferring to a maintainer to sign off on the allocator-lifetime change and the deliberate define: retention trade-off.
Extended reasoning...
Overview
This PR changes JSTranspiler::constructor from Arena::new() (one mi_heap_new() per Bun.Transpiler instance) to Arena::borrowing_default() (shared process heap), matching the Zig reference's Transpiler.init(bun.default_allocator, …). To compensate for losing the per-instance bulk-free, exports.replace string backing storage moves to a new Config.replace_exports_bufs: Vec<Box<[u8]>> field that drops with the instance; the arena parameter on Config::from_js is removed. A new regression test asserts heap count doesn't grow per instance. Two follow-up commits (db1d93f, 4eaecf8) addressed all three legs of my earlier reviews: the exports.replace leak is fixed via owned boxes, the stale field comment is rewritten, the vacuous toEqual is deleted, and the configure_defines slow-path retention is now explicitly documented in-code as the reference behavior (option (b) from my suggested fix).
Security risks
None. This is an allocator/lifetime refactor with no auth, input-parsing, or boundary-crossing changes. The 'static lifetime erasure for E::EString data is pre-existing pattern; the new Box<[u8]> backing is address-stable across Vec reallocation and outlives the borrowing Exprs by field declaration order.
Level of scrutiny
Moderate-to-high. This touches allocator semantics for a public runtime API (Bun.Transpiler), with raw-pointer lifetime laundering (detach_lifetime_ref), drop-order-dependent borrows, and a deliberate acceptance of a user-controllable retention path (define: slow-path values living for process lifetime) on the grounds that the Zig reference has the same behavior. The reasoning is sound and well-documented, but it's the kind of trade-off a maintainer should explicitly OK rather than a bot.
Other factors
CI reported failures on db1d93f (the build comment hasn't updated for 4eaecf8 yet). The transpiler test suites are reported passing locally in the PR description, and the new test correctly fails on the unfixed build. All prior review feedback has been addressed; nothing outstanding from my side.
|
Heads up for whoever rebases this: #38541 changes |
|
Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-06-16 and it conflicts with main. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main. |
What
JSTranspiler::constructorwas building a dedicatedMimallocArena(mi_heap_new()) pernew Bun.Transpiler()and handing it toTranspiler::init. The reference implementation passesbun.default_allocatorto the inner transpiler and only uses a thin bump arena for config parsing (JSTranspiler.zig:690-695).Switched to
Arena::borrowing_default()so the inner transpiler's resting-state allocator is the process-global heap, matching the VM transpiler injsc_hooks.rs. Every parse operation (scan/transformSync/scanImports/ asynctransform) already swaps in a fresh per-callArena::new()viaTranspilerStateGuard, so the stored arena is only the resting-state handle plusconfigure_defines' bump (which the reference also routes throughbun.default_allocator).Since
borrowing_default()'s Drop is a no-op,exports.replacestring values can no longer rely on arena bulk-free. They are now stored asBox<[u8]>inConfig.replace_exports_bufsand drop with the instance, taking the place of the per-instance bump arena the reference uses forconfig.fromJS. Thearenaparameter onConfig::from_jsis removed.Repro
Numbers (release, 5000 held instances)
The remaining per-instance retention is the inner
Transpiler/BundleOptions/Resolver/Definestructs themselves and is unchanged by the allocator choice; this PR only removes the extrami_heap_tper instance so the allocator matches the reference semantics.Verification
bun bd test test/js/bun/transpiler/(47 pass)bun bd test test/bundler/transpiler/transpiler.test.js(170 pass, 21 todo)bun bd test test/js/bun/jsc/heapStats-mimalloc.test.ts(pass)transpiler-arena-heap.test.tsfails on the unfixed build (64 new heaps for 64 instances) and passes with the fix (0 new heaps)