From 4881aa298f7a1c06799c69fb9cf20c55bd34fcff Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:14:41 +0000 Subject: [PATCH 1/3] Bun.Transpiler: allocate exports.replace string values in the instance 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. --- src/ast/expr.rs | 2 +- src/runtime/api/JSTranspiler.rs | 6 +- test/bundler/transpiler/transpiler.test.js | 67 ++++++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/src/ast/expr.rs b/src/ast/expr.rs index cfedeb4f9bf8..a26cc90151a4 100644 --- a/src/ast/expr.rs +++ b/src/ast/expr.rs @@ -1039,7 +1039,7 @@ impl IntoExprData for &E::EString { impl Expr { /// When the lifetime of an Expr.Data's pointer must exist longer than reset() is called, use this function. /// Be careful to free the memory (or use an arena that does it for you) - /// Also, prefer Expr.init or Expr.alloc when possible. This will be slower. + /// Prefer `Expr::init` for nodes that only need to live until the current parse's reset(). #[inline] pub fn allocate(bump: &Bump, st: T, loc: Loc) -> Expr { data::Store::assert(); diff --git a/src/runtime/api/JSTranspiler.rs b/src/runtime/api/JSTranspiler.rs index df5d4bc5f529..d3327a117f5c 100644 --- a/src/runtime/api/JSTranspiler.rs +++ b/src/runtime/api/JSTranspiler.rs @@ -878,6 +878,9 @@ fn export_replacement_value( global: &JSGlobalObject, arena: &Arena, ) -> JsResult> { + // The result outlives every parse, so it must not be built in the thread-local store. + let _guard = bun_ast::expr::Disabler::scope(); + if value.is_boolean() { return Ok(Some(Expr { data: bun_ast::ExprData::EBoolean(bun_ast::E::Boolean { @@ -916,7 +919,8 @@ fn export_replacement_value( // `E::EString::init` erases the borrow to `'static` per the AST // crate's `Str` convention (see ast/E.rs). let data = arena.alloc_slice_copy(utf8.slice()); - return Ok(Some(Expr::init( + return Ok(Some(Expr::allocate( + arena, bun_ast::E::EString::init(data), bun_ast::Loc::EMPTY, ))); diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 2ec82493d4ff..ea3d86671562 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2404,6 +2404,73 @@ export default class { expect(output.includes("localVarToReplace")).toBe(true); expect(output.includes("localVarToRemove")).toBe(false); }); + + it("string replacement values survive other modules being loaded after the transpiler is created", async () => { + // String values are the only replacement values that are heap-allocated + // AST nodes. They used to be allocated in the thread-local AST store, + // which every synchronous parse on the main thread (a require() of a + // CommonJS file, an import of a JSON file, ...) resets and then refills + // with that module's own nodes, so a later transform printed the + // replacement out of memory that by then held some other node. Debug + // builds poison the store on reset, so any reset exposes the stale read; + // release builds only misbehave once the slot has been reused, which is + // why the required module declares a few hundred strings (a few dozen + // are enough to reach the slot). + const resetLines = []; + for (let i = 0; i < 300; i++) { + resetLines.push(`const s${i} = "other string ${i}";`); + } + resetLines.push("module.exports = { s0, s299 };"); + + using dir = tempDir("transpiler-replace-string-values", { + "reset.cjs": resetLines.join("\n"), + "reset.json": `{}`, + "entry.mjs": ` + const transpiler = new Bun.Transpiler({ + exports: { + replace: { + foo: "bar", + getStaticProps: ["__N_SSG", "ssg"], + default: "dflt", + }, + }, + }); + + require("./reset.cjs"); + await import("./reset.json"); + + console.log( + JSON.stringify([ + transpiler.transformSync("export const foo = 1;"), + transpiler.transformSync("export function getStaticProps() {}"), + transpiler.transformSync("export default 1;"), + await transpiler.transform("export const foo = 1;"), + await transpiler.transform("export function getStaticProps() {}"), + await transpiler.transform("export default 1;"), + ]), + ); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual([ + 'export const foo = "bar";\n', + 'export var __N_SSG = "ssg";\n', + 'export default "dflt";\n', + 'export const foo = "bar";\n', + 'export var __N_SSG = "ssg";\n', + 'export default "dflt";\n', + ]); + expect(exitCode).toBe(0); + }); }); const bunTranspiler = new Bun.Transpiler({ From a90fada041fb42e61d9b784c224c6516c5fb856c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:00:13 +0000 Subject: [PATCH 2/3] test: shorten the comment in the exports.replace string test --- test/bundler/transpiler/transpiler.test.js | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index ea3d86671562..5eca13297c30 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2406,16 +2406,7 @@ export default class { }); it("string replacement values survive other modules being loaded after the transpiler is created", async () => { - // String values are the only replacement values that are heap-allocated - // AST nodes. They used to be allocated in the thread-local AST store, - // which every synchronous parse on the main thread (a require() of a - // CommonJS file, an import of a JSON file, ...) resets and then refills - // with that module's own nodes, so a later transform printed the - // replacement out of memory that by then held some other node. Debug - // builds poison the store on reset, so any reset exposes the stale read; - // release builds only misbehave once the slot has been reused, which is - // why the required module declares a few hundred strings (a few dozen - // are enough to reach the slot). + // Release builds only reuse the AST store slot after a few dozen string declarations. const resetLines = []; for (let i = 0; i < 300; i++) { resetLines.push(`const s${i} = "other string ${i}";`); From 1e3a2881ca37a8f73d18e34a9eb8c30b1e59938c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:50:16 +0000 Subject: [PATCH 3/3] ci: retrigger