Skip to content

Bun.Transpiler: stop deep-cloning macro_map/replace_exports per transform() call - #32375

Closed
robobun wants to merge 4 commits into
mainfrom
farm/566e74d9/transpiler-clone-regression
Closed

robobun wants to merge 4 commits into
mainfrom
farm/566e74d9/transpiler-clone-regression

Conversation

@robobun

@robobun robobun commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

What

Bun.Transpiler#transform() / #transformSync() deep-cloned the configured macro and exports.{replace,eliminate} hash maps on every call. The Zig implementation shared the backing storage by shallow struct copy (kept alive by transpiler.ref()), so per-call cost was flat regardless of map size.

Repro

const N = 5000, ELIM = parseInt(process.argv[2] || "0");
const t = new Bun.Transpiler({
  loader: "ts",
  exports: ELIM > 0 ? { eliminate: Array.from({ length: ELIM }, (_, i) => "e" + i) } : undefined,
});
for (let i = 0; i < 100; i++) await t.transform("export const a=1;");
const s = Bun.nanoseconds();
for (let i = 0; i < N; i++) await t.transform("export const a=1;");
console.log(JSON.stringify({ ELIM, per_call_us: ((Bun.nanoseconds() - s) / N / 1e3).toFixed(2) }));

Debug+ASAN, per-call overhead vs ELIM=0 baseline:

ELIM before after
0 0 0
500 +1261µs (+75%) +136µs (+8%)
2000 +5350µs (+319%) +328µs (+19%)

Cause

TransformTask::create called clone_macro_map(&config.macro_map) and config.runtime.replace_exports.entries.clone(), then run() cloned both again into ParseOptions: four deep clones per async call, two per sync call.

ParseOptions.macro_remappings is never read by Transpiler::parse (macro remapping is resolved via MacroContext.remap, a BackRef into transpiler.options.macro_remap populated once at construction), so the macro-map clones were pure waste.

Fix

  • Drop TransformTask.macro_map; pass MacroMap::default() to ParseOptions in both paths.
  • Move the owned ReplaceableExportMap from Config.runtime (a Runtime::Features value) to a dedicated Config.replace_exports field.
  • Change Runtime::Features.replace_exports from an owned ReplaceableExportMap to bun_ptr::BackRef<ReplaceableExportMap> (same approach as runtime_transpiler_cache's raw *mut, keeping Features lifetime-free). All parser read sites work unchanged via Deref.
  • ParseOptions.replace_exports becomes &'b ReplaceableExportMap; TransformTask stores the borrow next to its IntrusiveRc<JSTranspiler> which keeps the backing Config live for the task's lifetime.
  • Add ReplaceableExportMap::empty() returning a &'static shared empty map for the default case.

Test

Added a concurrent async transform() test with a 2001-entry eliminate list that cross-checks every result against transformSync, covering the cross-thread borrow lifetime.

…form() call

The Rust port deep-cloned config.macro_map and config.runtime.replace_exports
twice per async transform() (once in TransformTask::create, once in run()) and
once per sync path, where the Zig implementation shared the hash-map storage
by shallow struct copy (kept alive via the transpiler refcount).

ParseOptions.macro_remappings is never read by Transpiler::parse (macro
remapping goes through MacroContext.remap, a BackRef into
transpiler.options.macro_remap set once at construction), so pass an empty
default.

For replace_exports, store the owned map on Config directly and thread a
borrow through ParseOptions into parser Features via BackRef (same pattern as
runtime_transpiler_cache), letting all parser read sites work unchanged via
Deref. The TransformTask stores the borrow alongside its IntrusiveRc handle
so the backing storage outlives the off-thread parse.
@coderabbitai

coderabbitai Bot commented Jun 16, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f6ecc944-b56b-4522-a45a-2ebe8fe4e378

📥 Commits

Reviewing files that changed from the base of the PR and between c8bc5c9 and c9cfbc8.

📒 Files selected for processing (6)
  • src/bundler/transpiler.rs
  • src/jsc/RuntimeTranspilerStore.rs
  • src/jsc/VirtualMachine.rs
  • src/runtime/api/JSTranspiler.rs
  • src/runtime/jsc_hooks.rs
  • test/bundler/transpiler/transpiler.test.js
💤 Files with no reviewable changes (3)
  • src/jsc/VirtualMachine.rs
  • src/bundler/transpiler.rs
  • src/runtime/api/JSTranspiler.rs

Walkthrough

ReplaceableExportMap gains an empty() static constructor backed by a LazyLock. The replace_exports field in Runtime::Features changes from an owned value to BackRef<ReplaceableExportMap>. Config takes ownership of the map; TransformTask and all ParseOptions construction sites borrow it by reference instead of cloning. The macro_remappings field is removed from ParseOptions, and VirtualMachine::has_any_macro_remappings is removed along with its resolver-path update logic. Two regression tests validate concurrency and performance.

Changes

ReplaceableExportMap borrow-not-clone refactor

Layer / File(s) Summary
ReplaceableExportMap::empty() static sentinel
src/ast/runtime.rs
Adds empty() returning a &'static ReplaceableExportMap backed by a LazyLock, providing a stable borrow target when no export remapping is configured.
Runtime::Features field type change to BackRef
src/js_parser/parser.rs, src/js_parser/p.rs
Runtime::Features.replace_exports changes from an owned ReplaceableExportMap to BackRef<ReplaceableExportMap>. Default initializes it via BackRef::new(ReplaceableExportMap::empty()). A stale blocked_on: comment is removed.
Config owns map; TransformTask and get_parse_result borrow it
src/runtime/api/JSTranspiler.rs
Config gains an owned replace_exports field parsed from exports.replace. TransformTask drops its cloned copy and stores &'a ReplaceableExportMap instead. Both TransformTask::run and get_parse_result build ParseOptions with the map borrowed.
ParseOptions field type and transpiler wiring
src/bundler/transpiler.rs
ParseOptions.replace_exports changes from an owned StringArrayHashMap to &'b ReplaceableExportMap; macro_remappings field is removed. The JS/TS parse path wires the borrow via BackRef::new. Default construction switches to ReplaceableExportMap::empty().
Remaining call sites and macro_remappings cleanup
src/js_parser/parse/parse_entry.rs, src/jsc/RuntimeTranspilerStore.rs, src/runtime/jsc_hooks.rs, src/jsc/VirtualMachine.rs
All remaining ParseOptions sites switch to ReplaceableExportMap::empty(). macro_remappings initialization logic and MacroRemap imports are removed. VirtualMachine removes has_any_macro_remappings state and its resolver-path update, introducing is_from_devserver and has_enabled_macro_mode instead.
Concurrency and performance regression tests
test/bundler/transpiler/transpiler.test.js
Adds an async stress test running 64 parallel transform() calls with a large exports.eliminate list, asserting identical stable outputs across all tasks. Adds a sync performance test verifying transformSync() latency does not scale with eliminate-list size.

Suggested reviewers

  • Jarred-Sumner
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely summarizes the main change: eliminating deep-cloning of macro_map and replace_exports in Bun.Transpiler transform calls.
Description check ✅ Passed The PR description comprehensively covers the problem, reproduction steps with benchmarks, root cause analysis, implementation approach, and testing, fully addressing the template sections.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@robobun

robobun commented Jun 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:08 PM PT - Jun 15th, 2026

✅ @robobun, your commit c9cfbc8bb330ac71473a78ef54e2c8447f8d3a6c passed in Build #62746! 🎉


🧪   To try this PR locally:

bunx bun-pr 32375

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

bun-32375 --bun

Comment thread src/bundler/transpiler.rs Outdated

@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 didn't find correctness bugs, but this swaps owned maps for cross-thread BackRef borrows kept alive by IntrusiveRc<JSTranspiler>, and CI build-rust is currently failing on 5 platforms — worth a human look before merge.

Extended reasoning...

Overview

This PR eliminates per-call deep clones of macro_map and replace_exports in Bun.Transpiler#transform()/transformSync(). It does so by:

  • Adding ReplaceableExportMap::empty() (a &'static LazyLock sentinel) in src/ast/runtime.rs.
  • Changing Runtime::Features.replace_exports from an owned map to bun_ptr::BackRef<ReplaceableExportMap> (raw NonNull<T> wrapper) in src/js_parser/parser.rs.
  • Moving owned storage to a new Config.replace_exports field in JSTranspiler, and having TransformTask hold &'a ReplaceableExportMap borrowed from it (kept alive by the adjacent IntrusiveRc<JSTranspiler>).
  • Changing ParseOptions.replace_exports to &'b ReplaceableExportMap and updating all construction sites (transpiler.rs, RuntimeTranspilerStore.rs, jsc_hooks.rs, parse_entry.rs).
  • Passing MacroMap::default() for the dead macro_remappings field in the JSTranspiler paths.
  • Adding a 64-way concurrent async test with a 2001-entry eliminate list.

Nine files touched across ast, bundler, js_parser, jsc, and runtime.

Security risks

None in the traditional sense (no auth/crypto/input-parsing surface). The risk here is memory safety: BackRef<T> is a lifetime-erased NonNull<T> with safe Deref, and the new TransformTask.replace_exports: &'a ReplaceableExportMap is read on a worker thread while the backing Config lives in a JsCell on the JS thread. Soundness rests on two invariants the PR documents but the type system does not enforce: (1) IntrusiveRc<JSTranspiler> keeps Config alive for the task's lifetime, and (2) Config.replace_exports is never mutated after construction. Both look correct from reading the code (the field is only written in Config::from_js during construction, and the visit pass only calls get_ptr/contains), but a UAF or data race here would be a security-relevant defect.

Level of scrutiny

High. This is a non-mechanical ownership refactor in core transpiler/parser infrastructure that introduces raw-pointer-backed cross-thread borrows. The reasoning is well-documented and the added concurrency test is good coverage, but the change shape (owned → BackRef, sync → async borrow) is exactly the kind that benefits from a second pair of eyes on the lifetime argument.

Other factors

  • CI is red: robobun reports build-rust failures on 5 platforms (FreeBSD aarch64, macOS x64/aarch64, Linux x64-android, aarch64-musl) for commit 4d20adc. An autofix commit (c8bc5c9) followed; its build status isn't shown yet.
  • I left one inline nit earlier about build_with_resolve_result_eager still cloning into the now-dead macro_remappings field — purely a consistency cleanup, not a blocker.
  • clone_macro_map in JSTranspiler.rs is now only used at construction time (line ~1066), no longer per-call.
  • The Default for Features change means every Features::default() now dereferences a LazyLock once; negligible but worth noting.

…l cost

ParseOptions.macro_remappings was never read by Transpiler::parse (macro
remapping is resolved via MacroContext.remap, a BackRef into
transpiler.options.macro_remap). Drop the field and the deep-clone at every
construction site.

Add a ratio test: per-call transform() time with a 4000-entry
exports.eliminate list must stay within 3x of an empty-list baseline.
Without the fix the ratio is 7x-9x.
Comment thread src/runtime/jsc_hooks.rs
Comment thread test/bundler/transpiler/transpiler.test.js Outdated
… path in ratio test

The flag's only readers were the macro_remappings clone-gate blocks removed
in the previous commit; the remaining self-OR writes are dead bookkeeping.

Rework the per-call-cost test to time transformSync() (no work-pool / event
loop hops) with base/big measurement windows interleaved across 5 rounds so
transient CI load affects both sides of the ratio symmetrically.

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

Thanks — c9cfbc8 addresses both remaining nits (write-only has_any_macro_remappings dropped; ratio test moved to transformSync + interleaved). No further findings, but the owned→BackRef change to Features.replace_exports and the cross-thread borrow held by TransformTask's IntrusiveRc are subtle enough that a human should sign off.

Extended reasoning...

Overview

This PR eliminates per-call deep-cloning of the exports.{replace,eliminate} and macro maps in Bun.Transpiler#transform()/#transformSync(). It does so by (a) moving owned storage from Runtime::Features.replace_exports to a new Config.replace_exports field, (b) changing Features.replace_exports to BackRef<ReplaceableExportMap> and ParseOptions.replace_exports to &'b ReplaceableExportMap, (c) having TransformTask borrow the map (kept alive by its IntrusiveRc<JSTranspiler>) instead of cloning, and (d) deleting the now-dead ParseOptions.macro_remappings field and VirtualMachine.has_any_macro_remappings flag plus their five clone/write sites. A ReplaceableExportMap::empty() static is added for the default case. Two tests are added: a 64-way concurrent async correctness check and an interleaved transformSync timing-ratio regression guard.

Security risks

None identified. The change is internal memory-ownership refactoring in the transpiler hot path; no auth, crypto, network, or user-input parsing surface is touched. The BackRef is read-only and the IntrusiveRc keeps the pointee alive for the task's lifetime, so there is no new UAF surface introduced by design — but that is exactly the invariant a human should confirm.

Level of scrutiny

Medium-high. This is a correctness-sensitive ownership refactor, not a config tweak: Features.replace_exports changes type across the parser/visit pass (read at ~15 sites in visit_stmt.rs/visit/mod.rs), and TransformTask now carries a &'a borrow across a work-pool thread hop whose validity rests on the js_instance: IntrusiveRc field's drop ordering. The pattern matches the existing runtime_transpiler_cache: *mut precedent in Features and the comments document the invariant clearly, but a maintainer familiar with the BackRef/IntrusiveRc conventions should confirm.

Other factors

All three of my prior inline comments were addressed (5b0bdda dropped ParseOptions.macro_remappings; c9cfbc8 dropped has_any_macro_remappings and reworked the ratio test to transformSync + 5-round interleave). The bug-hunting system found nothing on the latest revision. The remaining timing-ratio test is much improved (no work-pool hops, symmetric noise exposure, ~7.5× vs 3× threshold separation per the author) but is still a wall-clock assertion — acceptable given the margin, but worth a maintainer's awareness. CI build #62746 is in progress.

@robobun

robobun commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

Stale PR review: keep open, rework.

The mechanism in this diff no longer applies to main. TransformTask (src/runtime/api/JSTranspiler.rs:636) no longer holds an IntrusiveRc<JSTranspiler>, so there is nothing to keep a borrowed ReplaceableExportMap alive, and 6 of the 10 files conflict. #40383 and #40405 (open, updated 2026-09-08) rewrite the same files in the opposite direction: TransformTask becomes owned snapshots with no unsafe, and clone_macro_map stays. A BackRef through Runtime::Features would go against that work. The measured win also needs hundreds of exports.eliminate entries. Real configs use one to five names, where the clone costs about 0.1us on a 10us call, and no user has reported it.

The part that is wanted is smaller and should wait for #40383/#40405 to land: delete the write-only ParseOptions.macro_remappings field (src/bundler/transpiler.rs:995, only touched by let _ = &this_parse.macro_remappings; at :1568), the per-module MacroRemap clone loops that feed it (src/runtime/jsc_hooks.rs:2434, src/jsc/RuntimeTranspilerStore.rs:741, src/bundler/transpiler.rs:2952), VirtualMachine.has_any_macro_remappings, and the per-call clone_macro_map in TransformTask (keep the constructor-time one that fills transpiler.options.macro_remap). That is a dead-code PR in the owned-snapshot idiom, with no lifetime changes to bun_ast or bun_js_parser.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

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.

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