Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 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
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 on lines +103 to +106
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.

🟡 The newly-added comment says "use Ast::empty_in(arena) (or Ast::empty() with the process-static arena for placeholder values)", but this same PR deletes Ast::empty() — only empty_in(arena) exists now. The parenthetical should be dropped.

Extended reasoning...

What the issue is

The PORT NOTE comment added above impl<'a> Ast<'a> at src/ast/ast_result.rs:104-106 reads:

parts/symbols/import_records are now ArenaVecs 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).

However, this PR also deletes the Ast::empty() function. The diff shows the old definition being removed:

-    pub fn empty() -> Ast {
-        Ast::default()
-    }

After this PR, the only constructor is pub fn empty_in(arena: &'a bun_alloc::MimallocArena) -> Self. There is no Ast::empty() and no "process-static arena" overload.

Why nothing prevents it

This is purely a documentation comment, so the compiler doesn't check it. The comment was likely written at an intermediate stage of the refactor when both empty() and empty_in() coexisted, and the parenthetical wasn't updated when empty() was finally removed.

Step-by-step proof

  1. Line 105-106 of the new file says: "use Ast::empty_in(arena) (or Ast::empty() with the process-static arena for placeholder values)".
  2. Grep the file for fn empty( — only empty_in matches; empty() does not exist.
  3. The diff hunk at the bottom of the impl Ast block explicitly removes pub fn empty() -> Ast { Ast::default() }.
  4. Therefore the comment references a function that this PR itself deletes.

Impact

No runtime impact — it's a code comment. The risk is that a future reader follows the hint, tries to call Ast::empty(), and gets a compile error / wastes time looking for it. Since this comment is the canonical "how do I get a default Ast now?" guidance, it should be accurate.

Fix

Drop the parenthetical so the sentence ends at Ast::empty_in(arena):

// `parts`/`symbols`/`import_records` are now `ArenaVec`s and need an allocator,
// so `Default` no longer applies; use `Ast::empty_in(arena)`.

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>;
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 | 🟠 Major | 🏗️ Heavy lift

Part still owns drop-managed containers.

PartList<'a> now stores Part inside the arena, but Part.dependencies, Part.symbol_uses, and Part.import_symbol_property_uses are still regular Vec/ArrayHashMap values. Those destructors will never run when the arena resets, so each populated part now leaks per-file state unless these members are migrated to arena/AstAlloc-backed storage or explicitly freed first. As per coding guidelines, "Do not rely on Drop for correctness in arena-backed code (values in bun_alloc::MimallocArena do not run Drop when the arena resets) — free resources explicitly before the arena resets."

🤖 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/ast/nodes.rs` at line 1140, PartList<'a> now places Part instances in the
arena but Part still owns drop-managed containers (Part.dependencies,
Part.symbol_uses, Part.import_symbol_property_uses) whose destructors won't run
when the Mimalloc arena resets, leaking per-file state; fix this by migrating
those fields to arena-backed containers or freeing them explicitly before the
arena reset: replace Vec/ArrayHashMap usages inside struct Part with
arena-allocated equivalents (e.g., bun_alloc::ArenaVec<'a, T> or your
AstAlloc-backed map types) or convert them to indices/refs into arena-owned
storage, and if migration isn't possible ensure code paths that clear/free
Part::dependencies, Part::symbol_uses and Part::import_symbol_property_uses are
called before the bun_alloc::MimallocArena reset so no Drop-dependent resources
remain.


#[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