Skip to content

test_runner: add StackCheck to pretty_format to stop SIGSEGV on deeply nested diff/snapshot values#34885

Open
robobun wants to merge 5 commits into
mainfrom
farm/a238cd6d/pretty-format-stack-check
Open

test_runner: add StackCheck to pretty_format to stop SIGSEGV on deeply nested diff/snapshot values#34885
robobun wants to merge 5 commits into
mainfrom
farm/a238cd6d/pretty-format-stack-check

Conversation

@robobun

@robobun robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

What

bun test SIGSEGVs (exit 139, no test summary) when a failing toEqual/toBe diff or a toMatchSnapshot serializer walks a ~20k-deep value. At 8k depth it still prints a correct 1 fail with a diff; past that the native stack is exhausted inside pretty_format::Formatter::print_as and the whole runner dies mid-run.

import { test, expect } from "bun:test";
test("deep diff", () => {
  let a: any = [];
  for (let i = 0; i < 20000; i++) a = [a];   // or {k:{k:{...}}} 20k deep
  expect(a).toEqual([1]);           // failing diff -> SIGSEGV
  // expect(a).toMatchSnapshot();   // snapshot serializer -> SIGSEGV
});
$ bun test repro.test.ts
bun test v1.4.0-canary.1 (1498d7b77)

repro.test.ts:
Segmentation fault (core dumped)

Why

src/runtime/test_runner/pretty_format.rs's Formatter only carries an identity-keyed visited map for cycle detection; it has no StackCheck and no depth limit, so a deep acyclic structure recurses print_as until the process crashes. The sibling formatter in src/jsc/ConsoleObject.rs already guards the same recursion with StackCheck::is_safe_to_recurse().

Fix

Add a stack_check: StackCheck field to pretty_format::Formatter, initialized via StackCheck::init() in Formatter::new() so the bound is armed by default. Before descending into any can_have_circular_references() tag (Array/Object/Map/Set) in print_as, check is_safe_to_recurse(); when it fails, set failed = true and return. The matcher still reports a normal failure with a truncated diff, and the snapshot serializer writes a truncated serialization, instead of the process dying.

Verification

# fail-before
$ USE_SYSTEM_BUN=1 bun test test/js/bun/test/pretty-format-overflow.test.ts
 1 pass
 3 fail

# pass-after
$ bun bd test test/js/bun/test/pretty-format-overflow.test.ts
 4 pass
 0 fail

New tests cover deep arrays, deep objects, and the snapshot path, each in a subprocess so a regression fails the suite rather than segfaulting the outer runner. expect.test.js (415 tests) and the snapshot suites pass unchanged.


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

fails on main (without fix)
ASAN without fix: 5 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
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (faf9c956f)

test/js/bun/test/pretty-format-overflow.test.ts:
41 |     });
42 | 
43 |     const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
44 | 
45 |     // The test should fail due to assertion mismatch, but should NOT crash
46 |     expect(exitCode).toBe(1);
                          ^
error: expect(received).toBe(expected)

Expected: 1
Received: 139

      at <anonymous> (/workspace/bun/test/js/bun/test/pretty-format-overflow.test.ts:46:22)
(fail) pretty_format should handle deeply nested objects without crashing > deeply nested object with many properties [6570.99ms]
100 |       cwd: String(dir),
101 |       stderr: "pipe",
102 |       stdout: "pipe",
103 |     });
104 |     const [, 
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (faf9c956f)

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 [47.71ms]
(pass) pretty_format stops recursion before native stack overflow > toMatchSnapshot on a deeply nested array [25.78ms]
(pass) pretty_format stops recursion before native stack overflow > failing toEqual on a deeply nested object [27.39ms]
(pass) pretty_format stops recursion before native stack overflow > failing toEqual on a deeply nested array [34.02ms]
(pass) pretty_format stops recursion before native stack overflow > failing toEqual on a deeply nested React element chain [37.88ms]

 5 pass
 0 fail
 19 expect() calls
Ran 5 tests across 1 file. [279.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
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (faf9c956f)

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 [6668.93ms]
(pass) pretty_format stops recursion before native stack overflow > failing toEqual on a deeply nested object [523.46ms]
(pass) pretty_format stops recursion before native stack overflow > failing toEqual on a deeply nested array [560.20ms]
(pass) pretty_format stops recursion before native stack overflow > toMatchSnapshot on a deeply nested array [530.47ms]
(pass) pretty_format stops recursion before native stack overflow > failing toEqual on a deeply nested React element chain [717.32ms]

 5 pass
 0 fail
 19 expect() calls
Ran 5 
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 692ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/22] gen cpp.rs (cppbind)
[2/22] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 244 extern-C blocks audited
[3/22] gen JS modules (bundle-modules)
Preprocess modules (7584ms)
Bundle modules (35ms)
Postprocesss modules (172ms)
Bundle Functions (724ms)
Generate Code (105ms)

[8.64s] Bundled "src/js" for production
  2042 kb
  165 internal modules
  13 native modules
  90 internal functions across 19 files
[3/8] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rus
... (truncated)
diff hotspot
src/runtime/test_runner/pretty_format.rs        |  11 ++-
 test/js/bun/test/pretty-format-overflow.test.ts | 108 +++++++++++++++++++++++-
 2 files changed, 117 insertions(+), 2 deletions(-)

gate history · 2 passed · 0 rejected · iteration 1

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

…ow on deep values

A failing toEqual diff or toMatchSnapshot over a ~20k-deep (non-circular)
array or object would recurse Formatter::print_as until the native stack
was exhausted and the test runner SIGSEGV'd mid-run with no summary.

The sibling formatter in ConsoleObject.rs already carries a StackCheck
guard for the same recursion; this one never had it. Add the field
(initialized via StackCheck::init() so the bound is armed), check
is_safe_to_recurse() before descending into Array/Object/Map/Set, and
set failed=true when the limit is reached so the matcher still reports
a normal failure with a truncated diff instead of crashing the process.
@robobun

robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

Status: diff is green, review LGTM. CI red on unrelated lanes; needs a maintainer to merge.

Reproduced with stock bun (4 subprocess tests SIGSEGV with stderr cut off at the test-file header). With the fix applied, bun bd test test/js/bun/test/pretty-format-overflow.test.ts passes 5/5 and expect.test.js passes 415/415.

The is_safe_to_recurse() check sits above the can_have_circular_references() gate so every self-recursive print_as tag is covered, including Tag::JSX (deep React element chains). Covered shapes: deep arrays, deep objects, deep JSX chains (failing toEqual), and deep arrays (toMatchSnapshot).

CI: pretty-format-overflow.test.ts passed on every lane in builds 76687 and 76717. Remaining failures are unrelated flakes (install/webview/cron/http/spawn) plus a resolver SIGSEGV in filesystem_router.test.ts on alpine aarch64 (crash in memchr::rfind via resolver.rs:5454, reported to main-break triage); this diff does not touch the resolver or bundler.

Overlap with #34884: that PR applies the same pretty_format.rs change and additionally arms stack_check in console_object::Formatter::new() (for the uncaught-exception / throw deepValue printer path). This PR's pretty_format.rs diff is functionally identical; this PR carries broader test-runner coverage (object / JSX / snapshot subprocess cases) that #34884 does not. Whichever lands first, the other needs a trivial rebase of pretty_format.rs and pretty-format-overflow.test.ts.

@robobun

robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 1:35 AM PT - Jul 21st, 2026

@robobun, your commit faf9c95 has 2 failures in Build #76717 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34885

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

bun-34885 --bun

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Pretty-format recursion safety

Layer / File(s) Summary
Formatter recursion guard
src/runtime/test_runner/pretty_format.rs
Formatter initializes StackCheck and stops circular-reference formatting when native recursion is unsafe.
Deep recursion regression coverage
test/js/bun/test/pretty-format-overflow.test.ts
Subprocess tests exercise depth-20000 arrays, objects, and snapshots, verifying expected matcher output without runner crashes.

Possibly related PRs

  • oven-sh/bun#34179: Both changes add early exits in the test-runner pretty-formatting path.
🚥 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 fix: adding StackCheck to pretty_format to prevent crashes on deeply nested diffs and snapshots.
Description check ✅ Passed The description covers what changed and how it was verified, though it uses different headings than the repository template.

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

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/test/pretty-format-overflow.test.ts`:
- Around line 55-58: Condense the regression comment above the deeply nested
pretty-format test from four lines to three or fewer while preserving its
explanation of the SIGSEGV risk and why the test runs in a subprocess.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c3ee84e4-99e4-4809-82d1-5b7c21039515

📥 Commits

Reviewing files that changed from the base of the PR and between 2b90109 and c3c28ef.

📒 Files selected for processing (2)
  • src/runtime/test_runner/pretty_format.rs
  • test/js/bun/test/pretty-format-overflow.test.ts

Comment thread test/js/bun/test/pretty-format-overflow.test.ts Outdated

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/js/bun/test/pretty-format-overflow.test.ts (1)

79-82: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Strengthen the deep-format assertions.

1 fail/1 pass and the generic matcher header only prove that the child completed; a formatter that emitted an empty or incorrect representation could still satisfy these checks. Assert the generated snapshot contents and stable truncated outer representation in the array/object diffs.

As per coding guidelines, every assertion must be able to fail and assert the strongest invariant.

Also applies to: 103-106, 127-130

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/js/bun/test/pretty-format-overflow.test.ts` around lines 79 - 82,
Strengthen the stderr assertions in the deep-format test cases around the
existing process execution checks, including the corresponding blocks at the
other referenced locations. Assert the expected snapshot contents and stable
truncated outer array/object representations in each diff, rather than relying
only on the generic matcher header and pass/fail counts; preserve the existing
exit-code assertions.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@test/js/bun/test/pretty-format-overflow.test.ts`:
- Around line 79-82: Strengthen the stderr assertions in the deep-format test
cases around the existing process execution checks, including the corresponding
blocks at the other referenced locations. Assert the expected snapshot contents
and stable truncated outer array/object representations in each diff, rather
than relying only on the generic matcher header and pass/fail counts; preserve
the existing exit-code assertions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c8319f17-6a1d-4f22-8a98-3ba9e7f885a2

📥 Commits

Reviewing files that changed from the base of the PR and between c3c28ef and ee3a945.

📒 Files selected for processing (1)
  • test/js/bun/test/pretty-format-overflow.test.ts

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. bun crashed with error "panic(main thread): integer overflow oh no: Bun has crashed." when do bun test with @testing-library/react using toMatchSnapshot #19534 - Crash during toMatchSnapshot with React components; stack trace shows recursive printAsformatforEach cycle in pretty_format — the exact code path this PR guards with StackCheck
  2. ToMatchSnapshot broken with react components #5540 - toMatchSnapshot broken with React components producing deeply nested virtual DOM trees (20MB+ output) — same deeply-nested-value formatting problem this PR prevents from crashing

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #19534
Fixes #5540

🤖 Generated with Claude Code

@robobun

robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

Re: strengthening the deep-format assertions (CodeRabbit), pushed 38df8b3 adding expect(stderr).toContain("+ Received") to the two toEqual cases. That proves the diff legend printed, i.e. the formatter produced non-empty output for both sides. I'm intentionally not asserting the exact truncated representation: the depth at which StackCheck::is_safe_to_recurse() fires varies with native stack size (debug+ASAN here cuts off around 650 levels; release and other platforms will differ), so a content match would be platform-fragile. The snapshot case already asserts 1 pass, which is only reachable after the serializer writes a file.

Re: suggested linked issues, neither is this bug:

Leaving both out of the PR body.

Comment thread src/runtime/test_runner/pretty_format.rs Outdated
Comment thread src/runtime/test_runner/pretty_format.rs Outdated
Tag::JSX recurses print_as -> format -> print_as::<JSX> via props.children
but is not in can_have_circular_references(), so a ~20k-deep React element
chain still exhausted the native stack with the check gated there. Move the
is_safe_to_recurse() check to the top of print_as so every self-recursive
tag is covered, and add a subprocess test for the JSX shape.

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

Both prior findings addressed — the JSX-chain gap is fixed in bf068fb (check hoisted above can_have_circular_references(), with a fourth subprocess test), and the snapshot-truncation determinism concern has a reasoned deferral. Leaving the final call on that trade-off (silent non-deterministic truncation vs. a follow-up depth cap) to a maintainer.

What was reviewed:

  • Confirmed all pretty_format::Formatter construction routes through Formatter::new() (pretty_format.rs:169, 214), so stack_check is always armed; DiffFormatter sites reach it via JestPrettyFormat::format.
  • StackCheck::is_safe_to_recurse() is an inlined pointer comparison — safe to run on every print_as entry.
  • New tests follow harness conventions (tempDir, describe.concurrent, concurrent pipe drain, stderr-before-exitCode assertions) and cover Array/Object/JSX/snapshot shapes.
Extended reasoning...

Overview

The PR adds a StackCheck field to pretty_format::Formatter and guards print_as entry with is_safe_to_recurse(), preventing native stack exhaustion (SIGSEGV) when formatting ~20k-deep acyclic values in toEqual diffs and toMatchSnapshot serialization. Production change is ~15 lines in one file; tests add four subprocess cases in the existing pretty-format-overflow.test.ts.

Prior feedback

I left two inline findings on the earlier revision:

  • 🔴 The check was gated on can_have_circular_references(), so Tag::JSX chains still crashed. Fixed in bf068fb — the check is now above the gate and a deep-React-element subprocess test was added.
  • 🟡 On the snapshot path, fmt.failed is never read, so a truncated serialization is persisted with a trip-depth that varies by build/platform. The author gave a reasoned deferral: throwing or returning Err from inside print_as creates re-entrant exception state on the diff_format.rs path (which discards the result with let _ =), and a deterministic depth cap changes serialization for all deep values. Strictly better than the pre-PR SIGSEGV, and I marked it non-blocking originally.

Security risks

None. This is a defensive recursion bound in test-runner output formatting.

Level of scrutiny

Low-to-moderate. The mechanism mirrors the existing ConsoleObject.rs guard exactly, and StackCheck is an established in-tree helper. I verified every Formatter construction site goes through new() and that is_safe_to_recurse() is a cheap inlined check suitable for per-entry use.

Why defer rather than approve

The one open item is a product/design call the author explicitly left for a maintainer: whether persisting a non-deterministic truncated snapshot (at extreme depth) is acceptable interim behavior, or whether the depth-cap follow-up should land alongside this. The author's reasoning against the in-PR alternatives is sound, but "happy to follow up separately if wanted" is a question for a human to answer, not me.

@robobun

robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

Overlaps with #34884: both add the same StackCheck field + is_safe_to_recurse() guard to pretty_format::Formatter::print_as. #34884 also seats StackCheck::init() in console_object::Formatter::new() (the uncaught-exception printer path, throw 50k-deep-array → SIGSEGV). This PR has the more thorough pretty_format test matrix. Whichever lands second needs a trivial rebase.

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

LGTM — the JSX-chain gap from the earlier review is closed (check now sits above the can_have_circular_references() gate, with a subprocess test for the 20k-deep React element shape), and the snapshot-truncation trade-off is a reasoned "strict improvement over SIGSEGV" call I already marked non-blocking. Note the overlap with #34884; whichever lands second needs the trivial rebase.

Extended reasoning...

Overview

Adds a StackCheck field to pretty_format::Formatter, initialized in Formatter::new(), and checks is_safe_to_recurse() at the top of print_as — before the can_have_circular_references() gate — so every self-recursive tag (Array/Object/Map/Set/JSX) bails out by setting self.failed = true instead of exhausting the native stack. Mirrors the identical guard already present in ConsoleObject.rs. Four new subprocess tests cover deep arrays, deep objects, deep JSX chains (toEqual diff path), and deep arrays via toMatchSnapshot.

Follow-up from the earlier review

The earlier 🔴 (stack check gated behind can_have_circular_references(), so Tag::JSX bypassed it) was fixed in bf068fb by hoisting the check above the gate; a fourth subprocess test for the 20k-deep React element chain was added and confirmed to fail on stock 1.3.14. Verified at pretty_format.rs:1161 that the check now runs unconditionally on print_as entry. The earlier 🟡 (snapshot path never reads fmt.failed, so a tripped guard persists a platform-dependent truncated serialization) was acknowledged with a reasoned trade-off — throwing or returning Err would leave a pending exception on the DiffFormatter Display path, and a fixed depth cap changes serialization for all deep values. I already flagged it non-blocking; not re-litigating.

Security risks

None. This is a defensive recursion guard in the test runner's diff/snapshot formatter; no untrusted-input parsing, auth, crypto, or network paths are touched.

Level of scrutiny

Low-to-medium. ~11 lines of Rust adding a field + a top-of-function guard that copies an established in-tree pattern. Formatter has no other struct-literal constructors (a missing field would fail to compile), so new() covers every entry. Tests follow harness conventions: describe.concurrent, tempDir, both pipes drained via Promise.all, stderr asserted before exitCode, bunEnv spread where modified.

Other factors

Fail-before/pass-after was demonstrated on both debug+ASAN and release. expect.test.js (415 tests) passes unchanged. The overlap with #34884 (same StackCheck field, plus a console_object::Formatter::new() seat) is already noted in the thread and is a coordination concern rather than a correctness one.

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