Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions src/ast/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2612,6 +2612,7 @@ pub mod g;
pub mod known_global;
pub mod new_store;
pub mod op;
pub mod owned_values;
pub mod s;
pub mod scope;
pub mod server_component_boundary;
Expand Down Expand Up @@ -2657,6 +2658,7 @@ pub use g::NamespaceAlias;
pub use nodes::*;
pub use op as Op;
pub use op::Code as OpCode;
pub use owned_values::OwnedValues;
pub use s as S;
pub use s::Kind as LocalKind;
pub use scope::Scope;
Expand Down
69 changes: 69 additions & 0 deletions src/ast/owned_values.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
use core::ptr::NonNull;

use crate::expr::Data;
use crate::{E, StoreRef};

/// Nodes and string bytes on the global heap, for values that outlive every arena and AST store.
#[derive(Default)]
pub struct OwnedValues {
bytes: Vec<Box<[u8]>>,
// The boxes are load-bearing: a `StoreRef` points into each, and a bare `Vec` moves its items when it grows.
#[expect(clippy::vec_box)]
strings: Vec<Box<E::EString>>,
}

/// The node keeps its address, and nothing writes to it through `list` before the drop.
fn keep<T>(list: &mut Vec<Box<T>>, node: T) -> StoreRef<T> {
list.push(Box::new(node));
let node: &mut T = list.last_mut().expect("pushed above");
StoreRef::from(NonNull::from(node))
}

impl OwnedValues {
/// A string node with its own copy of `bytes`.
pub fn string(&mut self, bytes: &[u8]) -> Data {
self.bytes.push(bytes.into());
let node = E::EString::init(self.bytes.last().expect("pushed above"));
Data::EString(keep(&mut self.strings, node))
}

/// About how many bytes the values take.
pub fn memory_cost(&self) -> usize {
self.bytes.iter().map(|bytes| bytes.len()).sum::<usize>()
+ self.bytes.capacity() * size_of::<Box<[u8]>>()
+ self.strings.len() * size_of::<E::EString>()
+ self.strings.capacity() * size_of::<Box<E::EString>>()
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn a_string_stays_readable_while_more_are_added() {
let mut values = OwnedValues::default();
let mut texts: Vec<Vec<u8>> = (0..65).map(|i| vec![b'a' + (i % 26) as u8; i]).collect();
let expected = texts.clone();
let nodes: Vec<Data> = texts.iter().map(|text| values.string(text)).collect();
// A node that only borrowed its text would read these bytes.
for text in &mut texts {
text.fill(b'#');
}
for (node, text) in nodes.iter().zip(&expected) {
let Data::EString(string) = node else {
panic!("not a string");
};
assert_eq!(string.slice8(), text.as_slice());
}
// With a capacity above the length, a cost that counted the length would not match.
assert!(values.bytes.capacity() > nodes.len() && values.strings.capacity() > nodes.len());
let bytes: usize = expected.iter().map(Vec::len).sum();
let slots = values.bytes.capacity() * size_of::<Box<[u8]>>()
+ values.strings.capacity() * size_of::<Box<E::EString>>();
assert_eq!(
values.memory_cost(),
bytes + nodes.len() * size_of::<E::EString>() + slots
);
}
}
22 changes: 22 additions & 0 deletions src/bundler/defines.rs
Original file line number Diff line number Diff line change
Expand Up @@ -449,3 +449,25 @@ impl DefineDataExt for DefineData {

// `dots: StringHashMap<Vec<DotDefine>>` and `identifiers` drop their
// contents automatically; `Box<Define>` frees `self`. No explicit Drop needed.

#[cfg(test)]
mod tests {
use super::{Define, DotDefine, IdentifierDefine};
use bun_collections::StringHashMapKey;

/// `Define::memory_cost` takes the cost of the globals from a constant. This counts what the table holds.
#[test]
fn memory_cost_covers_the_globals_of_the_table() {
let table = Define::with_pure_globals(false, false);
let mut counted = table.identifiers.capacity()
* size_of::<(StringHashMapKey, IdentifierDefine)>()
+ table.dots.capacity() * size_of::<(StringHashMapKey, Vec<DotDefine>)>();
for (_, defines) in table.dots.iter() {
for define in defines {
counted += size_of::<DotDefine>() + define.parts.len() * size_of::<Box<[u8]>>();
counted += define.parts.iter().map(|part| part.len()).sum::<usize>();
}
}
assert_eq!(table.memory_cost(), counted);
}
}
31 changes: 31 additions & 0 deletions src/js_parser/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,37 @@ pub mod defines {
}
}

/// What `insert_global` allocates for the two tables of globals that `with_pure_globals` loads.
const GLOBALS_COST: usize = {
const fn cost(globals: &[&[&[u8]]]) -> usize {
let (mut cost, mut global) = (0, 0);
while global < globals.len() {
let parts = globals[global];
cost += size_of::<DotDefine>() + parts.len() * size_of::<Box<[u8]>>();
let mut part = 0;
while part < parts.len() {
cost += parts[part].len();
part += 1;
}
global += 1;
}
cost
}
cost(crate::defines_table::GLOBAL_NO_SIDE_EFFECT_PROPERTY_ACCESSES)
+ cost(
crate::defines_table::GLOBAL_NO_SIDE_EFFECT_FUNCTION_CALLS_SAFE_FOR_TO_STRING,
)
};

/// About how many bytes the table owns beside itself. A user define counts by its slot only.
pub fn memory_cost(&self) -> usize {
Self::GLOBALS_COST
+ self.identifiers.capacity()
* size_of::<(bun_collections::StringHashMapKey, IdentifierDefine)>()
+ self.dots.capacity()
* size_of::<(bun_collections::StringHashMapKey, Vec<DotDefine>)>()
}
Comment thread
robobun marked this conversation as resolved.
Resolved

/// The dot defines whose last part is `name` (`NODE_ENV` for
/// `process.env.NODE_ENV`).
#[inline]
Expand Down
95 changes: 75 additions & 20 deletions src/runtime/api/JSTranspiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,12 @@ pub(crate) struct JSTranspiler {
/// Empty between calls: kept for its capacity.
pub(crate) scan_pass_result: JsCell<ScanPassResult<'static>>,
pub(crate) buffer_writer: JsCell<Option<JSPrinter::BufferWriter>>,
// Arena bulk-frees the config strings. Boxed so its
// The heap of the constructor if an object or array define is in it, else the process heap. Boxed so its
// address is stable across the move into `Box<JSTranspiler>` —
// `transpiler.arena` holds a `&'static Arena` pointing into it.
pub(crate) _arena: Box<Arena>,
/// About what the instance owns beside itself. Written once, before the wrapper exists.
estimated_size: usize,
pub(crate) ref_count: bun_ptr::RefCount<JSTranspiler>,
}

Expand All @@ -74,6 +76,8 @@ pub(crate) struct Config {
pub(crate) macros_buf: Box<[u8]>,
pub(crate) log: bun_ast::Log,
pub(crate) runtime: Runtime::Features,
/// The string values of `runtime.replace_exports` and of the define table.
pub(crate) values: bun_ast::OwnedValues,
pub(crate) tree_shaking: bool,
pub(crate) trim_unused_imports: Option<bool>,

Expand All @@ -99,6 +103,7 @@ impl Default for Config {
top_level_await: true,
..Default::default()
},
values: Default::default(),
tree_shaking: false,
trim_unused_imports: None,
dead_code_elimination: true,
Expand Down Expand Up @@ -164,7 +169,6 @@ impl Config {
&mut self,
global: &JSGlobalObject,
object: Option<&TranspilerOptions<'_>>,
arena: &Arena,
) -> JsResult<()> {
let Some(object) = object else {
return Ok(());
Expand Down Expand Up @@ -526,7 +530,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.values)?
{
replacements
.put(&key, bun_ast::runtime::ReplaceableExport::Replace(expr))
.map_err(|_| bun_jsc::JsError::OutOfMemory)?;
Expand All @@ -535,9 +541,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.values,
)? {
let replacement_key = value.get_index(global, 0)?;
let slice = replacement_key.to_bun_string(global)?;
let replacement_name = slice.to_owned_slice();
Expand Down Expand Up @@ -840,7 +848,7 @@ impl TransformTask {
fn export_replacement_value(
value: JSValue,
global: &JSGlobalObject,
arena: &Arena,
values: &mut bun_ast::OwnedValues,
) -> JsResult<Option<bun_ast::Expr>> {
if value.is_boolean() {
return Ok(Some(Expr {
Expand Down Expand Up @@ -874,18 +882,11 @@ fn export_replacement_value(

if value.is_string() {
let str = value.to_js_string_view(global)?;
let utf8 = str.to_utf8();
// 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(utf8.slice());
// The result outlives every parse, so don't allocate it in the thread-local store.
return Ok(Some(Expr::allocate(
arena,
bun_ast::E::EString::init(data),
bun_ast::Loc::EMPTY,
)));
return Ok(Some(Expr {
data: values.string(str.to_utf8().slice()),
loc: bun_ast::Loc::EMPTY,
}));
}

Ok(None)
Expand Down Expand Up @@ -922,7 +923,7 @@ impl JSTranspiler {
// covers config.log, config.tsconfig, arena. ref_count.clearWithoutDestructor is a
// no-op when we never handed out refs. `bun.destroy(this)` → Box not yet created.

config.from_js(global, config_arg, arena_ref)?;
config.from_js(global, config_arg)?;

if (config.log.warnings + config.log.errors) > 0 {
return Err(global.throw_value(
Expand Down Expand Up @@ -952,12 +953,13 @@ impl JSTranspiler {
}
};

let this: Box<JSTranspiler> = Box::new(JSTranspiler {
let mut this: Box<JSTranspiler> = Box::new(JSTranspiler {
config: JsCell::new(config),
_arena: arena,
transpiler: JsCell::new(transpiler),
scan_pass_result: JsCell::new(ScanPassResult::init()),
buffer_writer: JsCell::new(None),
estimated_size: 0,
ref_count: bun_ptr::RefCount::init(),
});
// errdefer past this point → `this: Box<_>` drops and runs Drop for JSTranspiler.
Expand Down Expand Up @@ -1018,8 +1020,36 @@ impl JSTranspiler {
transpiler.options.react_fast_refresh = false;
transpiler.options.repl_mode = config.repl_mode;

let heap_bytes = this._arena.allocated_bytes();
let keeps_heap = heap_bytes != 0
&& !own_define_strings(&mut transpiler.options.define, &mut config.values);
// A define has its text in `config.transform`, in the options of the transpiler, and in the table.
let define_text = config.transform.define.as_ref().map_or(0, |define| {
let texts = define.keys.iter().chain(&define.values);
3 * texts.map(|text| text.len()).sum::<usize>()
});
let mut size = transpiler.options.define.memory_cost()
+ define_text
+ config.values.memory_cost()
+ config.tsconfig_buf.len()
+ config.macros_buf.len();
if keeps_heap {
size += heap_bytes;
} else {
// Not a heap for each instance: mimalloc has 64,512 for the process, and instances wait for the GC.
let resting = Box::new(Arena::borrowing_default());
// SAFETY: as for `arena_ref` above. `this` holds the box for as long as it holds `transpiler`.
transpiler.arena = unsafe { bun_ptr::detach_lifetime_ref::<Arena>(resting.as_ref()) };
this._arena = resting;
}
this.estimated_size = size;
Ok(bun_core::heap::into_raw(this))
}

/// The GC calls this from its marking threads.
pub(crate) fn estimated_size(&self) -> usize {
self.estimated_size
}
}

impl Drop for JSTranspiler {
Expand Down Expand Up @@ -1047,6 +1077,31 @@ impl Drop for JSTranspiler {
}
}

/// Copies the string values of `define` into `values`. `false`, and no copy, when a value is an object or an array.
fn own_define_strings(
define: &mut bun_js_parser::defines::Define,
values: &mut bun_ast::OwnedValues,
) -> bool {
use bun_ast::ExprData::{EArray, EObject, EString};
let dots = define.dots.values_mut().flatten();
let datas = define
.identifiers
.values_mut()
.chain(dots.map(|dot| &mut dot.data));
let mut strings = Vec::new();
for data in datas {
match data.value {
EObject(_) | EArray(_) => return false,
EString(string) => strings.push((data, string)),
_ => {}
}
}
for (data, string) in strings {
data.value = values.string(string.slice8());
}
true
}

/// `scan` / `transform_sync` / `scan_imports` temporarily point the long-lived
/// `Transpiler` at a stack-local `Arena`/`Log`; on EVERY exit (including `?`
/// and early `return Err`) those must be restored before the locals drop, or
Expand Down
1 change: 1 addition & 0 deletions src/runtime/api/Transpiler.bind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const promiseOfText = table.instance("Promise").holding({ value: table.string, r
export const Transpiler = b.class({
rustPath: "crate::api::js_transpiler::JSTranspiler",
refCounted: true,
estimatedSize: true,
construct: {
args: { options: TranspilerOptions.nullable },
expected: { options: "Expected an object" },
Expand Down
Loading
Loading