Skip to content

Do not create a mimalloc heap per FileSystemRouter, reload() or :nth-child(An-B) selector - #42920

Open
robobun wants to merge 3 commits into
mainfrom
robobun/bed5cc34/no-heap-per-router-or-nth-selector
Open

robobun wants to merge 3 commits into
mainfrom
robobun/bed5cc34/no-heap-per-router-or-nth-selector

Conversation

@robobun

@robobun robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • new Bun.FileSystemRouter() and router.reload() each create one mimalloc heap that nothing uses. Construction takes 5.6 us on main and 2.1 us on 1.3.14. The cause is Box::new(ArenaAllocator::new()) at src/runtime/api/filesystem_router.rs:171 and :439. The arena field is dead: RouteConfig owns its strings as Box<[u8]>.
  • The CSS parser creates one heap per :nth-child(An-B) selector written without spaces (2n-1). Bun.build of 2,000 such rules takes 10.7 ms, and 6.1 ms with 2n+1. The cause is Bump::new() in nth::parse_number_saturate (src/css/css_parser.rs:4027).

Fix

  • Remove the arena field of FileSystemRouter. The constructor copies each fileExtensions entry into its Box<[u8]> one time. One unsafe block goes away.
  • parse_n_dash_digits and parse_number_saturate take input.arena() from the calling parser, as the Zig parser in 1.3.14 did.
  • Correct because the output does not change and no allocation moves. The second tokenizer reads -<digits> only, and a number token allocates nothing.
  • Verified: test/js/bun/util/filesystem_router.test.ts and test/js/bun/css/nth-anplusb-ident.test.ts. The 5 new tests fail on main. Also css.test.ts, doesnt_crash.test.ts and test/regression/issue/18242.test.ts.

Background

  • bun_alloc::Arena is MimallocArena. Arena::new() calls mi_heap_new and Drop calls mi_heap_destroy. The pair costs more than 1 us with no allocation. The Zig code used a bump arena, which is free to create.
  • heapStats().mimalloc.heaps.total from bun:jsc counts every heap that the process created. The tests read it before and after a loop.
  • The tokenizer reads 2n-1 as one dimension token with the unit n-1. The parser reads the digits after n- with a second tokenizer. A tokenizer uses its arena only for a name or a string that has an escape.
Notes

Scope. Other host functions also call Arena::new() one time per call. This PR changes only these two sites. Both changes stay correct under a wider fix (a pooled arena, or a heap that is created on the first allocation): the router arena is dead code, and the CSS arena is the arena of the caller.

Measurements. Release builds of c6b7fcb with this diff ("PR") and without it ("main"), plus the 1.3.14 release from npm. Each number is the median of 15 fresh processes per build, with the builds interleaved. The machine was loaded (load1 60 to 98), so compare the columns, not the absolute values.

1.3.14 main PR
new Bun.FileSystemRouter({ style, dir }), 1 route 2.07 us 5.58 us 1.97 us
same, with fileExtensions: [".tsx", ".ts"] 2.46 us 22.20 us 2.06 us
router.reload() 15.47 us 21.70 us 18.92 us
router.match("/") (control) 0.43 us 0.38 us 0.38 us
heaps per constructor call, per reload() 0, 0 1, 1 0, 0
Bun.build, 2,000 x .aN:nth-child(2n-1){color:red} 30.5 ms 10.73 ms 6.07 ms
Bun.build, 2,000 x .aN:nth-child(2n+1){color:red} (control) 30.9 ms 6.12 ms 5.88 ms
Bun.build, 2,000 x .aN:hover{color:red} (control) 33.2 ms 5.90 ms 5.78 ms
heaps per build, 2n-1 file 7 2016 16

The parser alone (cssInternals.minifyTest, 2,000 rules, one thread): 2n-1 7.11 ms to 3.56 ms, 2n+1 3.60 ms to 3.51 ms.

An A/A run of the Bun.build benchmark (the main binary against a copy of itself) gave ratios from 0.91 to 1.07 on this machine. The controls above are inside that band.

The constructor with fileExtensions is the slowest cell on main because it is the only one that writes to the new heap, and each heap stays alive until the GC collects its router.

Why the heap count is exact in the tests. Both tests run synchronous code on the JS thread with no other work in the process. The router test expects 0 heaps for 50 constructor calls and for 50 reload() calls. The CSS test expects the same count for 100 rules as for 1 rule, for each of the three call sites of parse_n_dash_digits (2n-3, n-5 and -n-5, +n-7). I ran both 20 times on the release build with no failure.

Other suites. test/js/bun/css/css-fuzz.test.ts passes on the release build with and without this diff. On the debug build its 10 s limits expire on this machine, and CI does not run that file (if (!isCI)).


no test proof · iteration 2 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/util/filesystem_router.test.ts

…d(An-B) selector

FileSystemRouter kept a boxed arena that nothing read. RouteConfig owns
its strings, so the arena held only a temporary copy of fileExtensions,
and reload() created a second arena that it never allocated from. Remove
the field and copy each extension into its Box once.

nth::parse_number_saturate created an arena for the tokenizer that reads
the digits of an An-B argument written without spaces (2n-1). Pass the
arena of the calling parser instead.
@robobun

robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:09 AM PT - Sep 16th, 2026

✅ @robobun, your commit a481c3b527d38f178032616e9880bdbbc3ef5955 passed in Build #116533! 🎉


🧪   To try this PR locally:

bunx bun-pr 42920

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

bun-42920 --bun

@robobun

robobun commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

How to reproduce on main. heapStats().mimalloc.heaps.total from bun:jsc counts every mimalloc heap that the process created.

// router.js: prints 1 heap per call for both on main, 0 with this PR
const fs = require("fs"), path = require("path"), os = require("os"), { heapStats } = require("bun:jsc");
const heaps = () => heapStats().mimalloc.heaps.total;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "r-"));
fs.writeFileSync(path.join(dir, "index.tsx"), "export default 1");
const router = new Bun.FileSystemRouter({ style: "nextjs", dir });
let before = heaps();
for (let i = 0; i < 100; i++) new Bun.FileSystemRouter({ style: "nextjs", dir });
console.log((heaps() - before) / 100, "heaps per constructor call");
before = heaps();
for (let i = 0; i < 100; i++) router.reload();
console.log((heaps() - before) / 100, "heaps per reload()");
fs.rmSync(dir, { recursive: true });
// nth.js: prints about 16 then 2016 heaps per build on main. With this PR the two counts are equal (16 to 18).
const fs = require("fs"), path = require("path"), os = require("os"), { heapStats } = require("bun:jsc");
const heaps = () => heapStats().mimalloc.heaps.total;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nth-"));
for (const arg of ["2n+1", "2n-1"]) {
  const file = path.join(dir, "a.css");
  fs.writeFileSync(file, Array.from({ length: 2000 }, (_, i) => `.a${i}:nth-child(${arg}){color:red}`).join("\n"));
  for (let i = 0; i < 3; i++) await Bun.build({ entrypoints: [file] });
  const before = heaps();
  await Bun.build({ entrypoints: [file] });
  console.log(`:nth-child(${arg}) x 2000:`, heaps() - before, "heaps per build");
}
fs.rmSync(dir, { recursive: true });

The new tests assert the same counts: test/js/bun/util/filesystem_router.test.ts and test/js/bun/css/nth-anplusb-ident.test.ts.

@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: 0c93eed8-5c90-40c3-a753-be024773feb5

📥 Commits

Reviewing files that changed from the base of the PR and between fa0157e and a481c3b.

📒 Files selected for processing (2)
  • test/js/bun/css/nth-anplusb-ident.test.ts
  • test/js/bun/util/filesystem_router.test.ts

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


Walkthrough

The change reuses existing arenas in CSS nth parsing and removes the separate arena from FileSystemRouter. Regression tests measure heap creation and verify selector output and router routes.

Changes

Allocator cleanup

Layer / File(s) Summary
CSS nth parser arena reuse
src/css/css_parser.rs, test/js/bun/css/nth-anplusb-ident.test.ts
CSS nth parsing passes the parser arena through numeric suffix parsing. Tests verify selector output and heap creation.
FileSystemRouter owned extensions
src/runtime/api/filesystem_router.rs, test/js/bun/util/filesystem_router.test.ts
FileSystemRouter stores owned extension data, removes its arena, and replaces only the router during reload. Tests cover repeated construction, reload, routes, and heap creation.

Suggested reviewers: jarred-sumner

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to a481c

The allocator changes preserve selector parsing, route behavior, and reload replacement while removing redundant heap creation. No blocking risk is identified.

🚥 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.
Title check ✅ Passed The title clearly summarizes the main change: preventing unnecessary mimalloc heap creation during FileSystemRouter operations and compact :nth-child(An-B) parsing.
Description check ✅ Passed The description explains the problem, fix, scope, measurements, and verification. It does not use the exact template headings, but it provides the required information and is sufficiently complete.

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.

I reviewed this PR and didn't find any bugs; a quick human look at the test metric would still be worthwhile since I could not run the new tests in this checkout.

What was reviewed:

  • FileSystemRouter: confirmed the removed arena had exactly one consumer (the fileExtensions copies, which were already re-boxed into RouteConfig.extensions), Router::Router carries no lifetime so nothing in router can borrow from it, and reload() never touched it beyond the swap — the "old router borrows from old arena" comment was already stale.
  • CSS nth: the sub-tokenizer input is guaranteed -<digits> by parse_n_dash_digits, so nothing is allocated in the caller's arena and no output changes; the three call sites and Parser::arena() (css_parser.rs:3108) line up.
  • Tests: both fail on main by construction (100 vs 1 heaps; 50 vs 0). The one thing I could not verify here is heapStats().mimalloc.heaps.total itself — the vendored mimalloc source is absent from this checkout, so whether that counter also increments for thread-default heap init (which would make the exact 0 / equal-count assertions flaky if any helper thread starts mid-loop under debug/ASAN CI) is worth a human confirming.
Extended reasoning...

Overview

Two independent allocator cleanups. In src/runtime/api/filesystem_router.rs the arena: JsCell<Box<ArenaAllocator>> field is deleted along with the unsafe detach_lifetime block that copied fileExtensions entries into it; the entries are now collected directly as Vec<Box<[u8]>> and moved into RouteConfig.extensions via into_boxed_slice(). reload() no longer allocates a fresh arena per call. In src/css/css_parser.rs (mod nth), parse_n_dash_digits and parse_number_saturate gain an arena: &Bump parameter and the three call sites pass input.arena() instead of constructing a throwaway Bump::new() (which is bun_alloc::Arena, i.e. an mi_heap_new/mi_heap_destroy pair). Two tests are added that count mimalloc heaps via heapStats().mimalloc.heaps.total.

Security risks

None. No user-controlled data changes its validation or path; the [1..] slicing of extension strings is preserved verbatim from the old code (with the existing get_length == 0 guard), and the CSS change only substitutes which arena backs a tokenizer whose input is already constrained to -<digits>.

Level of scrutiny

Moderate for the Rust changes, which I read fully: I traced every prior use of the router arena (only the extension copies), confirmed Router::Router has no lifetime parameter so JsCell<Router::Router> cannot borrow from the removed field, and checked that reload()'s snapshot-and-swap still works. For CSS, parse_n_dash_digits verifies bytes[2..] are all ASCII digits before calling parse_number_saturate with &str[1..], so the sub-parser only ever sees -<digits> and produces a Token::Number, which does not allocate from the arena; even if it did, the caller's arena outlives the stylesheet parse. Both changes are behavior-preserving by inspection.

Other factors

No debug build exists in this checkout and Bash was denied for build/run, so I could not execute the new tests. The vendored mimalloc tree is also absent, so I could not confirm from source that heaps.total exists in the oven-sh fork's mi_stats_get_json output or whether it counts only mi_heap_new heaps versus thread-default heap initialization. The author reports 20 clean runs on a release build; the residual risk is a flaky exact-0/equal-count assertion under debug/ASAN CI if a helper thread's heap init is counted. That is a CI-visible concern rather than a correctness bug in the runtime change, which is why I defer rather than approve or stay silent.

@robobun

robobun commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

On the open question in the review above: heaps.total does not count a thread that starts. I checked the source and ran it.

Source (vendor/mimalloc at the commit that main pins). heaps has one increment, mi_subproc_stat_increase(subproc, heaps, 1) in _mi_heap_init (src/heap.c:126). _mi_heap_init has two callers:

  • _mi_heap_new_for_subproc (src/heap.c:148). mi_heap_new and mi_heap_new_in_arena reach it. mi_subproc_new also reaches it, but Bun does not call mi_subproc_new.
  • The process main heap (src/init.c:221), one time.

The first allocation of a new thread creates a theap. That increments a different counter, theaps (src/theap.c:638).

Run. The script reads both counters, starts work pool threads (64 crypto.pbkdf2 calls and 64 fs.promises.readFile calls), reads the counters again, creates 50 routers, and reads them a third time.

build start after the threads after 50 routers
release, this PR heaps 5, theaps 13 heaps 5, theaps 27 heaps 5, theaps 27
release, main heaps 5, theaps 13 heaps 5, theaps 25 heaps 55, theaps 25
debug + ASAN, this PR heaps 4, theaps 2 heaps 4, theaps 14 heaps 4, theaps 14
Script
const { heapStats } = require("bun:jsc");
const crypto = require("crypto"), fs = require("fs"), os = require("os"), path = require("path");
const snap = () => { const m = heapStats().mimalloc; return { heaps: m.heaps.total, theaps: m.theaps.total }; };
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "t-"));
fs.writeFileSync(path.join(dir, "index.tsx"), "export default 1");
const before = snap();
await Promise.all([
  ...Array.from({ length: 64 }, (_, i) => new Promise((resolve, reject) => crypto.pbkdf2("p" + i, "salt", 2000, 32, "sha256", (e, k) => (e ? reject(e) : resolve(k))))),
  ...Array.from({ length: 64 }, () => fs.promises.readFile(path.join(dir, "index.tsx"))),
]);
const afterThreads = snap();
for (let i = 0; i < 50; i++) new Bun.FileSystemRouter({ style: "nextjs", dir });
const afterRouters = snap();
console.log(JSON.stringify({ before, afterThreads, afterRouters }));
fs.rmSync(dir, { recursive: true });

So only a MimallocArena::new() on some thread during the measured loop can change the count. Both tests measure synchronous code, and no bundler or transpiler work is in flight at that point in either file. In build #116516 both files passed on the ASAN lane, on macOS aarch64, on Windows x64 and on Windows aarch64.

I added one comment line to each test that says what the counter counts.

@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.

Code review found no issues

No high-confidence issues detected in this change.

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