Skip to content
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
ee6c3be
perf(threading): cache thread_id::current() in a #[thread_local] slot
Jarred-Sumner May 18, 2026
d4252e9
ast: arena-back Ast.symbols/parts/import_records via ArenaVec
Jarred-Sumner May 18, 2026
766b552
js_parser: zero-copy move of symbols/parts/import_records in to_ast
Jarred-Sumner May 18, 2026
a07bd69
css/js_printer/resolver: adapt to arena-backed Ast lists
Jarred-Sumner May 18, 2026
1dac3fb
ast/js_parser: thread arena through Ast::empty_in/from_parts; decoupl…
Jarred-Sumner May 18, 2026
e035622
ast: keep symbol::Map storage on the global allocator (NestedList = V…
Jarred-Sumner May 18, 2026
b14f5ee
bundler: thread Graph<'a>/LinkerGraph<'a>/JSAst<'a>, borrow Graph.hea…
Jarred-Sumner May 18, 2026
3095c91
bundler: finish threading arena lifetime through Graph/LinkerContext/…
Jarred-Sumner May 18, 2026
beccfc2
runtime/jsc: thread arena lifetime through ParseOptions/ParseResult c…
Jarred-Sumner May 18, 2026
44fc2ae
bundler: re-seat linker-side parts/import_records onto Graph.heap in …
Jarred-Sumner May 18, 2026
e076274
bundler: zero-copy allocator retag in take_ast_ownership instead of e…
Jarred-Sumner May 18, 2026
f2a0af6
bundler: TLS-cache Worker in get_worker; lock only on first touch per…
Jarred-Sumner May 18, 2026
3a5d33e
[autofix.ci] apply automated fixes
autofix-ci[bot] May 18, 2026
4aa39de
bundler: dense Vec<Option<RequestedExports>> instead of ArrayHashMap<…
Jarred-Sumner May 18, 2026
f91c936
paths: pool JoinScratch buffer instead of heap-allocating per join
Jarred-Sumner May 18, 2026
74017e2
resolver: borrow ESM ConditionsMap instead of deep-cloning per resolve
Jarred-Sumner May 18, 2026
c05b341
resolver: avoid eager Pragma clone for the discarded outer Result in …
Jarred-Sumner May 18, 2026
e856dd8
bundler: spell out BundledAst::empty_in defaults instead of round-tri…
Jarred-Sumner May 18, 2026
696e136
bundler: drop redundant Box of already-'static source path in on_pars…
Jarred-Sumner May 18, 2026
e2f6a24
bundler: format source identifier once in scan_imports_and_exports sy…
Jarred-Sumner May 18, 2026
2f17a5c
bundler: use AutoBitSet for named_ir_indices in schedule_barrel_defer…
Jarred-Sumner May 18, 2026
e9eec9f
resolver: borrow Entry in value_for_key/resolve_exports instead of de…
Jarred-Sumner May 18, 2026
624bb56
ast_alloc: keep bump cursor across same-heap set_thread_heap; invalid…
Jarred-Sumner May 18, 2026
e21d0b8
bun_alloc: BabyVec<'a,T> with u32 len/cap; retarget ArenaVec alias
Jarred-Sumner May 18, 2026
1d8e785
[autofix.ci] apply automated fixes
autofix-ci[bot] May 19, 2026
1d1f2ed
threading: drop leftover NTF_* notify-stats instrumentation
Jarred-Sumner May 19, 2026
f368ec8
bundler: drop stale 'static PORT NOTE on JSAst alias
Jarred-Sumner May 19, 2026
65e2ccd
ast: drop stale Ast::empty() reference from comment
Jarred-Sumner May 19, 2026
e7dc28f
jsc: move feature(allocator_api) out of crate doc comment
Jarred-Sumner May 19, 2026
f357814
bundler: fix incorrect SAFETY claim on Worker::arena lifetime
Jarred-Sumner May 19, 2026
efe38b7
bun_alloc: hard-assert BabyVec u32 capacity instead of clamping
Jarred-Sumner May 19, 2026
3f4f1c3
bun_alloc: release-assert full range in BabyVec::drain
Jarred-Sumner May 19, 2026
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
65 changes: 22 additions & 43 deletions src/ast/ast_result.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@

use bun_alloc::AstAlloc;
use bun_collections::array_hash_map::{AutoContext, StringContext};
use bun_collections::{ArrayHashMap, StringArrayHashMap, StringHashMap, VecExt};
use bun_collections::{ArrayHashMap, StringArrayHashMap, StringHashMap};

use crate::import_record::ImportRecord;
use crate::runtime;
use crate::{
CharFreq, ExportsKind, Expr, InlinedEnumValue, LocRef, NamedExport, NamedImport, Part, Range,
Expand All @@ -17,12 +16,11 @@ use crate::{

use crate::part::List as PartList;
use crate::symbol::List as SymbolList;
// `ImportRecord.List` is `Vec<ImportRecord>` (`bun_ast::import_record::List`).
type ImportRecordList = Vec<ImportRecord>;
type ImportRecordList<'a> = crate::import_record::List<'a>;

pub type TopLevelSymbolToParts = ArrayHashMap<Ref, Vec<u32>>;

pub struct Ast {
pub struct Ast<'a> {
pub approximate_newline_count: usize,
pub has_lazy_export: bool,
pub runtime_imports: runtime::Imports,
Expand Down Expand Up @@ -51,16 +49,16 @@ pub struct Ast {

/// These are stored at the AST level instead of on individual AST nodes so
/// they can be manipulated efficiently without a full AST traversal
pub import_records: ImportRecordList,
pub import_records: ImportRecordList<'a>,

// `hashbang`/`directive` are `[]const u8` slices into source text (not
// freed in Zig `deinit`). `StoreStr` records them under the same
// lifetime-erased contract as `StoreRef`.
pub hashbang: StoreStr,
pub directive: Option<StoreStr>,
pub parts: PartList,
pub parts: PartList<'a>,
// This list may be mutated later, so we should store the capacity
pub symbols: SymbolList,
pub symbols: SymbolList<'a>,
pub module_scope: Scope,
pub char_freq: Option<CharFreq>,
pub exports_ref: Ref,
Expand Down Expand Up @@ -102,8 +100,12 @@ pub struct Ast {
// PORT NOTE: Zig field defaults reference named constants (`Ref.None`, `logger.Range.None`,
// `ExportsKind.none`, `Target.browser`) whose equivalence to the Rust types' `Default::default()`
// is unverified across crates, so spell them out here instead of `#[derive(Default)]`.
impl Default for Ast {
fn default() -> Self {
//
// `parts`/`symbols`/`import_records` are now `ArenaVec`s and need an allocator,
// so `Default` no longer applies; use `Ast::empty_in(arena)` (or `Ast::empty()`
// with the process-static arena for placeholder values).
Comment thread
Jarred-Sumner marked this conversation as resolved.
Outdated
impl<'a> Ast<'a> {
pub fn empty_in(arena: &'a bun_alloc::MimallocArena) -> Self {
Self {
approximate_newline_count: 0,
has_lazy_export: false,
Expand All @@ -121,11 +123,11 @@ impl Default for Ast {
import_keyword: Range::NONE,
export_keyword: Range::NONE,
top_level_await_keyword: Range::NONE,
import_records: Default::default(),
import_records: ImportRecordList::new_in(arena),
hashbang: StoreStr::EMPTY,
directive: None,
parts: Default::default(),
symbols: Default::default(),
parts: PartList::new_in(arena),
symbols: SymbolList::new_in(arena),
module_scope: Scope::default(),
char_freq: None,
exports_ref: Ref::NONE,
Expand Down Expand Up @@ -175,39 +177,16 @@ pub type NamedExports = StringArrayHashMap<NamedExport, StringContext, AstAlloc>
pub type ConstValuesMap = ArrayHashMap<Ref, Expr, AutoContext, AstAlloc>;
pub type TsEnumsMap = ArrayHashMap<Ref, StringHashMap<InlinedEnumValue>, AutoContext, AstAlloc>;

impl Ast {
pub fn from_parts(parts: Box<[Part]>) -> Ast {
impl<'a> Ast<'a> {
pub fn from_parts(parts: Box<[Part]>, arena: &'a bun_alloc::MimallocArena) -> Ast<'a> {
let mut p = PartList::with_capacity_in(parts.len(), arena);
p.extend(parts.into_vec());
Ast {
parts: PartList::from_owned_slice(parts),
runtime_imports: Default::default(),
..Default::default()
parts: p,
..Ast::empty_in(arena)
}
}

// Zig `initTest` borrowed `parts` via `Part.List.fromBorrowedSliceDangerous`
// and relied on explicit `deinit` never being called. `Vec::drop` now
// unconditionally guards on `Origin::Borrowed` (not debug-only), so unwrapping
// the `ManuallyDrop` is safe — the caller's slice is never freed by `Ast`'s Drop.
pub fn init_test(parts: &[Part]) -> Ast {
Ast {
// SAFETY: test-only helper; the borrowed list is tagged
// `Origin::Borrowed`, so `Vec::drop` skips the free, and no
// grow/free path is reached on `Ast.parts` before the borrow ends.
parts: std::mem::ManuallyDrop::into_inner(unsafe {
PartList::from_borrowed_slice_dangerous(parts)
}),
runtime_imports: Default::default(),
..Default::default()
}
}

// Zig: `pub const empty = Ast{ .parts = Part.List{}, .runtime_imports = .{} };`
// All fields use their defaults, so `Ast::default()` is the Rust equivalent.
// TODO(port): if a true `const` is required at use sites, revisit once field types are `const`-constructible.
pub fn empty() -> Ast {
Ast::default()
}

// Zig: `std.json.stringify(self.parts, opts, stream)` where
// `opts = .{ .whitespace = .{ .separator = true } }`. In the Rust port the
// `crate::JsonWriter` trait stands in for the configured
Expand All @@ -219,7 +198,7 @@ impl Ast {
pub fn to_json<W: crate::JsonWriter>(&self, stream: &mut W) -> Result<(), bun_core::Error> {
// PORT NOTE: `whitespace.separator = true` is the caller's
// responsibility when constructing the `JsonWriter` impl.
stream.write(self.parts.slice())
stream.write(self.parts.as_slice())
}

// Zig `deinit` only freed `parts`, `symbols`, `import_records` via `bun.default_allocator`,
Expand Down
2 changes: 1 addition & 1 deletion src/ast/import_record.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ bitflags::bitflags! {
}
}

pub type List = Vec<ImportRecord>;
pub type List<'a> = bun_alloc::ArenaVec<'a, ImportRecord>;

#[repr(u8)]
#[derive(Copy, Clone, Eq, PartialEq, Debug, Default)]
Expand Down
2 changes: 1 addition & 1 deletion src/ast/nodes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1137,7 +1137,7 @@ pub struct Part {
}

pub type PartImportRecordIndices = Vec<u32, bun_alloc::AstAlloc>;
pub type PartList = Vec<Part>;
pub type PartList<'a> = bun_alloc::ArenaVec<'a, Part>;
Comment thread
Jarred-Sumner marked this conversation as resolved.

#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum PartTag {
Expand Down
35 changes: 18 additions & 17 deletions src/ast/symbol.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
use core::sync::atomic::{AtomicU32, Ordering};
use std::cell::Cell;

use bun_collections::VecExt;

use crate::ImportItemStatus;
use crate::base::Ref;
use crate::g as G;
Expand Down Expand Up @@ -388,8 +386,12 @@ pub struct Use {
pub count_estimate: u32,
}

pub type List = Vec<Symbol>;
pub type NestedList = Vec<List>;
pub type List<'a> = bun_alloc::ArenaVec<'a, Symbol>;
/// `Map.symbols_for_source` storage. Decoupled from [`List`] (which is
/// arena-backed): the linker clones every per-source symbol table here so it
/// can mutate them independently of the parsed `BundledAst.symbols`, and those
/// clones are owned for the link lifetime — global allocator, no arena tag.
pub type NestedList = Vec<Vec<Symbol>>;

impl Symbol {
pub fn merge_contents_with(&mut self, old: &mut Symbol) {
Expand Down Expand Up @@ -418,9 +420,9 @@ pub struct Map {
impl Map {
// Debug-only dump of the symbol table.
pub fn dump(&self) {
for (i, symbols) in self.symbols_for_source.slice().iter().enumerate() {
for (i, symbols) in self.symbols_for_source.iter().enumerate() {
bun_core::prettyln!("\n\n-- Source ID: {} ({} symbols) --\n", i, symbols.len(),);
for (inner_index, symbol) in symbols.slice().iter().enumerate() {
for (inner_index, symbol) in symbols.iter().enumerate() {
let display_ref = if symbol.has_link() {
symbol.link.get()
} else {
Expand Down Expand Up @@ -550,7 +552,7 @@ impl Map {
// SAFETY: src in-bounds (parser-produced ref); raw-ptr field read — no `&` to the
// element is created. idx in-bounds of the inner list.
unsafe {
let inner: *mut List = self.symbols_for_source.as_ptr().cast_mut().add(src);
let inner: *mut Vec<Symbol> = self.symbols_for_source.as_ptr().cast_mut().add(src);
debug_assert!(idx < (*inner).len());
Some((*inner).as_mut_ptr().add(idx))
}
Expand Down Expand Up @@ -585,11 +587,10 @@ impl Map {

pub fn init(source_count: usize) -> Map {
// Zig: `arena.alloc([]Symbol, sourceCount)` (default_allocator) then NestedList.init.
// Per PORTING.md §Allocators (non-arena path), use Vec → Vec.
let mut v: Vec<List> = Vec::with_capacity(source_count);
v.resize_with(source_count, List::default);
let mut v: NestedList = Vec::with_capacity(source_count);
v.resize_with(source_count, Vec::new);
Map {
symbols_for_source: NestedList::move_from_list(v),
symbols_for_source: v,
}
}

Expand All @@ -600,8 +601,8 @@ impl Map {
// caller is the printer one-shot, cold).
// OWNERSHIP: returned `Map` is *owned*; the `Vec<List>` allocated here leaks if a
// consumer parks it in `ManuallyDrop` (e.g. renamer.rs `MinifyRenamer.symbols`).
pub fn init_with_one_list(list: List) -> Map {
Self::init_list(NestedList::move_from_list(vec![list]))
pub fn init_with_one_list(list: Vec<Symbol>) -> Map {
Self::init_list(vec![list])
}

pub fn init_list(list: NestedList) -> Map {
Expand Down Expand Up @@ -647,8 +648,8 @@ impl Map {
// `link` is `Cell<Ref>`, so we can iterate the table by shared ref and
// mutate `link` in place; `follow()` only takes `&self` and only touches
// `link`, so the nested shared borrows coexist.
for symbols in self.symbols_for_source.slice().iter() {
for symbol in symbols.slice().iter() {
for symbols in self.symbols_for_source.iter() {
for symbol in symbols.iter() {
if !symbol.has_link() {
continue;
}
Expand Down Expand Up @@ -690,10 +691,10 @@ impl Map {
// such refs satisfy the in-bounds contract (see `get_const`):
// `(source_index, inner_index)` with tag ∈ {Symbol, AllocatedName},
// never `SourceContentsSlice` and never the null source sentinel.
let outer = self.symbols_for_source.slice();
let outer = self.symbols_for_source.as_slice();
let lookup = |r: Ref| -> &Symbol {
debug_assert!(!r.is_source_contents_slice());
&outer[r.source_index() as usize].slice()[r.inner_index() as usize]
&outer[r.source_index() as usize][r.inner_index() as usize]
};

let mut root = link;
Expand Down
44 changes: 26 additions & 18 deletions src/bun_alloc/MimallocArena.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ impl MimallocArena {
// destroyed (we own it). After this call all outstanding allocations
// are freed; replacing `self.heap` with a fresh heap restores the
// invariant.
crate::ast_alloc::bump_invalidate_heap(self.heap_ptr());
unsafe { mimalloc::mi_heap_destroy(self.heap_ptr()) };
let heap = unsafe { mimalloc::mi_heap_new() };
self.heap = NonNull::new(heap).unwrap_or_else(|| crate::out_of_memory());
Expand Down Expand Up @@ -585,6 +586,7 @@ impl Drop for MimallocArena {
// every block still allocated in it without running per-block free.
// SAFETY: `self.heap` is a live heap obtained from `mi_heap_new` and
// is destroyed exactly once here.
crate::ast_alloc::bump_invalidate_heap(self.heap_ptr());
unsafe { mimalloc::mi_heap_destroy(self.heap_ptr()) };
}
}
Expand Down Expand Up @@ -798,21 +800,6 @@ pub fn std_vtables() -> [&'static crate::AllocatorVTable; 2] {
}

// ── ArenaVec helpers ─────────────────────────────────────────────────────
// `std::vec::Vec<T, A>` lacks `from_iter_in` / `into_bump_slice*`; provide
// thin shims so call sites that used `bumpalo::collections::Vec` keep working.

/// `bumpalo::collections::Vec::from_iter_in` parity for `Vec<T, &MimallocArena>`.
#[inline]
pub fn vec_from_iter_in<'a, T, I>(iter: I, arena: &'a MimallocArena) -> Vec<T, &'a MimallocArena>
where
I: IntoIterator<Item = T>,
{
let iter = iter.into_iter();
let (lo, _) = iter.size_hint();
let mut v = Vec::with_capacity_in(lo, arena);
v.extend(iter);
v
}

/// `bumpalo::collections::String` parity — a UTF-8 buffer backed by the arena.
/// Thin newtype over `Vec<u8, &'a MimallocArena>` so `write!` works and
Expand Down Expand Up @@ -904,12 +891,33 @@ pub trait ArenaVecExt<'a, T> {
impl<'a, T> ArenaVecExt<'a, T> for Vec<T, &'a MimallocArena> {
#[inline]
fn from_iter_in<I: IntoIterator<Item = T>>(iter: I, arena: &'a MimallocArena) -> Self {
vec_from_iter_in(iter, arena)
let iter = iter.into_iter();
let (lo, _) = iter.size_hint();
let mut v = Vec::with_capacity_in(lo, arena);
v.extend(iter);
v
}
#[inline]
fn into_bump_slice(self) -> &'a [T] {
&*self.leak()
}
#[inline]
fn into_bump_slice_mut(self) -> &'a mut [T] {
self.leak()
}
#[inline]
fn bump(&self) -> &'a MimallocArena {
*self.allocator()
}
}

impl<'a, T> ArenaVecExt<'a, T> for crate::BabyVec<'a, T> {
#[inline]
fn from_iter_in<I: IntoIterator<Item = T>>(iter: I, arena: &'a MimallocArena) -> Self {
crate::vec_from_iter_in(iter, arena)
}
#[inline]
fn into_bump_slice(self) -> &'a [T] {
// Storage is owned by the arena and lives for `'a`; `Vec::leak` forgoes
// the `Vec` drop so the arena reclaims it on `reset`/`Drop`.
&*self.leak()
}
#[inline]
Expand Down
36 changes: 32 additions & 4 deletions src/bun_alloc/ast_alloc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,14 +97,32 @@ static BUMP_CUR: Cell<*mut u8> = Cell::new(core::ptr::null_mut());
/// One-past-the-end of the active bump chunk.
#[thread_local]
static BUMP_END: Cell<*mut u8> = Cell::new(core::ptr::null_mut());
/// The `mi_heap_t*` that owns the active bump chunk (set in [`bump_refill`]).
/// Lets [`set_thread_heap`] keep the cursor when re-entering the same heap so
/// the bundler's per-task `push()`/`pop()` doesn't abandon a 16 KB chunk every
/// task — that was ~70 K tasks × 16 KB ≈ 1.1 GB of mostly-empty chunks held in
/// the never-reset worker `ast_memory_store` arenas.
#[thread_local]
static BUMP_HEAP: Cell<*mut mimalloc::Heap> = Cell::new(core::ptr::null_mut());

/// Drop the bump cursor (the chunk itself is owned by `AST_HEAP` and reclaimed
/// by `mi_heap_destroy`). Called on every [`set_thread_heap`] so a cursor never
/// outlives the heap that backs its chunk.
/// Drop the bump cursor (the chunk itself is owned by `BUMP_HEAP` and reclaimed
/// by `mi_heap_destroy`).
#[inline]
fn bump_reset() {
BUMP_CUR.set(core::ptr::null_mut());
BUMP_END.set(core::ptr::null_mut());
BUMP_HEAP.set(core::ptr::null_mut());
}

/// Invalidate the bump cursor if it is backed by `heap`. Called from
/// `MimallocArena::reset`/`Drop` immediately before `mi_heap_destroy(heap)` so
/// a recycled `mi_heap_t*` slot can't ABA-match `BUMP_HEAP` and serve a stale
/// (freed) chunk under [`set_thread_heap`].
#[inline]
pub(crate) fn bump_invalidate_heap(heap: *mut mimalloc::Heap) {
if BUMP_HEAP.get() == heap {
bump_reset();
}
}

/// Carve `size` bytes at `align` (a power of two `<= MI_MAX_ALIGN_SIZE`) from
Expand Down Expand Up @@ -149,6 +167,7 @@ fn bump_refill(heap: *mut mimalloc::Heap, size: usize) -> Option<*mut u8> {
// both `add`s are in bounds.
BUMP_CUR.set(unsafe { chunk.add(size) });
BUMP_END.set(unsafe { chunk.add(BUMP_CHUNK) });
BUMP_HEAP.set(heap);
Some(chunk)
}

Expand All @@ -172,7 +191,16 @@ fn bump_refill(heap: *mut mimalloc::Heap, size: usize) -> Option<*mut u8> {
#[inline]
pub fn set_thread_heap(heap: *mut mimalloc::Heap) {
AST_HEAP.set(heap);
bump_reset();
// Keep the cursor when re-entering the heap that owns it (the bundler's
// per-task `push()`/`pop()` always passes the same per-worker arena). Only
// discard when switching to a *different* non-null heap — the chunk then
// belongs to the wrong owner. Clearing to null is a no-op for the cursor
// (no allocs happen while `AST_HEAP` is null), so it survives the
// `pop()→push()` round-trip. Heap destruction must call [`bump_reset`]
// explicitly to defend against address reuse.
if !heap.is_null() && heap != BUMP_HEAP.get() {
bump_reset();
}
Comment on lines 192 to +203
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.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Don't preserve the bump cursor across set_thread_heap(null) while arenas are Send.

BUMP_HEAP/BUMP_CUR are thread-local, but MimallocArena can be reset or dropped on a different thread. After a pop on thread A the cursor now survives there; destroying the heap on thread B only clears B's TLS, not A's. If mi_heap_new() later reuses that address on A, the pointer-equality fast path at Lines 201-203 keeps a freed chunk alive and heap_alloc() can hand out UAF pointers.

Safe fallback
 pub fn set_thread_heap(heap: *mut mimalloc::Heap) {
     AST_HEAP.set(heap);
-    if !heap.is_null() && heap != BUMP_HEAP.get() {
+    if heap.is_null() || heap != BUMP_HEAP.get() {
         bump_reset();
     }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/bun_alloc/ast_alloc.rs` around lines 192 - 203, The current
set_thread_heap preserves the bump cursor when called with null, which is unsafe
because MimallocArena may be dropped on another thread; modify set_thread_heap
so it also clears the per-thread bump state when AST_HEAP is being set to null.
Specifically, update the condition around bump_reset() in set_thread_heap to
call bump_reset() when heap.is_null() OR when heap != BUMP_HEAP.get() (so it
still preserves the cursor only for the same non-null BUMP_HEAP), referencing
AST_HEAP, BUMP_HEAP, BUMP_CUR, bump_reset, and set_thread_heap to locate the
change.

}

/// Current thread's AST heap, or null if no `ASTMemoryAllocator` scope is
Expand Down
Loading
Loading