Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 48 additions & 22 deletions src/runtime/api/JSTranspiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use bun_options_types::TargetExt as _;
use std::io::Write as _;

use crate::node::{Encoding, StringOrBuffer};
use bun_alloc::{Arena, ArenaVec}; // bumpalo::Bump / bumpalo::collections::Vec re-exports
use bun_alloc::{Arena, ArenaVec};
use bun_ast::Expr;
use bun_ast::Loader;
use bun_ast::{ImportRecord, ImportRecordFlags};
Expand Down Expand Up @@ -47,9 +47,14 @@ pub struct JSTranspiler {
pub scan_pass_result: JsCell<ScanPassResult>,
pub buffer_writer: JsCell<Option<JSPrinter::BufferWriter>>,
pub log_level: bun_ast::Level,
// Arena bulk-frees the config strings. Boxed so its
// address is stable across the move into `Box<JSTranspiler>` —
// `transpiler.arena` holds a `&'static Arena` pointing into it.
// Resting-state arena handle for the inner `Transpiler` between host-fn
// calls (every parse swaps in a fresh per-call `Arena::new()` via
// `TranspilerStateGuard`). Wraps `mi_heap_main()` via
// `Arena::borrowing_default()` so no per-instance mi_heap is created;
// Drop is a no-op. Boxed so its address is stable across the move into
// `Box<JSTranspiler>` since `transpiler.arena` holds a `&'static Arena`
// pointing into it. Config strings (`exports.replace`) are owned by
// `Config.replace_exports_bufs` instead so they drop with the instance.
pub arena: Box<Arena>,
// Intrusive refcount field for `bun_ptr::IntrusiveRc<JSTranspiler>`:
// single-thread intrusive `bun.ptr.RefCount` because `*JSTranspiler`
Expand All @@ -74,6 +79,12 @@ pub struct Config {
pub macros_buf: Box<[u8]>,
pub log: bun_ast::Log,
pub runtime: Runtime::Features,
// Owned backing storage for `runtime.replace_exports` string-valued
// entries (`E::EString.data` borrows a `Box` in this vec via an erased
// `'static`). Declared after `runtime` so the borrowing `Expr`s drop
// first. Takes the place of the per-instance bump arena the reference
// implementation uses for `config.fromJS`.
pub replace_exports_bufs: Vec<Box<[u8]>>,
pub tree_shaking: bool,
pub trim_unused_imports: Option<bool>,
pub inlining: bool,
Expand All @@ -100,6 +111,7 @@ impl Default for Config {
top_level_await: true,
..Default::default()
},
replace_exports_bufs: Vec::new(),
tree_shaking: false,
trim_unused_imports: None,
inlining: false,
Expand Down Expand Up @@ -159,12 +171,7 @@ const PROP_ITER_OPTS: JSPropertyIteratorOptions = JSPropertyIteratorOptions {
impl Config {
// NOTE: out-param constructor kept as `&mut self` because `self` is a pre-initialized
// field on `JSTranspiler` (in-place mutation), not a fresh value to return.
pub fn from_js(
&mut self,
global: &JSGlobalObject,
object: JSValue,
arena: &Arena,
) -> JsResult<()> {
pub fn from_js(&mut self, global: &JSGlobalObject, object: JSValue) -> JsResult<()> {
if object.is_undefined_or_null() {
return Ok(());
}
Expand Down Expand Up @@ -584,7 +591,9 @@ impl Config {
// `V: Default` upstream and `ReplaceableExport` has no Default. Compute
// the value first, then `put` (which upserts without needing a default
// slot).
if let Some(expr) = export_replacement_value(value, global, arena)? {
if let Some(expr) =
export_replacement_value(value, global, &mut self.replace_exports_bufs)?
{
replacements
.put(&key, bun_ast::runtime::ReplaceableExport::Replace(expr))
.map_err(|_| bun_jsc::JsError::OutOfMemory)?;
Expand All @@ -593,9 +602,11 @@ impl Config {

if value.is_object() && value.get_length(global)? == 2 {
let replacement_value = value.get_index(global, 1)?;
if let Some(to_replace) =
export_replacement_value(replacement_value, global, arena)?
{
if let Some(to_replace) = export_replacement_value(
replacement_value,
global,
&mut self.replace_exports_bufs,
)? {
let replacement_key = value.get_index(global, 0)?;
let slice =
OwnedString::new(replacement_key.to_bun_string(global)?);
Expand Down Expand Up @@ -911,7 +922,7 @@ impl<'a> Drop for TransformTask<'a> {
fn export_replacement_value(
value: JSValue,
global: &JSGlobalObject,
arena: &Arena,
bufs: &mut Vec<Box<[u8]>>,
) -> JsResult<Option<bun_ast::Expr>> {
if value.is_boolean() {
return Ok(Some(Expr {
Expand Down Expand Up @@ -949,11 +960,13 @@ fn export_replacement_value(
let zig_str = value.get_zig_string(global)?;
let mut buf = Vec::new();
write!(&mut buf, "{}", zig_str).expect("unreachable");
// Bump-allocate so the bytes
// live as long as the JSTranspiler arena that owns the resulting Expr;
// `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(&buf);
// Stash the bytes in a `Box` owned by `Config` so they are freed when
// the `JSTranspiler` drops. `E::EString::init` erases the borrow to
// `'static` per the AST crate's `Str` convention (see ast/E.rs); the
// `Box` heap allocation is address-stable across moves of `Config`
// and across reallocation of the outer `Vec`.
bufs.push(buf.into_boxed_slice());
let data: &[u8] = bufs.last().expect("just pushed");
return Ok(Some(Expr::init(
bun_ast::E::EString::init(data),
bun_ast::Loc::EMPTY,
Expand Down Expand Up @@ -987,7 +1000,20 @@ impl JSTranspiler {
log: bun_ast::Log::init(),
..Default::default()
};
let arena = Box::new(Arena::new());
// `borrowing_default()` wraps `mi_heap_main()` so the inner `Transpiler`
// allocates from the process-global heap, matching
// `Transpiler.Transpiler.init(bun.default_allocator, ...)` and the VM
// transpiler in `jsc_hooks.rs`. Every parse (`scan` / `transformSync` /
// `scanImports` / async `transform`) swaps in a fresh per-call
// `Arena::new()` via `TranspilerStateGuard`, so this arena is only the
// resting-state handle. `configure_defines` bump-allocates user-supplied
// `define:` values here (defines.rs slow path), which then live for the
// process lifetime; that is the reference behavior (Zig routes
// `configureDefines` through `bun.default_allocator` and never frees the
// inner transpiler) and the auto-injected defaults hit the fast path
// that never touches this arena. An owned `mi_heap_new()` here would
// instead pin a dedicated mimalloc heap per `new Bun.Transpiler()`.
let arena = Box::new(Arena::borrowing_default());
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
// SAFETY: `arena` is heap-allocated and moved (as a Box) into `Box<JSTranspiler>` below;
// its address is stable for the lifetime of the JSTranspiler. `Transpiler<'static>` forces
// the borrow to 'static, so launder through a raw ptr.
Expand All @@ -1003,7 +1029,7 @@ impl JSTranspiler {
} else {
JSValue::UNDEFINED
};
config.from_js(global, config_arg, arena_ref)?;
config.from_js(global, config_arg)?;

if global.has_exception() {
return Err(bun_jsc::JsError::Thrown);
Expand Down
31 changes: 31 additions & 0 deletions test/js/bun/transpiler/transpiler-arena-heap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { heapStats } from "bun:jsc";
import { expect, test } from "bun:test";

// The inner `Transpiler` is constructed with the process-global allocator
// (`bun.default_allocator` in the reference implementation). Constructing a
// `Bun.Transpiler` must not `mi_heap_new()` a dedicated mimalloc heap per
// instance; each owned heap pins tens of KB of segment metadata for the
// object's lifetime.
test("new Bun.Transpiler() does not create a per-instance mimalloc heap", () => {
// Warm up: first construction may lazily initialize shared state.
new Bun.Transpiler({ loader: "ts" });
Bun.gc(true);

const before = heapStats({ dump: true }).mimallocDump.heaps.length;

const instances: Bun.Transpiler[] = [];
const N = 64;
for (let i = 0; i < N; i++) {
instances.push(new Bun.Transpiler({ loader: "ts" }));
}

const after = heapStats({ dump: true }).mimallocDump.heaps.length;

// Allow a small slack for unrelated lazy heap creation, but nowhere near
// one heap per instance.
expect(after - before).toBeLessThan(N / 4);

// Keep `instances` live across the measurement and verify they still work.
expect(instances[0].transformSync("export const x: number = 1;")).toContain("const x = 1");
expect(instances[N - 1].transformSync("export const y: number = 2;")).toContain("const y = 2");
});
Loading