Skip to content

Bun.Transpiler: allocate exports.replace string values in the instance arena - #38541

Closed
robobun wants to merge 3 commits into
mainfrom
farm/368105d9/transpiler-replace-string-arena
Closed

robobun wants to merge 3 commits into
mainfrom
farm/368105d9/transpiler-replace-string-arena

Conversation

@robobun

@robobun robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • new Bun.Transpiler({ exports: { replace: { foo: "bar" } } }) followed by any synchronous parse on the same thread (a require() of a CommonJS file, an import of a .json file, ...) and then a transformSync() / transform() of a module that exports foo prints the replacement from memory that no longer belongs to it.
  • Debug builds crash deterministically in the printer (the store is poisoned with 0xAA on reset):
    ERROR: AddressSanitizer: unknown-crash on address 0xaaaaaaaaaaaaaaaa
    READ of size 2863311530
        #2 bun_alloc::baby_vec::BabyVec<u8>::extend_from_slice   src/bun_alloc/baby_vec.rs
        #3 bun_ast::e::EString::resolve_rope_if_needed            src/ast/e.rs
        #4 bun_js_printer::Printer::print_expr                    src/js_printer/lib.rs
        #13 JSTranspiler::transform_sync                          src/runtime/api/JSTranspiler.rs
    
    Release builds read whatever the intervening parse left in that slot: with a small module in between the output is still correct by luck; with a module of a few dozen declarations or more it prints the wrong value (export const foo = "";) or dies with panic(main thread): Segmentation fault ... Crashed while printing input.jsx, depending on what landed there.
  • Cause: export_replacement_value in src/runtime/api/JSTranspiler.rs copies the string bytes into the JSTranspiler's own arena but builds the node with Expr::init, which appends the E::EString struct to the thread-local AST store. The returned Expr (a pointer to that struct) is kept in config.runtime.replace_exports for the life of the transpiler and copied into every later parse, while the next parse on the main thread resets that store and fills it with its own nodes.
  • Only string values are affected: booleans, numbers, null and undefined are stored inline in ExprData. Both the plain form (foo: "bar") and the inject form (foo: ["name", "bar"]) go through this function.

Fix

  • Build the node with Expr::allocate(arena, ..) instead of Expr::init(..), so the E::EString struct lives in the same arena as its bytes.
  • Correct because that arena (JSTranspiler.arena) is never reset and is dropped together with the Config holding the Expr, so the node lives exactly as long as the pointer to it. This is also what the code did before the port (allocator.create(E.String) on the instance allocator), and what DefineData::parse in src/bundler/defines.rs does for the same reason with define values.
  • export_replacement_value now also holds bun_ast::expr::Disabler::scope(), the existing debug-only guard used by PackageJSONEditor for the same situation: if this function ever appends to the thread-local store again, debug builds panic at construction time ([bun_ast::expr::Expr] called while disabled), which every existing exports.replace test would hit. Checked by temporarily putting Expr::init back: transpiler.test.js aborts at module load with that panic. Expr::allocate never reaches the store, so the guard is inert for the fixed code, and it compiles to nothing in release.
  • The doc comment on Expr::allocate pointed at a non-existent Expr.alloc; it now says when to use Expr::init instead.
  • Verified with test/bundler/transpiler/transpiler.test.js ("string replacement values survive other modules being loaded after the transpiler is created"): a spawned fixture creates the transpiler, require()s a CommonJS module with 300 string declarations, imports a JSON file, then transforms an export const, an export function (inject form) and an export default through both transformSync() and transform(). The required module is deliberately large so that release builds reuse the slot too: without the fix the test fails on a release build (all six outputs print "") and on a debug build (sanitizer report from the child); with the fix it passes on both.
  • The rest of transpiler.test.js passes with the change (189 pass, 0 fail).

Background

  • AST node store: Expr is a small Copy value; for heap-sized node kinds such as E::EString it holds a pointer into a thread-local slab (src/ast/new_store.rs). The slab is bulk-reset at the start of every parse that runs on that thread without a per-parse allocator scope, and the following parse writes its own nodes over the same memory, so anything built with Expr::init is only valid until the next such parse. Expr::allocate(bump, ..) is the variant for nodes that must outlive that reset; it puts the node in the caller's arena instead. Debug builds additionally fill the slab with 0xAA on reset, which is why the stale read is a deterministic crash there.
  • bun_ast::expr::Disabler is a debug-only flag checked by the slab's append; Disabler::scope() sets it for the current scope, turning any Expr::init of a slab-backed node inside that scope into a panic. It is a no-op in release builds.
  • Bun.Transpiler option parsing runs in the constructor with no allocator scope active, so Expr::init there lands in the thread-local slab. transformSync() / transform() parse into their own per-call arena, which is why the bug only shows up once some other synchronous parse on the main thread (module loading) has reset the slab.
Per-form probe on a debug build, before and after the change

Each row is a separate process: construct the transpiler, require("./reset.cjs"), then transform the given source.

value source before after
foo: "bar" export const foo = 1; (sync) ASAN crash export const foo = "bar";
getStaticProps: ["__N_SSG", "ssg"] export function getStaticProps() {} (sync) ASAN crash export var __N_SSG = "ssg";
default: "dflt" export default 1; (sync) ASAN crash export default "dflt";
foo: "bar" export const foo = 1; (async) ASAN crash export const foo = "bar";
getStaticProps: ["__N_SSG", "ssg"] export function getStaticProps() {} (async) ASAN crash export var __N_SSG = "ssg";
num: 42 export const num = 1; export const num = 42; unchanged
inj: ["__INJ", true] export function inj() {} export var __INJ = true; unchanged
foo: "bar", no require() in between export const foo = 1; export const foo = "bar"; unchanged
Release build without the fix, by size of the module required in between

Same fixture as the test, release binary at b7a043103, reset.cjs holding N string declarations. 5, 10 and 20 declarations: correct output on every run (the slot is not reached). 40 and more: wrong output or a segmentation fault on every run. The test uses 300.


[human-review] gate passed · iteration 1 · 3 files touched

fails on main (without fix)
ASAN without fix: 1 failed, 22 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/transpiler/transpiler.test.js
bun test v1.4.3 (367d939d9)

test/bundler/transpiler/transpiler.test.js:
(pass) Bun.Transpiler > handles errors when parsing macros [9.10ms]
(pass) Bun.Transpiler > normalizes \r\n [16.59ms]
1
(pass) Bun.Transpiler > doesn't hang indefinitely #2746 [18.63ms]
(pass) Bun.Transpiler > property access inlining > bails out with spread [17.33ms]
(pass) Bun.Transpiler > property access inlining > bails out with multiple items [5.93ms]
(pass) Bun.Transpiler > property access inlining > works [6.44ms]
(pass) Bun.Transpiler > property access inlining > works nested [6.07ms]
(pass) Bun.Transpiler > property access inlining > bails out when the array item is an optional chain [123.41ms]
(pass) Bun.Transpiler > property access inlining > bails out or strips `this` when the index is a call/assignment target [51.33ms]
(pass) Bun.Transpiler > property access inlining > preserves runtime semantics when inlining from a literal index [908.25ms]
(pass) Bun.Transpiler > property access inlining > bails out on optio
... (truncated)

release without fix: 22 skipped
bun test v1.4.3-canary.1 (a90fada04)

test/bundler/transpiler/transpiler.test.js:
(pass) Bun.Transpiler > handles errors when parsing macros [5.06ms]
(pass) Bun.Transpiler > normalizes \r\n [0.41ms]
1
(pass) Bun.Transpiler > doesn't hang indefinitely #2746 [0.15ms]
(pass) Bun.Transpiler > property access inlining > bails out with spread [0.18ms]
(pass) Bun.Transpiler > property access inlining > bails out with multiple items [0.04ms]
(pass) Bun.Transpiler > property access inlining > works [0.04ms]
(pass) Bun.Transpiler > property access inlining > works nested [0.03ms]
(pass) Bun.Transpiler > property access inlining > bails out when the array item is an optional chain [1.01ms]
(pass) Bun.Transpiler > property access inlining > bails out or strips `this` when the index is a call/assignment target [0.29ms]
(pass) Bun.Transpiler > property access inlining > preserves runtime semantics when inlining from a literal index [31.05ms]
(pass) Bun.Transpiler > property access inlining > bails out on optional-chain index into enum [0.68ms]
(pass) Bun.Transpiler > property access inlining > template literal around an inlined string enum member > member as the first part of the
... (truncated)
passes on PR (with fix)
ASAN with fix: 22 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/transpiler/transpiler.test.js
bun test v1.4.3 (367d939d9)

test/bundler/transpiler/transpiler.test.js:
(pass) Bun.Transpiler > handles errors when parsing macros [16.04ms]
(pass) Bun.Transpiler > normalizes \r\n [18.47ms]
1
(pass) Bun.Transpiler > doesn't hang indefinitely #2746 [12.52ms]
(pass) Bun.Transpiler > property access inlining > bails out with spread [13.39ms]
(pass) Bun.Transpiler > property access inlining > bails out with multiple items [6.67ms]
(pass) Bun.Transpiler > property access inlining > works [7.57ms]
(pass) Bun.Transpiler > property access inlining > works nested [3.93ms]
(pass) Bun.Transpiler > property access inlining > bails out when the array item is an optional chain [112.93ms]
(pass) Bun.Transpiler > property access inlining > bails out or strips `this` when the index is a call/assignment target [34.45ms]
(pass) Bun.Transpiler > property access inlining > preserves runtime semantics when inlining from a literal index [1505.17ms]
(pass) Bun.Transpiler > property access inlining > bails out on opt
... (truncated)

release with fix: 22 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 3436ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/91] gen generated_host_exports.rs
generated_host_exports.rs: 121 exports (host=5, lazy=10, generic=106, rust=0); 248 extern-C blocks audited
[2/90] build.rs build_script_build
[3/90] rustc bun_platform 
[4/90] rustc bun_core 
[5/90] rustc bun_errno 
[6/90] rustc bun_ptr 
[7/90] rustc bun_boringssl_sys 
[8/90] rustc bun_safety 
[9/90] rustc bun_output 
[10/90] rustc bun_brotli 
[11/90] rustc bun_cares_sys 
[12/90] rustc bun_zstd 
[13/90] rustc bun_zlib_sys 
[14/90] rustc bun_picohttp 
[15/90] rustc bun_base64 
[16/90] rustc bun_clap 
[17/90] rustc bun_valkey 
[18/90] rustc bun_tcc_sys 
[19/90] rustc bun_lsquic_sys 
[20/90] rustc bun_shell_parser 
warning: `feature(generic_const_exprs)` is not supported with the next-generation trait solver
 --> src/shell_parser/lib.rs:1:30
  |
1 | #![feature(adt_const_params, generic_const_exprs, allocator_api)]
  |                              ^^^^^^^^^^^^^^^^^^^
  |
  = note: `-Znext-solver=globally` is currently enabled by default for testing
  = note: reverted the s
... (truncated)
diff hotspot
src/ast/expr.rs                            |  2 +-
 src/runtime/api/JSTranspiler.rs            |  6 +++-
 test/bundler/transpiler/transpiler.test.js | 58 ++++++++++++++++++++++++++++++
 3 files changed, 64 insertions(+), 2 deletions(-)

gate history · 1 passed · 1 rejected · iteration 1

evidence per changed file
file                                        reads  edits  tests
src/ast/expr.rs                                 0      0     11
src/runtime/api/JSTranspiler.rs                 0      0     11
test/bundler/transpiler/transpiler.test.js      1      2     11

root cause · written by the author bot

String-valued exports.replace entries were built with Expr::init, which placed the replacement Expr in the thread-local expression store that every subsequent parse resets, so later synchronous or asynchronous transforms on the same Bun.Transpiler instance read a dangling node and produced wrong or corrupted replacement values. The fix switches export_replacement_value to Expr::allocate with the transpiler's own boxed arena, which lives as long as the instance and is also kept alive by the Strong reference held during async transforms. A subprocess regression test confirms rep…

@coderabbitai

coderabbitai Bot commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 17cfd3a1-b64d-4e54-a18a-37a4d1cfaf64

📥 Commits

Reviewing files that changed from the base of the PR and between 4881aa2 and a90fada.

📒 Files selected for processing (1)
  • test/bundler/transpiler/transpiler.test.js

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 7 remain after this review.


Walkthrough

String-valued export replacements now use arena-backed expressions. A subprocess regression test checks replacement values after CommonJS and JSON module loads in synchronous and asynchronous transforms.

Changes

Export replacement lifetime

Layer / File(s) Summary
Persistent expression allocation
src/ast/expr.rs, src/runtime/api/JSTranspiler.rs, test/bundler/transpiler/transpiler.test.js
export_replacement_value allocates string replacements in the supplied arena. The regression test verifies replacement values after later CommonJS and JSON module loads in synchronous and asynchronous transforms.

Suggested reviewers: jarred-sumner

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to 1e3a2

String-valued export replacements remain available after other modules load, and the regression test covers both transform APIs. No actionable merge-blocking risk is established.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly identifies the main change: allocating exports.replace string values in the transpiler instance arena.
Description check ✅ Passed The description fully explains the problem, root cause, fix, scope, and verification results. It does not use the template headings exactly, but it includes the required content for what the PR does a…

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

@robobun

robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: fix and test are in this PR (head 7a815dd), waiting on CI.

Reproduced on a debug build at 7cf62962b6 with a three-file fixture: construct new Bun.Transpiler({ exports: { replace: { foo: "bar" } } }), require() a CommonJS file, then transformSync("export const foo = 1;"). The second step resets the thread-local AST store the replacement node was allocated in, and the third reads it back, which AddressSanitizer reports as a wild read in EString::resolve_rope_if_needed from JSTranspiler::transform_sync. The same happens for the inject form (foo: ["name", "str"]), for export default, and through the async transform(); numeric and boolean values are unaffected. Without the require() in between the output is correct. On the release binary at b7a043103 the same fixture prints "" for every replacement, or segfaults, once the module required in between has 40 or more declarations.

With the change, all of those forms print the replacement after the require(), and test/bundler/transpiler/transpiler.test.js passes as a whole (189 pass). The new test in that file fails without the src/ change on both a release build (wrong output) and a debug build (sanitizer report from the child), and passes with it.

Review so far: the two flagged comments were trimmed in d96761b (threads resolved); 7a815dd added the debug guard and the release-detectable fixture described in the PR body. Nothing outstanding.

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

LGTM — one-line allocator fix moving the E::EString node from the thread-local AST store into the instance arena, with a subprocess regression test.

What was reviewed:

  • Confirmed Expr::allocate(bump, ..) exists and places the node in the caller's bump arena; the arena passed here is the boxed JSTranspiler.arena, which outlives config.runtime.replace_exports.
  • Test covers plain, inject, and default string forms via both transformSync() and transform(), drains pipes concurrently, and asserts exact output; tempDir/bunExe/bunEnv are already imported at line 2.
  • The 60s timeout is justified by the comment (only the pre-fix failing path is slow, symbolizing the child's sanitizer report).
Extended reasoning...

Overview

The PR changes one call in export_replacement_value (src/runtime/api/JSTranspiler.rs) from Expr::init(..) to Expr::allocate(arena, ..), updates the field comment on JSTranspiler.arena to mention the new resident, and adds a spawned-child regression test to test/bundler/transpiler/transpiler.test.js.

The mechanism is exactly as described: Expr::init (src/ast/expr.rs:1061) puts heap-sized node kinds like E::EString into the thread-local data::Store, which is reset by the next main-thread parse; Expr::allocate (src/ast/expr.rs:1052) puts the node into the caller-supplied bump arena instead. The arena argument threaded through Config::from_js → export_replacement_value is arena_ref, a &'static view of the Box<Arena> that becomes JSTranspiler.arena and is dropped only with the JSTranspiler itself. The string bytes were already copied into that arena via alloc_slice_copy; now the node struct joins them, so the Expr stored in config.runtime.replace_exports no longer dangles after an intervening require().

Security risks

None. This is an internal allocator-choice fix for a use-after-free; no new input parsing, no boundary changes.

Level of scrutiny

Memory-safety fix in native code, so I traced the arena provenance end-to-end (constructor → from_js → export_replacement_value) and confirmed Expr::allocate's signature matches. The functional change is a single well-understood substitution; the rest is comments and a test. Both Expr::init and Expr::allocate call data::Store::assert(), so no new precondition is introduced.

Other factors

The test follows the harness conventions closely: tempDir + Bun.spawn with bunEnv, concurrent Promise.all on stdout/stderr/exited, stderr asserted empty before parsing stdout, exact-value toEqual on the six outputs, exitCode asserted last. It exercises every string-value code path (plain replace, inject-form array, default) through both sync and async transforms after two different store-resetting operations (CJS require and JSON import). The 60s per-test timeout was flagged and ruled out — the comment names a real reason (child sanitizer symbolization on the pre-fix build), and the passing path is fast. No prior human review comments to address.

Comment thread src/runtime/api/JSTranspiler.rs Outdated
Comment thread src/runtime/api/JSTranspiler.rs Outdated
@robobun

robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:29 PM PT - Sep 28th, 2026

✅ @robobun, your commit 1e3a2881ca37a8f73d18e34a9eb8c30b1e59938c passed in Build #121438! 🎉


🧪   To try this PR locally:

bunx bun-pr 38541

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

bun-38541 --bun

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Two small follow-up pushes since the review above, no functional change:

  • e0ce5c5 drops the explicit 60s per-test timeout from the new test. The CI runner already passes its own per-test --timeout (90s, 270s under ASAN), so the override only lowered that ceiling; it bought nothing beyond a nicer local failure message, and the test itself takes about 0.5s on a debug build.
  • d96761b trims the comments flagged above. The src/ diff is now the Expr::init to Expr::allocate change plus one line saying why.

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

LGTM — one-line allocator swap that moves the E::EString node for exports.replace string values into the same instance arena that already holds its bytes, fixing a UAF when the thread-local AST store is reset between construction and transform.

What was reviewed:

  • Expr::allocate semantics in src/ast/expr.rs — its doc comment matches this exact use case (node must outlive reset()), and Store::assert() there is the same guard Expr::init already passed.
  • Arena lifetime: JSTranspiler.arena is a Box<Arena> never reset and dropped with the Config holding the Expr; the async TransformTask path holds a Strong on the wrapper so the arena outlives the off-thread read.
  • Test: hermetic spawned fixture in the existing transpiler.test.js, drains pipes concurrently, covers sync/async × plain/inject/default, asserts exact output; tempDir/bunExe/bunEnv already imported at line 2.
Extended reasoning...

Overview

The PR changes one call site in src/runtime/api/JSTranspiler.rs::export_replacement_value: Expr::init(EString::init(data), ..) → Expr::allocate(arena, EString::init(data), ..). The arena is the JSTranspiler's own Box<Arena>, already threaded through Config::from_js and already used one line above to hold the string bytes (arena.alloc_slice_copy(&buf)). The change puts the E::EString struct in the same arena as its payload instead of the thread-local AST slab, which is bulk-reset by any subsequent main-thread parse. A regression test is added to test/bundler/transpiler/transpiler.test.js.

Security risks

None. This is an internal allocation-lifetime fix in the transpiler option parser; no input validation, auth, crypto, or trust boundary is touched. The change strictly narrows a UAF window (moving an allocation from a shorter-lived store to a longer-lived one that already owns related data).

Level of scrutiny

Low-to-medium. The functional change is a single call-site swap between two well-documented Expr constructors whose doc comment (src/ast/expr.rs:1048-1050) describes exactly this scenario. I verified: (a) Expr::allocate's signature and semantics match — it bump-allocates the node in the caller's arena via into_data_alloc; (b) the Store::assert() it calls is the same debug guard Expr::init already satisfied at this site, so no new precondition is introduced; (c) the arena passed here is JSTranspiler.arena: Box<Arena>, address-stable, never reset, dropped with the struct that holds the Expr in config.runtime.replace_exports; (d) the async transform() path clones replace_exports.entries and keeps the JSTranspiler alive via TransformJs._transpiler: Strong, so the arena outlives the off-thread parse's read of the node.

Other factors

The test follows repo harness conventions: spawned subprocess with bunExe()/bunEnv, tempDir fixture, Promise.all over stdout/stderr/exited, exact-value assertions on JSON-parsed output before the exit-code check, no network, no sleeps. It covers the variant matrix that goes through the changed function (plain replace, inject form, export default, sync and async) and the two module-load paths that reset the store (require of CJS, import of JSON). The PR description documents that the test crashes under ASAN without the fix and passes with it, and that the full transpiler.test.js suite still passes. The two comment-cop inline threads were addressed in d96761b (comments trimmed) and are resolved. No outstanding human review comments.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 7a815dd after a second look at the change, two things came out of it (PR description updated to match):

  • The test only caught the bug on debug builds, where the store is poisoned on reset; on a release build the tiny module it required in between never reached the stale slot, so the pre-fix release binary passed it. The module it requires now declares 300 strings, and without the fix the test fails on the release binary as well (all six outputs come back as ""; 40 or more declarations is enough, 20 is not).
  • export_replacement_value now holds bun_ast::expr::Disabler::scope(), the same debug-only guard PackageJSONEditor uses, so putting Expr::init back in this function panics at construction time in debug builds. Confirmed by temporarily reintroducing it: transpiler.test.js aborts at module load with [bun_ast::expr::Expr] called while disabled. The guard compiles to nothing in release.

Also fixed the Expr::allocate doc comment, which referred to a non-existent Expr.alloc.

The previous build (96157) ended with no test failures; its two macOS aarch64 jobs expired waiting for an agent, so this push also gives it a fresh run.

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

LGTM — targeted use-after-reset fix.

What was reviewed:

  • Expr::allocate(arena, ..) vs Expr::init: verified into_data_alloc puts the E::EString node in the caller's bump (the boxed JSTranspiler.arena, which lives as long as config.runtime.replace_exports), while into_data_store would append to the thread-local slab that Store::begin() resets — matches the pattern used for define values.
  • Disabler::scope() guard: debug-only (bodies are #[cfg(debug_assertions)]-gated, no-op in release); Expr::allocate calls Store::assert() but not Disabler::assert(), and the inline bool/number/null/undefined arms don't touch the store, so the guard cannot fire on the current code — it just fences future reintroduction.
  • Test follows harness conventions (tempDir/bunEnv/bunExe already imported, concurrent pipe drain, exact-value assertions, exitCode last) and the 300-string reset.cjs refills the slab so the assertion also fails on release builds, not just via debug poisoning.
Extended reasoning...

Overview

One-line functional change in src/runtime/api/JSTranspiler.rs: export_replacement_value now builds string replacement nodes with Expr::allocate(arena, ..) instead of Expr::init(..), so the E::EString struct lands in the same Box<Arena> that already holds its bytes rather than in the thread-local AST slab that any subsequent main-thread parse resets. A debug-only bun_ast::expr::Disabler::scope() guard is armed for the duration of the function to catch any future store-backed allocation in this scope. src/ast/expr.rs gets a one-line doc tweak on Expr::allocate (the old comment referenced a non-existent Expr.alloc). A new spawned-fixture test in test/bundler/transpiler/transpiler.test.js covers the plain, inject and default replacement forms through both transformSync() and transform().

Security risks

None. This is a lifetime fix for constructor-time option data in Bun.Transpiler; no new inputs, parsing, or trust boundaries. The change removes a use-after-reset (freed-store read) rather than introducing one.

Level of scrutiny

Memory-safety adjacent, so I traced the mechanism rather than taking the description at face value. Expr::init → into_data_store → data::Store::append writes into the per-thread slab (new_store.rs:470-482) that begin() resets; Expr::allocate → into_data_alloc → bump.alloc writes into the passed arena (expr.rs:1023-1031, 1052-1057). The arena passed here is the Box<Arena> field on JSTranspiler, which is never reset and drops with the struct that owns config.runtime.replace_exports — so node lifetime now matches pointer lifetime exactly. The string bytes were already in that arena (arena.alloc_slice_copy), so this just brings the containing struct to the same place. I confirmed the Disabler guard is release-no-op (all three methods are #[cfg(debug_assertions)]-gated bodies) and that neither Expr::allocate nor the four inline-ExprData arms hit Disabler::assert(), so the guard is inert on the fixed code and only trips if someone reintroduces a store-backed node here.

Other factors

The test is well-constructed: it spawns a child so the store state is clean, forces a slab reset via both require() of a CJS file and import() of JSON, and — importantly — has reset.cjs declare 300 string constants so the reused slab slot actually holds different bytes in release builds (where the store isn't 0xAA-poisoned), making the JSON.parse(stdout) equality assertion fail without the fix even outside debug/ASAN. Harness conventions are followed (tempDir, bunEnv, concurrent Promise.all drain, stderr/stdout asserted before exitCode, no per-test timeout override). The two comment-cop bot flags were addressed in d96761b and both threads are marked resolved; the remaining src/ diff is the allocate change plus one explanatory line and the guard. Nothing else outstanding on the timeline.

…e arena

export_replacement_value copied the string bytes into the JSTranspiler's
arena but built the E::EString node with Expr::init, which appends the
node to the thread-local AST store. The Expr is kept in
config.runtime.replace_exports for the life of the transpiler, while the
next synchronous parse on the thread (a require() of a CommonJS file, a
JSON import, ...) resets that store, so later transforms printed the
replacement from a freed slot.

Allocate the node in the same arena as its bytes with Expr::allocate;
the arena lives exactly as long as the config that holds the Expr.
Disabler::scope() makes a debug build panic if anything in this
function appends to the thread-local Expr store again.

The required module in the test declares enough strings to reuse the
slot the replacement node used to occupy, so the test also fails on a
release build without the fix.
@robobun

robobun commented Sep 28, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (a4f1429) in 4881aa2. The four commits are one commit now, and the change is the same.

The rebase had one conflict, in export_replacement_value. main now copies the bytes from utf8.slice(). The resolution keeps that line and keeps Expr::allocate from this PR.

Verification on the new head, with bun bd test test/bundler/transpiler/transpiler.test.js:

Bun 1.4.2 and a release build of main a4f1429148:   the new test fails, Segmentation fault in the child process
src/ from this PR (debug build):                     the new test passes. Full file: 238 pass, 0 fail

#44137 fixes an abort of exports.replace on a re-export list. With a string value, the repro of that PR reaches the freed node too, so this PR is the fix for that case.

@robobun
robobun force-pushed the farm/368105d9/transpiler-replace-string-arena branch from 7a815dd to 4881aa2 Compare September 28, 2026 22:44

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread test/bundler/transpiler/transpiler.test.js 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.

LGTM. The comment nit from the previous review is addressed in a90fada, and the fix itself is a one-call change with a regression test.

What was reviewed:

  • Arena lifetime for the replacement Expr: JSTranspiler._arena is a Box<Arena> dropped with the instance, and the async path's TransformJs holds a jsc::Strong on the wrapper, so the node outlives every sync and async transform that reads it.
  • The remaining arms of export_replacement_value (boolean/number/null/undefined) build ExprData inline with no store append, so the Disabler::scope() guard cannot trip on them; Expr::allocate only calls Store::assert(), which checks initialization, not the disabled flag.
  • Same bug class elsewhere: the define option goes through DefineData::parse, which already deep_clones into the bump arena, so no sibling site is left on the thread-local store.
  • Test: await using on the subprocess, pipes drained via Promise.all, stderr asserted before the exit code, exact expected output for all six sync/async transforms; the serial it(...) matches the neighboring tests in this describe block.
Extended reasoning...

The diff touches src/runtime/api/JSTranspiler.rs (swap Expr::init for Expr::allocate(arena, ..) in export_replacement_value plus a debug-only Disabler guard), a doc-comment rewrite in src/ast/expr.rs, and a new subprocess regression test in test/bundler/transpiler/transpiler.test.js. It touches no security-sensitive surface; it is a memory-lifetime fix for AST nodes that previously pointed into a thread-local slab reset by later parses. Approval is decided by the change being three lines of production code whose ownership story checks out from the code (boxed instance arena, Strong ref held by the async job), the only sibling site (defines) already handling the same class, no CODEOWNERS entry covering the changed files, and the prior review's single nit having been addressed by the follow-up commit.

@robobun

robobun commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: consolidated into #44137 on request. #44137 has both commits of this PR (4881aa2 and a90fada) and the test. The fix for export let A in #44137 reads the value of the entry, so it needs this change in the same PR.

@robobun robobun closed this Sep 29, 2026
@robobun

robobun commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator Author

This PR is closed and its change is in #44137. A follow-up for the change is in #44137 (comment):

  • The debug-only Disabler::scope() guard stops a debug build when toString() of a String object value loads a module. Two commits on the branch robobun/d01710db/replace-string-values-no-guard remove it.
  • The repro results on current main: 7 inputs, each fails on a release build of main a4f1429 and on Bun 1.4.2, and prints the right text with the change and on Bun 1.3.13.

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