From 26590c21c48658e6ca15d9c2f309c2795a9e44f2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:38:53 +0000 Subject: [PATCH 1/5] shell: reuse the parse arena between scripts A `bun_alloc::Arena` is a whole mimalloc heap. Every `Bun.$` script made one for its tokens and AST, destroyed it and made a second one when the script finished, and destroyed that one in the finalizer. `memory_cost` walked the heap two more times. The heap create/destroy pairs cost more than a shell builtin takes to run. A finished script now parks its arena on the VM (`RareData`), and the next script parses into it. The slot keeps one arena, and only while the dead ASTs in it are under 256 KiB. `ShellArgs` measures the arena once, after the parse, and reports the part the script added as its memory cost. A script that never finishes, and the shell of `bun run`, drop their arena as before. --- src/jsc/rare_data.rs | 22 ++++++ src/runtime/shell/ParsedShellScript.rs | 9 +-- src/runtime/shell/interpreter.rs | 84 +++++++++++++++-------- test/js/bun/shell/shell-leak-args.test.ts | 29 ++++++++ 4 files changed, 111 insertions(+), 33 deletions(-) diff --git a/src/jsc/rare_data.rs b/src/jsc/rare_data.rs index d9bf6f6bd4d6..45d876ab3a33 100644 --- a/src/jsc/rare_data.rs +++ b/src/jsc/rare_data.rs @@ -273,6 +273,8 @@ pub struct RareData { websocket_inflate_scratch: Option>, /// One libdeflate handle for every JS-thread one-shot inflate; see [`Self::libdeflate_decompressor`]. libdeflate_decompressor: Option, + /// Arena of the last finished `Bun.$` script and its `allocated_bytes()`; see [`Self::take_shell_arena`]. + shell_arena: Option<(bun_alloc::Arena, usize)>, // There is intentionally no `aws_signature_cache` field — storage lives in // `bun_s3_signing::credentials::AWS_SIGNATURE_CACHE` (process static; it @@ -334,6 +336,7 @@ impl Default for RareData { compression_scratch: None, websocket_inflate_scratch: None, libdeflate_decompressor: None, + shell_arena: None, s3_default_client: Strong::empty(), node_quic_callbacks: Strong::empty(), default_csrf_secret: Box::default(), @@ -678,6 +681,25 @@ impl RareData { } } + /// Arena for the tokens and AST of one `Bun.$` script, and the `allocated_bytes()` it + /// already holds. A `bun_alloc::Arena` is a whole mimalloc heap, and one create/destroy + /// pair costs more than a shell builtin takes to run, so a finished script parks its arena + /// here and the next script parses into it. By value: scripts overlap, and one that finds + /// the slot empty gets a fresh arena. + pub fn take_shell_arena(&mut self) -> (bun_alloc::Arena, usize) { + self.shell_arena.take().unwrap_or_default() + } + + /// Hand a taken arena back with its current `allocated_bytes()`. An arena frees nothing + /// until it is destroyed, so the slot keeps the first one returned only while the dead + /// ASTs in it are small, and lets a larger one go. + pub fn put_back_shell_arena(&mut self, arena: bun_alloc::Arena, allocated_bytes: usize) { + const KEEP: usize = 256 * 1024; + if self.shell_arena.is_none() && allocated_bytes <= KEEP { + self.shell_arena = Some((arena, allocated_bytes)); + } + } + /// libdeflate keeps no state between calls, so one handle serves every one-shot inflate on /// this thread. `None` when libdeflate cannot allocate it; callers fall back to zlib. pub fn libdeflate_decompressor(&mut self) -> Option<&mut libdeflate::Decompressor> { diff --git a/src/runtime/shell/ParsedShellScript.rs b/src/runtime/shell/ParsedShellScript.rs index 7381674211b1..f9541160def7 100644 --- a/src/runtime/shell/ParsedShellScript.rs +++ b/src/runtime/shell/ParsedShellScript.rs @@ -204,10 +204,6 @@ fn create_parsed_shell_script_impl( callframe: &CallFrame, marked_argument_buffer: &mut MarkedArgumentBuffer, ) -> JsResult { - // Box drops automatically on every early `return`/`?` below, - // so no scopeguard is needed. - let mut shargs: Box = ShellArgs::init(); - let arguments = callframe.arguments(); if arguments.len() < 2 { return Err(global.throw_not_enough_arguments("Bun.$", 2, arguments.len())); @@ -233,6 +229,11 @@ fn create_parsed_shell_script_impl( marked_argument_buffer, )?; + // Box drops automatically on every early `return` below, so no + // scopeguard is needed. + let (arena, arena_bytes) = global.bun_vm().as_mut().rare_data().take_shell_arena(); + let mut shargs: Box = ShellArgs::init(arena, arena_bytes); + // Reshaped for borrowck — `out_parser`/`out_lex_result` borrow // `shargs.__arena`, so they're scoped to a block that ends before // `shargs.script_ast = script` below. The arena reference is taken via raw diff --git a/src/runtime/shell/interpreter.rs b/src/runtime/shell/interpreter.rs index 1835df18b470..fdba8c8cdb9d 100644 --- a/src/runtime/shell/interpreter.rs +++ b/src/runtime/shell/interpreter.rs @@ -355,18 +355,38 @@ impl ShellArgs { /// Heap-allocated (returned as `Box`) because the interpreter stores /// `Box` and state nodes hold `*const ast::*` into the arena; /// the box must not move once `parse()` has filled `script_ast`. - pub(crate) fn init() -> Box { + /// + /// `arena_bytes` is what `arena.allocated_bytes()` reports: 0 for a new + /// arena, or the count `RareData::take_shell_arena` returned with it. + pub(crate) fn init(arena: bun_alloc::Arena, arena_bytes: usize) -> Box { Box::new(ShellArgs { - __arena: bun_alloc::Arena::new(), + __arena: Some(arena), + arena_bytes, + ast_bytes: 0, // Overwritten by `parse()` before `run()`. An empty stmt list is // a safe placeholder. script_ast: ast::Script { stmts: &[] }, }) } + /// For the parse only: nothing allocates from the arena after + /// [`Self::set_script_ast`] has measured it. #[inline] pub(crate) fn arena(&self) -> &bun_alloc::Arena { - &self.__arena + self.__arena + .as_ref() + .expect("the arena is taken after the script has finished") + } + + /// Give up the arena, and the AST with it, once the script has finished. + /// Returns the arena with its `allocated_bytes()`, the pair + /// `RareData::put_back_shell_arena` takes. + pub(crate) fn take_arena(&mut self) -> Option<(bun_alloc::Arena, usize)> { + self.script_ast = ast::Script { stmts: &[] }; + self.ast_bytes = 0; + let arena = self.__arena.take()?; + debug_assert_eq!(arena.allocated_bytes(), self.arena_bytes); + Some((arena, self.arena_bytes)) } /// Store the parsed AST root alongside its owning arena. This is the single @@ -394,14 +414,18 @@ impl ShellArgs { let stmts: &'static [ast::Stmt] = unsafe { core::slice::from_raw_parts(stmts.as_ptr().cast::(), stmts.len()) }; self.script_ast = ast::Script { stmts }; + // `allocated_bytes()` walks the heap's pages, so it runs once per script. + let arena_bytes = self.arena().allocated_bytes(); + self.ast_bytes = arena_bytes.saturating_sub(self.arena_bytes); + self.arena_bytes = arena_bytes; } - /// Reports the arena's `allocated_bytes()` (a superset — tokens + strpool + /// Reports what the parse added to the arena (a superset — tokens + strpool /// + AST nodes). This is for GC `estimatedSize` reporting only, where /// over-approximation is preferable to a tree walk on a lifetime-erased /// AST mirror. pub(crate) fn memory_cost(&self) -> usize { - core::mem::size_of::() + self.__arena.allocated_bytes() + core::mem::size_of::() + self.ast_bytes } } @@ -678,7 +702,7 @@ impl Interpreter { // We are the script's shell (`bun run