Skip to content

shell: expand braces without a mimalloc heap, and free the atoms of a nested expansion - #42927

Open
robobun wants to merge 4 commits into
mainfrom
robobun/660b5ffa/braces-no-arena
Open

robobun wants to merge 4 commits into
mainfrom
robobun/660b5ffa/braces-no-arena

Conversation

@robobun

@robobun robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • $.braces("a{1,2,3}") costs 3x what it cost in 1.3.14 (612 ns, now 1869 ns). A nested group costs 8x (687 ns, now 5747 ns).
  • bun_alloc::Arena is a whole mimalloc heap. $.braces creates one per call (src/runtime/api/BunObject.rs:397). A script creates one per word that expands (src/runtime/shell/states/Expansion.rs:323). Only a nested group uses it.
  • A nested expansion leaks every atom longer than 15 bytes. The AST holds Atom::Text(SmolStr) in arena slices, and an arena does not run Drop.

Fix

  • The AST owns its slices. OwnedSlice<T> is a Box<[T]> held as a raw pointer, and its Drop frees the box. braces::expand and braces::Parser take no arena.
  • The pointer stays raw because expand_nested writes bubble_up backrefs into child groups while it reads the parent that owns them. That access pattern does not change.
  • Verified: two new tests in test/js/bun/shell/brace.test.ts fail on main (200 heaps for 200 calls, 40 MiB of RSS growth). New unit tests in braces.rs pass under Miri.

Background

  • A mimalloc heap (mi_heap_new, mi_heap_destroy) frees all of its blocks at destroy. One pair costs a thread-local slot, fresh 64 KiB pages when the heap is used, and up to four merges of the heap statistics.
  • SmolStr stores up to 15 bytes inline. A longer string is a heap allocation that Drop frees.
  • The brace lexer produces tokens. A flat expansion works on the tokens. Only a nested group builds an AST (Group, Atom, Expansion).
Notes

Numbers

Release builds of main (c6b7fcb) and this branch, same toolchain, plus the 1.3.14 release binary. Median of 3 interleaved runs, in ns per call. The machine is a shared container.

1.3.14 main this PR
$.braces("abc") 162 1476 140
$.braces("a{1,2,3}") 612 1869 506
$.braces("x{1,2}{a,b}") 777 2010 640
$.braces("{a,{b,c}}") 687 5747 692
$.escape("a b'c" + i) + $.braces("a{1,2,3}") 913 2299 858

The Zig code used std.heap.ArenaAllocator here, which costs nothing until it is used. In a sampling profile of $.braces("abc") on main, 72% of the samples are heap create and destroy, and mi_stats_add alone is 47%.

The leak

Parser::advance clones each text token into the AST. The clone of a long atom owns a heap buffer, and the arena slice that held it was freed without Drop. 20 000 calls of $.braces("{" + "x".repeat(4000) + ",{b,c}}") grow RSS by 90 MB on main (20 000 x 4000 bytes) and by 7 MB here. A script leaks the same way: 1000 runs of $`echo {${long},{b,c}}` with a 64 KiB long grow RSS by 63 MB on main and by 3 MB here.

The new unit test expand_nested_groups uses atoms longer than 15 bytes. bun_shell_parser is in the Miri crate set (scripts/rust-miri.ts). With the Drop of OwnedSlice disabled, Miri reports each leaked allocation. Before this change the nested path needed a mimalloc heap, which Miri cannot call, so it had no unit test.

The RSS test a nested expansion frees its atoms skips on a debug build. The brace lexer handles 256 KiB in about 100 ms there, so the test takes 14 s. On a release build it takes 0.3 s. The heap count test runs on every build.

Related

Other test results

Ran brace.test.ts, bunshell.test.ts, bunshell-default.test.ts, lex.test.ts and parse.test.ts with the debug ASAN build: 543 pass, 0 fail. cargo test -p bun_shell_parser, bun run rust:miri -p bun_shell_parser, clippy and test/internal/source-lints pass.


[human-review] gate passed · iteration 0 · 4 files touched

fails on main (without fix)
ASAN without fix: 1 failed, 1 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/shell/brace.test.ts
bun test v1.4.3 (c6b7fcb5b)

test/js/bun/shell/brace.test.ts:
(pass) $.braces > no-op [1.86ms]
(pass) $.braces > 2 [1.98ms]
(pass) $.braces > 3 [2.27ms]
(pass) $.braces > nested [1.65ms]
(pass) $.braces > nested 2 [1.92ms]
(pass) $.braces > nested sibling product [1.38ms]
(pass) $.braces > nested sibling product with surrounding text [1.71ms]
(pass) $.braces > nested sibling product mixed with variants [1.93ms]
(pass) $.braces > nested sibling product triple [1.63ms]
(pass) $.braces > nested with empty variant > {x,a{,}b} [1.69ms]
(pass) $.braces > nested with empty variant > {x,{a,}}z [0.64ms]
(pass) $.braces > nested with empty variant > {x,{,a}}z [0.56ms]
(pass) $.braces > nested with empty variant > {x,{,}}z [0.45ms]
(pass) $.braces > nested with empty variant > a{b,c{d,}}e [0.42ms]
(pass) $.braces > nested with empty variant > a{b,c{,d}}e [0.44ms]
(pass) $.braces > nested with empty variant > {x,{a,,b}} [0.48ms]
(pass) $.braces > nested with empty variant > {x,{a,b,}} [0.47ms]
(pass) $.braces > neste
... (truncated)

release without fix: 2 FAILED
bun test v1.4.3-canary.1 (26590c21c)

test/js/bun/shell/brace.test.ts:
(pass) $.braces > no-op [0.05ms]
(pass) $.braces > 2 [0.02ms]
(pass) $.braces > 3 [0.01ms]
(pass) $.braces > nested [0.03ms]
(pass) $.braces > nested 2 [0.02ms]
(pass) $.braces > nested sibling product [0.02ms]
(pass) $.braces > nested sibling product with surrounding text [0.01ms]
(pass) $.braces > nested sibling product mixed with variants [0.02ms]
(pass) $.braces > nested sibling product triple [0.02ms]
(pass) $.braces > nested with empty variant > {x,a{,}b} [0.02ms]
(pass) $.braces > nested with empty variant > {x,{a,}}z [0.01ms]
(pass) $.braces > nested with empty variant > {x,{,a}}z
(pass) $.braces > nested with empty variant > {x,{,}}z
(pass) $.braces > nested with empty variant > a{b,c{d,}}e
(pass) $.braces > nested with empty variant > a{b,c{,d}}e
(pass) $.braces > nested with empty variant > {x,{a,,b}}
(pass) $.braces > nested with empty variant > {x,{a,b,}}
(pass) $.braces > nested with empty variant > {{a,},x}
(pass) $.braces > nested with empty variant > p{q,{r,}{s,}}t
(pass) $.braces > very deeply nested [0.04ms]
(pass) $.braces > literal outer group around hundreds of nested groups
... (truncated)
passes on PR (with fix)
ASAN with fix: 1 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/shell/brace.test.ts
bun test v1.4.3 (c6b7fcb5b)

test/js/bun/shell/brace.test.ts:
(pass) $.braces > no-op [2.63ms]
(pass) $.braces > 2 [1.79ms]
(pass) $.braces > 3 [2.27ms]
(pass) $.braces > nested [1.64ms]
(pass) $.braces > nested 2 [1.94ms]
(pass) $.braces > nested sibling product [1.71ms]
(pass) $.braces > nested sibling product with surrounding text [1.43ms]
(pass) $.braces > nested sibling product mixed with variants [1.83ms]
(pass) $.braces > nested sibling product triple [1.62ms]
(pass) $.braces > nested with empty variant > {x,a{,}b} [1.58ms]
(pass) $.braces > nested with empty variant > {x,{a,}}z [0.46ms]
(pass) $.braces > nested with empty variant > {x,{,a}}z [0.44ms]
(pass) $.braces > nested with empty variant > {x,{,}}z [0.28ms]
(pass) $.braces > nested with empty variant > a{b,c{d,}}e [0.31ms]
(pass) $.braces > nested with empty variant > a{b,c{,d}}e [0.33ms]
(pass) $.braces > nested with empty variant > {x,{a,,b}} [0.33ms]
(pass) $.braces > nested with empty variant > {x,{a,b,}} [0.32ms]
(pass) $.braces > neste
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 679ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/7] gen generated_host_exports.rs
generated_host_exports.rs: 121 exports (host=5, lazy=10, generic=106, rust=0); 242 extern-C blocks audited
[1/7] cargo bun_runtime → libbun_runtime.a
�[1m�[33mwarning�[0m�[1m: binary `bun_shim_impl` should have a kebab-case name�[0m
   �[1m�[94m|�[0m
�[1m�[94m 1�[0m �[1m�[94m|�[0m /workspace/bun/build/release/rust-target/.../bun_shim_impl
   �[1m�[94m|�[0m                                              �[1m�[33m^^^^^^^^^^^^^�[0m
   �[1m�[94m|�[0m
   �[1m�[94m= �[0m�[1mnote�[0m: `cargo::non_kebab_case_bins` is set to `warn` by default
�[1m�[96mhelp�[0m: to change the binary name to `bun-shim-impl`, convert `bin.name`
  �[1m�[94m--> �[0msrc/install/windows-shim/Cargo.toml:41:8
   �[1m�[94m|�[0m
�[1m�[94m41�[0m �[91m- �[0mname = �[91m"bun_shim_impl"�[0m
�[1m�[94m41�[0m �[92m+ �[0mname = �[92m"bun-shim-impl"�[0m
   �[1m�[94m|�[0m
�[1m�[33mwarning�[0m: `bun_shim_impl` (manifest) generated 1 warning
�[1m�[92m   Compiling�[0m bun_shell_parser v0.0.0 (/workspace/bun/src/shell_pa
... (truncated)
diff hotspot
src/runtime/api/BunObject.rs          |   7 +-
 src/runtime/shell/states/Expansion.rs |   3 -
 src/shell_parser/braces.rs            | 123 ++++++++++++++++++++++++----------
 test/js/bun/shell/brace.test.ts       |  34 +++++++++-
 4 files changed, 120 insertions(+), 47 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                   reads  edits  tests
src/runtime/api/BunObject.rs               2      4     16
src/runtime/shell/states/Expansion.rs      2      2     14
src/shell_parser/braces.rs                 8      7     17
test/js/bun/shell/brace.test.ts            2      2     14

… expansion

`$.braces()` and brace expansion in a script created a `bun_alloc::Arena`
per call. An arena is a whole mimalloc heap, and only a nested group
used it: a flat expansion paid for a heap create/destroy pair that it
never touched, and a nested one for the pages as well.

The nested AST was built from arena slices that held `Atom::Text(SmolStr)`.
An arena does not run `Drop`, so every atom longer than the 15 inline
bytes of a `SmolStr` leaked its heap buffer on each expansion.

The AST now owns its slices (`OwnedSlice`, a `Box<[T]>` held as a raw
pointer because `expand_nested` writes backrefs into the elements), so
`braces::expand` and `braces::Parser` take no arena and the atoms drop.
@robobun

robobun commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on main (c6b7fcb) and on 1.3.14 with release builds:

import { heapStats } from "bun:jsc";
const before = heapStats().mimalloc.heaps.total;
for (let i = 0; i < 100; i++) Bun.$.braces("a{1,2,3}");
console.log(heapStats().mimalloc.heaps.total - before); // main: 100, this PR: 0
  • $.braces("a{1,2,3}"): 612 ns on 1.3.14, 1869 ns on main, 506 ns here. $.braces("{a,{b,c}}"): 687 ns, 5747 ns, 692 ns.
  • Leak: 128 calls of $.braces("{" + atom + ",{b,c}}") with a 256 KiB atom grow RSS by 37 MiB on main and by less than 3 MiB here.
  • test/js/bun/shell/brace.test.ts: the two new tests fail on main and pass here.

@coderabbitai

coderabbitai Bot commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: a5d78b59-3e09-45df-be19-d3997159e387

📥 Commits

Reviewing files that changed from the base of the PR and between 6ff6fd5 and fc1f031.

📒 Files selected for processing (1)
  • src/shell_parser/braces.rs

Included review availability: Your plan provides up to 10 included reviews per hour; 1 remains after this review.


Walkthrough

Brace parsing and expansion now use heap-owned boxed slices instead of temporary arenas. Runtime callers use the updated interfaces. Tests cover recursive expansion and memory behavior.

Changes

Brace expansion ownership

Layer / File(s) Summary
Owned AST storage and parser construction
src/shell_parser/braces.rs
Brace AST collections now use OwnedSlice. Parser construction uses standard vectors and no longer accepts an arena.
Expansion and serialization updates
src/shell_parser/braces.rs, test/js/bun/shell/brace.test.ts
Serialization and nested expansion read owned slices through raw pointers. Flat, nested, and long-text expansion tests were added.
Runtime integration and memory validation
src/runtime/api/BunObject.rs, src/runtime/shell/states/Expansion.rs, test/js/bun/shell/brace.test.ts
Runtime callers no longer create or release temporary arenas. Tests check mimalloc heap counts and RSS growth.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to fc1f0

The ownership migration preserves the documented lifetimes and error boundaries, with no verified behavior regression requiring resolution before merge.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The description clearly explains the problem, the ownership and allocation fix, and verification results. It does not use the exact template headings, but it provides the required content and detailed…
Title check ✅ Passed The title clearly identifies the two main changes: removing a mimalloc heap from brace expansion and freeing atoms in nested expansions.

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline nit, I also checked the OwnedSlice drop path against the bubble_up backrefs in expand_nested: the root Group is moved onto expand's stack frame before any backref is written, Group has no Drop that dereferences bubble_up, and the nested Expansion -> OwnedSlice<Group> -> Atom chain frees recursively without touching the raw parent pointers, so the ownership change does not introduce a use-after-free or double free.

Extended reasoning...

The PR replaces bump-arena slices with a raw-pointer-wrapped Box<[T]> (OwnedSlice) in the brace-expansion AST. The one verified finding is a style nit (open-coded Box::into_raw/Box::from_raw instead of bun_core::heap). I independently traced the unsafe interplay: parse() returns the root Group by value and expand holds it on its own frame before expand_nested writes bubble_up pointers into child groups, so no Group moves after a backref points at it; OwnedSlice::drop only frees its own boxed slice and the contained Group/Atom values drop their own OwnedSlice/SmolStr fields recursively, never following bubble_up. The remaining ruled-out candidates (ASAN lane timing for the RSS test, Miri aliasing on as_ptr, debug-lane coverage of the leak test) concern test infrastructure rather than correctness of the fix.

One verified lower-impact observation (a convention, logging or cleanup point) was not posted.

Comment thread src/shell_parser/braces.rs
Comment thread src/shell_parser/braces.rs Outdated
Comment thread src/shell_parser/braces.rs Outdated

@claude claude Bot left a comment

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.

I reviewed the updated push and found no bugs; the earlier Box::into_raw/Box::from_raw nit is addressed in fc1f031 (bun_core::heap::into_raw/destroy). Because the change re-homes ownership of an AST that expand_nested still mutates through raw bubble_up backrefs, a human look at the unsafe contract is still worthwhile.

What was reviewed:

  • Drop chain OwnedSlice<Group> -> Group -> GroupAtoms::Many -> Atom::Expansion -> OwnedSlice<Group>: each box freed exactly once, no Clone/Copy on any AST type, recursion depth bounded by MAX_BRACE_GROUPS (256).
  • Parser error paths (parse/parse_expansion returning Err mid-build): partially built Vecs drop normally before any OwnedSlice::new, so nothing is orphaned as a raw pointer.
  • Call sites: BunObject.rs and Expansion.rs are the only callers of Parser::init/expand; both updated, no stale arena argument remains.
  • Not verified here: the heapStats().mimalloc.heaps.total field the new heap-count test reads comes from mi_stats_get_json, and the vendored mimalloc source is not in this checkout, so I could not confirm the key name from source.
Extended reasoning...

Overview

The PR removes the per-call bun_alloc::Arena (a full mimalloc heap) from brace expansion in src/runtime/api/BunObject.rs and src/runtime/shell/states/Expansion.rs, and replaces the arena-owned *mut [Atom]/*mut [Group] slices in src/shell_parser/braces.rs with a small ast::OwnedSlice<T> wrapper (a Box<[T]> stored raw, freed in Drop via bun_core::heap::destroy). Parser::init and expand lose the &Bump parameter; parse/parse_expansion build plain Vecs and convert with OwnedSlice::new. The expand_nested recursion is unchanged apart from reading slices through as_ptr(). Two Rust unit tests and two JS tests (heap-count and RSS-delta) were added.

Security risks

None specific to this change. Input is bounded by the pre-existing check_brace_group_count (MAX_BRACE_GROUPS = 256), which also caps the recursive Drop depth of the nested AST. No new parsing of untrusted data was introduced; the lexer and expansion arithmetic are untouched.

Level of scrutiny

Moderate-to-high: this is unsafe Rust in the "most-blocked category" of the repo's review guidance. The ownership move is small and self-contained, and I checked the points that matter for it: OwnedSlice has no Clone/Copy so no double-free is reachable; every OwnedSlice::new is reached only after the Vec is fully built (all ? propagations occur before the conversion, so error paths drop plain Vecs); as_ptr() returns the stored raw pointer with its original provenance and no &mut to the slice is ever materialized; the aliasing pattern in expand_nested (writes only into child Groups while a parent is reached via raw pointer) is unchanged from the arena version. The previously raised nit about using bun_core::heap::{into_raw, destroy} instead of Box::into_raw/Box::from_raw was addressed in the follow-up commit. Because the remaining risk is the raw-pointer aliasing contract that I can only verify by reading (the PR states Miri passes on the new unit tests, which I could not run here), a human confirmation is still worthwhile rather than an unattended approve.

Other factors

All callers of Parser::init/braces::expand were updated (grep found only the two runtime sites). The RSS test uses the existing expectRssDeltaBelow harness helper, uses Buffer.alloc instead of "x".repeat, skips on debug with a stated reason, and uses test.skipIf(...).concurrent, a chain already used elsewhere in the suite. The one thing I could not confirm from source is the mimalloc.heaps.total key that the heap-count test reads: heapStats parses mi_stats_get_json output in src/jsc/modules/BunJSCModule.h, but the vendored mimalloc tree is absent from this shallow checkout and no other test references that key, so its existence rests on the author's statement that the test fails on main and passes on the branch.

@claude claude Bot left a comment

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.

I re-reviewed the latest push (fc1f0314, d3d6f45d) and found no bugs; the earlier bun_core::heap nit is addressed. Because this changes who owns and frees the brace AST behind raw pointers, a human look at the unsafe ownership model is still worthwhile.

What was reviewed:

  • OwnedSlice<T> lifecycle: created once from Vec::into_boxed_slice, freed once in Drop; Group/Atom/Expansion derive neither Clone nor Copy, so no duplicate owner can arise.
  • bubble_up backrefs: they remain non-owning raw pointers into boxed slices (stable addresses) or the stack root, so recursive Drop of the tree cannot double-free through them.
  • Callers in BunObject.rs and Expansion.rs: only the arena argument was removed; error mapping and the opts.parse path are unchanged.
  • Tests: the heap-count check and the release-only RSS test both depend on the new ownership (bound 16 MiB vs. ~37 MiB unfixed); the Rust unit tests use >15-byte atoms so Miri would flag a leak.
Extended reasoning...

Overview

The PR removes the per-call bun_alloc::Arena from the shell brace-expansion parser and replaces bump-owned AST slices with ast::OwnedSlice<T>, a Box<[T]> held as a raw pointer with a Drop impl that frees it via bun_core::heap::destroy. Parser::init and expand lose their bump parameter; the two callers (Bun.$.braces in src/runtime/api/BunObject.rs and the shell Expansion state in src/runtime/shell/states/Expansion.rs) drop their arena creation. Tests are added in test/js/bun/shell/brace.test.ts (heap count, release-only RSS delta) and as Rust unit tests in src/shell_parser/braces.rs.

Security risks

None specific to this change. Input is still bounded by check_brace_group_count / MAX_BRACE_GROUPS before the AST is built, so recursive Drop depth is bounded the same way recursion in expand_nested already was. No new parsing of untrusted data, no new allocation sized from user-controlled counts beyond what the previous arena path already did. The memory-safety surface is the raw-pointer ownership: I checked that OwnedSlice is neither Copy nor Clone (it has a Drop impl, and the containing AST types derive nothing), as_ptr only hands out a borrowed view, and bubble_up pointers target boxed-slice elements or the stack-resident root, both of which are address-stable for the duration of expand_nested.

Level of scrutiny

Medium. The diff is small and the ownership model is simple (one owner per slice, freed in Drop), but it is unsafe Rust that rewrote how the AST is freed, and the expand_nested recursion mutates child Groups through raw pointers while the parent is borrowed. The bug hunt ran to a dry streak without findings and my own reading found no double-free, use-after-free, or leak path, but a human familiar with the shell parser should confirm the ownership story matches their mental model before merge.

Other factors

The previous run's only comment (use bun_core::heap::{into_raw, destroy} instead of Box::into_raw/Box::from_raw) was addressed in fc1f0314, and d3d6f45d trimmed comments. The SAFETY comments were updated to describe box ownership instead of the arena. Test coverage is adequate: the heap-count test runs on all builds and the RSS test is skipped on debug with a stated reason and a threshold well below the unfixed leak; the Rust unit tests exercise both flat and nested expansion with heap-backed SmolStr atoms so Miri can catch a leak. No outstanding third-party objections appear in the timeline metadata.

@robobun

robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:10 AM PT - Sep 16th, 2026

✅ @robobun, your commit 5932fe8e321bc31df3a90161e3124cf9bf3cdb81 passed in Build #116569! 🎉


🧪   To try this PR locally:

bunx bun-pr 42927

That installs a local version of the PR into your bun-42927 executable, so you can run:

bun-42927 --bun

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants