test runner: dispatch pretty_format::print_as on a runtime Tag

RO
2mo ago
+1476-1367

Problem

  • The debug (ASAN) build fails the repo's own test/js/bun/test/pretty-format-overflow.test.ts on Linux with the default 8 MB stack. The child bun test exits 139 (stack overflow) instead of 1 while it formats the 500-level object for the toEqual diff. The test is red on main under bun bd test and green here.
  • Formatter::print_as in src/runtime/test_runner/pretty_format.rs is generic over const FORMAT: Tag and holds all 24 arms in one closure. rustc emits the whole body per instantiation, so every print_as frame carries the locals of all 24 arms: 10 to 13 KB under ASAN, per nesting level. The body also compiles 48 times: 480K lines of LLVM IR, 15% of bun_runtime.

Fix

  • format is a runtime parameter. Each arm is its own #[inline(never)] method, the shape console_object::Formatter::print_as in src/jsc/ConsoleObject.rs already has.
  • No new dispatch: Formatter::format already matched on the tag at runtime. Arm bodies moved verbatim (hide whitespace in the diff view).
  • Per nesting level the debug build spends about 6 KB of stack instead of about 16 KB. The fixture passes at 1200 levels and overflows at 1400. Main overflows at 500. bun_runtime IR: 3,149,105 to 2,687,005 lines.
  • Verified: pretty-format-overflow.test.ts now asserts the innermost property is printed and the child exits 1 with no signal. It fails on main under bun bd (exit 139) and passes here. Also the snapshot, printing, expect and mock suites under test/js/bun/test.

Background

  • pretty_format.rs is the serializer behind toEqual diffs and toMatchSnapshot. print_as renders one value and recurses through format for nested values, so its frame size sets the depth the formatter survives.
  • A const generic parameter does not prune a match before codegen. LLVM deletes the dead arms later. At opt-level 0 their locals stay in the frame.
  • #34884 adds a stack guard to this formatter. The guard bounds the recursion. This change decides where the bound lands. They are independent.
Notes

Depth ceiling, measured with the fixture from pretty-format-overflow.test.ts at DEPTH levels, ulimit -s 8192, debug ASAN build (bun bd): main exits 1 at 400 and segfaults at 500. This branch exits 1 at 1200 and segfaults at 1400. The CI ASAN lane is a release build (ci-build profile), so CI does not see the debug frames. Local bun bd does.

Frame sizes (sub rsp in the prologue, debug ASAN build). Before: every print_as::<.., Tag::X, ..>::{closure#0} was 0x2860 to 0x32c0 bytes (10,336 to 12,992), Tag::Object 0x2a80 (10,880). After: print_as 0x180 (384), print_object 0x280 (640), print_array 0x320 (800), print_typed_array 0xb80 (2944, the largest). The rest of the per-level cost is in PropertyIterator::for_each, the C++ forEachPropertyOrdered frame and format. A follow-up can port the for_each_prelude plus #[inline(never)] property-key split that ConsoleObject.rs uses into PropertyIterator::for_each for another 1 to 2 KB per level.

IR measured with cargo llvm-lines on every crate (release profile, -Cno-prepopulate-passes, LTO off). Whole workspace before: 8,305,084 lines in 151,486 function copies. print_as and its closures: 480,240 lines in 576 copies. After: 20,620 lines in 70 copies.

Compile time moves little, because LLVM deletes the dead arms in its first passes. bun_runtime alone, release profile with LTO off, codegen-units=1: 136.0 s before vs 128.4 s after in one pair of runs, equal within noise in another pair. -Ztime-passes: codegen_to_LLVM_IR 9.7 s to 8.6 s, LLVM_passes 113.8 s to 107.2 s. Peak rustc RSS 3763 MB to 3634 MB. Dev profile with ASAN: 17.3 s vs 17.2 s. The release libbun_runtime.a is 11 KB smaller.

The transformation was mechanical: arm bodies dedented into methods, self.print_as::<W, { Tag::X }, C>( rewritten to self.print_as::<W, C>(Tag::X, , the two arms that ended in return converted to tail expressions, &mut writer passed as writer. Only value and js_type are passed to the arms that read them. write_with_formatting (the %s/%d/%o path) calls print_as with its runtime token instead of matching over four instantiations. git diff -w shows about 260 added and 150 removed lines.

Because the arm bodies move, open PRs that edit them need a rebase: #40235 (unsafe removal in the test runner, touches the Private, Map, Set, Object and TypedArray arms), #34884, and the snapshot formatting PRs (#38935, #38939, #36674, #36645, #35712, #37043, #31292, #40319, #29709). The bodies are unchanged, so the rebase is a re-indent. If #40235 should land first, this transform can be re-applied on top of it.


[review] gate passed · iteration 2 · 2 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/test/pretty-format-overflow.test.ts
bun test v1.4.1 (adc354d99)

test/js/bun/test/pretty-format-overflow.test.ts:
45 |     // The diff goes to stderr. Drain stdout too so the child never blocks on it.
46 |     const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited, proc.stdout.text()]);
47 | 
48 |     // The diff header proves the formatter ran; the innermost property proves
49 |     // it walked all 500 levels instead of dying part-way down.
50 |     expect(stderr).toContain("expect(received).toEqual(expected)");
                        ^
error: expect(received).toContain(expected)

Expected to contain: "expect(received).toEqual(expected)"
Received: "\nnested.test.ts:\n"

      at <anonymous> (/workspace/bun/test/js/bun/test/pretty-format-overflow.test.ts:50:20)
(fail) pretty_format should handle deeply nested objects without crashing > deeply nested object with many properties [961.68ms]

 0 pass
 1 fail
 1 expect() calls
Ran 1 test across 1 file. [3.00s]
error: script "bd" exited with code 1
__F:1:S:0

release without fix: all passed
bun test v1.4.1-canary.1 (d99fc726f)

test/js/bun/test/pretty-format-overflow.test.ts:
(pass) pretty_format should handle deeply nested objects without crashing > deeply nested object with many properties [12.81ms]

 1 pass
 0 fail
 4 expect() calls
Ran 1 test across 1 file. [91.00ms]
__F:0:S:0
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/mechgate.xml" test/js/bun/test/pretty-format-overflow.test.ts
bun test v1.4.1 (adc354d99)

test/js/bun/test/pretty-format-overflow.test.ts:
(pass) pretty_format should handle deeply nested objects without crashing > deeply nested object with many properties [893.74ms]

 1 pass
 0 fail
 4 expect() calls
Ran 1 test across 1 file. [2.85s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 611ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/5] gen generated_host_exports.rs
generated_host_exports.rs: 116 exports (host=5, lazy=10, generic=101, rust=0); 243 extern-C blocks audited
[1/5] cargo bun_runtime → libbun_runtime.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

   Compiling bun_js_parser v0.0.0 (/workspace/bun/src/js_parser)
   Compiling bun_js_printer v0.0.0 (/workspace/bun/src/js_printer)
   Compiling bun_resolver v0.0.0 (/workspace/bun/src/resolver)
   Compiling bun_ini v0.0.0 (/workspace/bun/src/ini)
   Compiling bun_bundler v0.0.0 (/workspace/bun/src/bundler)
   Compiling bun_router v0.0.0 (/workspace/bun/src/router)
   Compiling bun_standalone_graph v0.0.0 (/workspace/bun/src/standalone_graph)
   Compiling bun_transpiler v0.0.0 (/workspace/bun/src/transpiler)
   Compiling bun_bunfig v0.0.0 (/workspace/bun/src/bunfig)
[1
... (truncated)
diff hotspot
src/runtime/test_runner/pretty_format.rs        | 2814 ++++++++++++-----------
 test/js/bun/test/pretty-format-overflow.test.ts |   29 +-
 2 files changed, 1476 insertions(+), 1367 deletions(-)

gate history · 2 passed · 2 rejected · iteration 2

evidence per changed file
file                                             reads  edits  tests
src/runtime/test_runner/pretty_format.rs            11      2      0
test/js/bun/test/pretty-format-overflow.test.ts      3      3      0