Skip to content

js_parser: start P::init with empty symbol tables; whole-file passes pass their length - #46404

Open
robobun wants to merge 8 commits into
mainfrom
robobun/daa5deff/inline-snapshot-parser-presize
Open

robobun wants to merge 8 commits into
mainfrom
robobun/daa5deff/inline-snapshot-parser-presize

Conversation

@robobun

@robobun robobun commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #46090: this branch holds its commits. Merge that first.

Problem

  • bun test builds one parser over the whole test file for each inline snapshot it writes. The parser's constructor (P::init) sized its tables from the file: 8,389,376 bytes for 1 MiB, 4,849,856 never freed.
  • The largest test file here (414 snapshots, 1.47 MB), all written: 97 MiB peak RSS, 33 MiB fixed. Under ulimit -v 4000000 it ends in oh no: Bun has run out of memory. and writes none.

Fix

  • P::init passes 0 to init_with_text_len: the tables start empty, as in 1.3.x. _parse, _scan_imports and parse_only pass the source length. init_p! requires it.
  • Correct because those passes get the same tables: their release code is identical.
  • Verified: test/js/bun/test/snapshot-tests/new-snapshot.test.ts (1 new test, fails without it). Also ran snapshots/snapshot.test.ts, bundler_loader.test.ts, transpiler.test.js.
  • Self-reviewed: 22 concerns raised, 19 addressed. Not taken: AST scopes in snapshot.rs (separate change), js_parser: do not size a lazy-export AST's symbol tables from the source length #46090's branch as the base (CI cannot build it).

Background

Downsides

  • A whole-file pass that passes no length is 0.67% slower (1000 modules, cold).
  • Release text: +0 bytes. Whole-file parses: +0.006% user instructions (noise floor 0.021%).
  • Not fixed: _parse sizes the tables for a cached or // @bun module and parses nothing.
Notes

Where this comes from. No user reported it. The review of #46090 found the snapshot writer as a second place that builds a P with no whole-file pass: #46090 (comment). It is a developer-machine path: in CI, bun test refuses a new inline snapshot without --update-snapshots.

Stacking. This branch holds the commits of #46090, so this page shows its diff too: bundler_loader.test.ts, the text_len argument and the 0 of the lazy export are from #46090. The change of this PR is the commit 4bc326e, 3 files, +55 -15: P::init passes 0, init_p! has one arm, its four call sites, and the test in new-snapshot.test.ts. It removes the second arm that #46090 adds to init_p!, and the whole-file default of its P::init. The branch of #46090 cannot be the base of this PR: CI does not build a branch without commit b7efd52 of main, and that branch does not have it. After #46090 merges, I rebase the one commit onto main.

Question for a maintainer. Which default should the public P::init have? Here it starts empty, and the three whole-file passes pass their length inside the crate. The review of #46090 named the other shape: keep the whole-file default, make init_with_text_len pub, pass 0 from snapshot.rs. I did not build that shape. I expect the same release code from it, because the writer's call here is already a direct init_with_text_len(.., 0). The switch is about 3 lines. #41861 fixed the same class in the renamer: a table sized from the whole input, one for each unit of work.

History. The writer had this shape in Zig too: one parser for each snapshot, one arena for each file. The Zig P.init made the tables empty. The Rust port sizes members and symbol_uses from source.contents.len() / 16 (#30412, in 1.4.0). #41286 (in 1.4.1) added the scope list at len / 64 and the symbols list at len / 16.

Where the bytes went. For a file of 1,048,762 bytes, each parser made six allocations:

Table Bytes Allocator Freed
scope list 393,264 arena when the parser drops
symbols 3,146,256 arena when the parser drops
module scope members 3,801,104 AstAlloc never
symbol_uses (3 columns) 1,048,752 AstAlloc never

write_inline_snapshots installs no AST allocation scope. AstAlloc then uses the global heap, and its deallocate does nothing. hashbrown writes one control byte for each bucket of members: that is the RSS.

Measurements. Release builds, linux x64 only. Base is main 71bc818 with #46090 (fd66e0a71). Each test file is generated: one comment line for the size, and N calls of expect(i).toMatchInlineSnapshot(). Peak RSS is VmHWM and the address space is VmPeak, both read from /proc/<pid>/status at exit.

N snapshots, file size base this PR nothing to write
3, 19 KB (near the median test file here: 2 snapshots, 18 KB) 26.6 to 27.4 MiB 27.2 to 27.4 MiB
18, 195 KB 28.5 MiB 27.9 MiB
414, 1,469,296 bytes (the shape of test/cli/check/check.test.ts) 96.4 to 96.9 MiB 32.1 to 33.6 MiB 30.6 MiB
the same, ulimit -v 4000000 exit 134, 0 written exit 0, 414 written
2000, 1 MiB 338.2 MiB 33.1 MiB 30.5 MiB
2000, 1 MiB, VmPeak 16575 MiB 10350 MiB 10350 MiB
2000, 1 MiB, minor page faults 80499 2729 2133
2000, 1 MiB, ulimit -v 11000000 exit 134, 0 written exit 0, 2000 written
200, 8 MiB 264.4 MiB 56.1 MiB 37.6 MiB
400, 1 MiB, user instructions 78.6M to 85.6M 68.2M 11.8M to 12.0M
  • It is a memory fix. Release time does not change at ordinary sizes.
  • With --parallel=2, two files of 2000 snapshots and ulimit -v 11000000, the workers of the base abort when they write their snapshots. The run still exits 0, with 0 of 4000 written. bun test --parallel: fail the run when a worker is killed or exits non-zero outside a test file #44431 is about that exit code. With this PR: 4000 of 4000.
  • The abort needs an address-space limit. One whole-file parse still asks for about 8 times its source.
  • gdb, 3 snapshots in the 1 MiB file, allocator requests of 100,000 bytes or more inside write_inline_snapshots: 21 (29,362,949 bytes) in the base, 3 (4,194,821 bytes) here. The 3 are the file and the result buffer. Blocks that nothing frees: 12 (14,549,568 bytes) in the base, 0 here.
  • size: text 107,319,777 in both builds, data and bss the same. All 105,435 text symbols have the same address and size.
  • objdump with the addresses removed: the four init_with_text_len, both _parse, both _scan_imports, both parse_only and to_lazy_export_ast have 0 instructions that differ, of 16,916. write_inline_snapshots has 1 of 2,167: push QWORD PTR [rbp-0x1d8] is now push 0x0.
  • gdb, transformSync of a module of 6,400 bytes: 9 allocator calls before the parse pass in both builds. The constructor asks for 2400, 128, 14864, 3200, 1600, 1600 and 19200 bytes in both.
  • User instructions (perf_event_open, runs of base and PR interleaved): a cold require of 1000 modules of 15,403 bytes is +0.006% (15 runs each, two copies of the base differ by 0.021%). bun build of typescript.js is +0.017% (7 runs each, two copies of the base differ by 0.028%).
  • A base binary patched so that _parse passes 0 is +0.666% on the 1000 modules. That is what the size is worth there.

The test. The child bun test runs a file with a comment of 512 KiB and 128 empty toMatchInlineSnapshot() calls. BUN_TEST_DRAIN_EVENT_LOOP=1 makes bun test run the exit listeners of the file, after it wrote the snapshots. The listener prints how much heapStats().mimalloc.committed.peak grew since afterAll. The test asserts the text of the rewritten file, and a growth below 128 times the comment (67,108,864 bytes).

Build Growth in bytes
base, release 344,064,000 to 359,727,104
this PR, release 851,968 to 3,735,552
base, debug with ASAN (64 snapshots) 171,114,496
this PR, debug with ASAN 0

The test is in new-snapshot.test.ts, not in the inline snapshots block of snapshots/snapshot.test.ts. That file passes only with FORCE_COLOR=1, which the CI runner sets: its error snapshots test has colors in its snapshot. A plain bun bd test of it fails with and without this change.

Not changed. No issue tracks these yet.

  • _parse builds the parser, and then returns for a transpiler-cache hit or a // @bun file with no parse pass. For a module of 1,048,576 bytes both make the 6 allocations, 8,388,624 bytes (gdb, on the build of this PR). transpiler cache: answer a hit before the parser and its heap are set up #43834 (draft) moves the cache lookup ahead of the parser. _parse is the hot path, so a change there needs its own numbers.
  • Snapshots::parse_file parses each .snap file with no AST allocation scope, so the AstAlloc tables of that parse are not freed. 10 test files with a .snap of 1 MiB each leave 48,627,712 bytes of large blocks live at the end, 1 file leaves 7,929,856. It is the same in the base.
  • The lists that the writer's parser allocates for a property matcher are not freed: 128 bytes for { a: expect.any(Number) }.
  • bun repl parses each input with no AST allocation scope too (gdb: the same four allocations come from the global heap). bun pm diff has none either, by a read of pm_diff_normalize.rs. Not measured.

Suites run. On a debug build with ASAN: test/js/bun/test/snapshot-tests/new-snapshot.test.ts, test/js/bun/test/snapshot-tests/snapshots/snapshot.test.ts (with FORCE_COLOR=1), test/bundler/bundler_loader.test.ts, test/bundler/transpiler/transpiler.test.js. The new test also ran on the release builds of the base and of this change.

Not run. macOS, Windows. On Windows, mimalloc commits a large block when it allocates it (has_overcommit = false in vendor/mimalloc/src/prim/windows/prim.c), so each of these tables counts against the commit limit there. I did not measure that.


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

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/bundler_loader.test.ts test/js/bun/test/snapshot-tests/new-snapshot.test.ts
ninja: Entering directory `/workspace/bun/build/debug'
[0/2] cargo plan → /workspace/bun/build/debug/rust-target/plan.json
FAILED: [code=1] rust-target/plan.json /workspace/bun/build/debug/rust-target/plan.json 
/workspace/bun/build/release/bun /workspace/bun/scripts/build/stream.ts cargo --console /workspace/bun/build/release/bun /workspace/bun/scripts/build/rust/plan.ts /workspace/bun/build/debug/rust-target/plan.input.json /workspace/bun/build/debug/rust-target/plan.json
�[1m�[91merror�[0m: failed to load manifest for workspace member `/workspace/bun/src/runtime`
referenced by workspace at `/workspace/bun/Cargo.toml`

Caused by:
  failed to parse manifest at `/workspace/bun/src/runtime/Cargo.toml`

Caused by:
  error inheriting `bun_histogram` from workspace root manifest's `workspace.dependencies.bun_histogram`

Caused by:
  `dependency.bun_histogram` was not found in `workspace.dependencies`
error: /opt/rust/bin/cargo build -p bun_runtime --l
... (truncated)

release without fix: all passed
bun test v1.4.4-canary.1 (6fa937c3c)

test/bundler/bundler_loader.test.ts:
(pass) bundler > bun loader > bun/loader-yaml-file [89.19ms]
(pass) bundler > bun loader > bun/loader-text-file [38.19ms]
(pass) bundler > bun loader > bun/loader-json-file [24.38ms]
(pass) bundler > bun loader > bun/loader-toml-file [27.53ms]
(pass) bundler > bun loader > bun/loader-toml-datetime-shadowed-temporal-global [24.46ms]
(pass) bundler > bun loader > bun/loader-toml-datetime-imported-temporal-binding [25.22ms]
(pass) bundler > bun loader > bun/loader-toml-datetime-no-bundle [33.02ms]
(pass) bundler > bun loader > bun/loader-toml-datetime [28.65ms]
(pass) bundler > bun loader > bun/loader-text-file [15.08ms]
(pass) bundler > bun loader > bun/loader-xml-file [19.52ms]
(pass) bundler > node loader > bun/loader-yaml-file [20.94ms]
(pass) bundler > node loader > bun/loader-text-file [23.77ms]
(pass) bundler > node loader > bun/loader-json-file [27.72ms]
(pass) bundler > node loader > bun/loader-toml-file [18.65ms]
(pass) bundler > node loader > bun/loader-toml-datetime-shadowed-temporal-global [25.02ms]
(pass) bundler > node loader > bun/loader-toml-datetime-imported-temporal-binding [1
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/bundler_loader.test.ts test/js/bun/test/snapshot-tests/new-snapshot.test.ts
bun test v1.4.4 (367d939d9)

test/bundler/bundler_loader.test.ts:
(pass) bundler > bun loader > bun/loader-yaml-file [840.08ms]
(pass) bundler > bun loader > bun/loader-text-file [623.25ms]
(pass) bundler > bun loader > bun/loader-json-file [546.83ms]
(pass) bundler > bun loader > bun/loader-toml-file [653.16ms]
(pass) bundler > bun loader > bun/loader-toml-datetime-shadowed-temporal-global [760.46ms]
(pass) bundler > bun loader > bun/loader-toml-datetime-imported-temporal-binding [451.30ms]
(pass) bundler > bun loader > bun/loader-toml-datetime-no-bundle [494.52ms]
(pass) bundler > bun loader > bun/loader-toml-datetime [453.97ms]
(pass) bundler > bun loader > bun/loader-text-file [435.06ms]
(pass) bundler > bun loader > bun/loader-xml-file [449.49ms]
(pass) bundler > node loader > bun/loader-yaml-file [454.10ms]
(pass) bundler > node loader > bun/loader-text-file [411.67ms]
(pass) bundler > node loader > bun/loader-json-file [503.00ms]
(pass) bundl
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 7147ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[0/2] cargo plan → /workspace/bun/build/release/rust-target/plan.json
282 units: 194 lib, 16 proc-macro (host), 27 custom-build (host), 23 run custom-build, 17 lib (host), 4 run custom-build (host), 1 rlib
[1/371] gen cpp.rs (cppbind)
[2/371] gen BunProcess.lut.h
Generating /workspace/bun/build/release/codegen/BunProcess.lut.h from /workspace/bun/src/jsc/bindings/BunProcess.cpp
[3/371] gen ProcessBindingNatives.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingNatives.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingNatives.cpp
[4/371] gen generated_host_exports.rs
generated_host_exports.rs: 111 exports (lazy=4, generic=107, rust=0); 252 extern-C blocks audited
[5/370] gen .bind.ts → Generated*.{cpp,h}, generated_bindgen_*.rs
[6/362] gen JS modules (bundle-modules)
Preprocess modules (9301ms)
Bundle modules (303ms)
Postprocesss modules (290ms)
Bundle Functions (561ms)
Generate Code (89ms)

[10.71s] Bundled "src/js" for production
  2693 kb
  202 internal modules
  12 native m
... (truncated)
diff hotspot
src/js_parser/p.rs                                 | 23 ++++++++--
 src/js_parser/parse/parse_entry.rs                 | 18 ++++----
 test/bundler/bundler_loader.test.ts                | 49 +++++++++++++++++++++-
 .../bun/test/snapshot-tests/new-snapshot.test.ts   | 41 ++++++++++++++++++
 4 files changed, 118 insertions(+), 13 deletions(-)

gate history · 2 passed · 0 rejected · iteration 0

evidence per changed file
file                                                  reads  edits  tests
src/js_parser/p.rs                                        2      2     41
src/js_parser/parse/parse_entry.rs                        1      1     41
test/bundler/bundler_loader.test.ts                       0      0     11
test/js/bun/test/snapshot-tests/new-snapshot.test.ts      2      1     33

…rce length

P::init reserves its symbol tables from the length of the source, about
8 bytes for each byte of it. to_lazy_export_ast has no parse pass, so
the module of an asset, a JSON file or a text file reserved that much
for tables that stay empty.

P::init_with_text_len takes the length as an argument. The lazy export
passes 0.
An ASAN build keeps the copy for the output file out of mimalloc, so the
growth there is 1.1 times the asset. A lower bound of the whole asset
left a margin of 6%.
P::init sized the symbol tables of the parser from the length of the
source, about 8 bytes for each byte of it. bun test builds one parser
over the whole test file for each inline snapshot it writes, and parses
only the arguments of one matcher call with it. A test file with many
new inline snapshots reserved that much once for each snapshot, and
more than half of it was never freed.

P::init now passes 0 to init_with_text_len. The passes over the whole
source (_parse, _scan_imports, parse_only) pass the length of the
source themselves: init_p! always calls init_with_text_len.
@robobun

robobun commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:44 AM PT - Oct 27th, 2026

❌ @robobun, your commit bcd1c02 has 1 failures in Build #133493 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 46404

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

bun-46404 --bun

@robobun

robobun commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

How it was reproduced, on release builds of main 71bc818 with #46090, linux x64:

# snap.test.ts: one comment line of 1,453,185 bytes, then 414 lines `expect(i).toMatchInlineSnapshot();`
# inside one test. That is 1,469,296 bytes, the shape of test/cli/check/check.test.ts.
env -u CI bun test ./snap.test.ts   # peak RSS is VmHWM from /proc/<pid>/status
  • Without the change: peak RSS 96.4 to 96.9 MiB. With it: 32.1 to 33.6 MiB. When nothing is written: 30.6 MiB.
  • Under ulimit -v 4000000, the build without the change ends in oh no: Bun has run out of memory. (exit 134) and writes 0 of 414. With the change it exits 0 and writes 414.

PR: #46404

CI. The change is green. The one red job is a failure of a CI machine.

  • Build 130549 (da2c1e8) passed on every lane.
  • Build 133420 (cd22e57, with main merged in): 179 jobs passed. A later push canceled the last 2 (macOS x64 tests).
  • Build 133493 (bcd1c02, one more line: the lifetime of a parameter) is complete: 180 jobs passed, 1 failed. The job darwin any aarch64 - test-bun fails before it runs a test. The agent darwin-aarch64-26.6.2-1 does not get the build: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'. Build 133485 failed in the same way on the same agent. The same lane passes on main on other agents.
  • A retry of that job is sufficient. js_parser: do not size a lazy-export AST's symbol tables from the source length #46090 must merge before this PR.

…ne-snapshot-parser-presize

# Conflicts:
#	test/js/bun/test/snapshot-tests/new-snapshot.test.ts

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

@robobun
robobun changed the base branch from robobun/18952770/lazy-export-presize to main October 22, 2026 01:08
@coderabbitai

coderabbitai Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 569c096d-11cc-4100-8665-f4cc73d65a15
📥 Commits

Reviewing files that changed from the base of the PR and between f2de590 and da2c1e8.

📒 Files selected for processing: 4
  • src/js_parser/p.rs
  • src/js_parser/parse/parse_entry.rs
  • test/bundler/bundler_loader.test.ts
  • test/js/bun/test/snapshot-tests/new-snapshot.test.ts

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

Walkthrough

Parser initialization now uses a supplied text length to estimate symbol and scope capacity. Parsing entry points pass the source length, and new bundler and snapshot tests measure memory use in those scenarios.

Changes

Parser Capacity Hint and Memory Tests

Layer / File(s) Summary
Pass text length to parser initialization
src/js_parser/p.rs, src/js_parser/parse/parse_entry.rs
P::init delegates to init_with_text_len. Parsing entry points pass source length; to_lazy_export_ast passes zero.
Check memory use in bundler and snapshot scenarios
test/bundler/bundler_loader.test.ts, test/js/bun/test/snapshot-tests/new-snapshot.test.ts
New tests check build output and committed-memory growth for small and large assets, and snapshot output, process status, and heap growth for generated tests.

Suggested reviewers: jarred-sumner

Priority: Normal

Merge Risk: Minimal · up to da2c1

No actionable issue was identified that would prevent merging after normal checks.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly summarizes the main change: P::init starts with empty symbol tables, and whole-file passes provide the source length.
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 follows the repository template with both required sections: "What does this PR do?" (Problem, Fix, Background, Downsides) and "How did you verify your code works?" (Verified, test det…
Full details: Description check

Explanation

The description follows the repository template with both required sections: "What does this PR do?" (Problem, Fix, Background, Downsides) and "How did you verify your code works?" (Verified, test details, suites run, measurements). It names the test added, the commands and build profiles used, and what was not verified (macOS, Windows). It also states the stacking dependency on #46090 and the remaining unfixed paths. The description is thorough and specific, and the claims are backed by measurements. Minor gap: the template's verification section is long and the core change is spread across a details block, which makes the reviewer work harder to find the key points. Nothing required is missing.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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.

@robobun

robobun commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator Author

Merged main (8a0e49e) into this branch.

  • One conflict, in src/js_parser/parse/parse_entry.rs, at the init_p! call of the scan pass. Main (98a4365, Fix use-after-free of BuildMessage.position.file: real lifetimes for Source, Path, ImportRecord and resolve results #46834) changed Pi<'_, TS> to Pi<'a, TS>. This PR adds the self.source.contents.len() argument. The resolution keeps both.
  • The added and removed lines of the PR are the same as before the merge (4 files, +118 -13). Only the context of that hunk changed.
  • cargo check --workspace passes on the merged tree (Linux x64).
  • Tests, on a local debug build that contains this merge and the main merges of eight other open PRs: test/bundler/bundler_loader.test.ts (65 pass), test/js/bun/test/snapshot-tests/new-snapshot.test.ts (3 pass).

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

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

The merge of main kept the elided lifetime of this signature. main changed
P::init to take &'a Source<'a> (#46834).

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

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

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.

1 participant