Skip to content

console: guard JSX/Proxy recursion in Bun.inspect (stack overflow SIGSEGV) - #29709

Open
robobun wants to merge 3 commits into
mainfrom
farm/f27ac5e7/fix-jsx-inspect-circular
Open

console: guard JSX/Proxy recursion in Bun.inspect (stack overflow SIGSEGV)#29709
robobun wants to merge 3 commits into
mainfrom
farm/f27ac5e7/fix-jsx-inspect-circular

Conversation

@robobun

@robobun robobun commented Apr 25, 2026

Copy link
Copy Markdown
Collaborator

console.log / Bun.inspect segfaults (native stack overflow, exit 139, no crash report) on:

const e = { $$typeof: Symbol.for("react.element"), type: "div", key: null, ref: null, props: {} };
e.props.children = e;
console.log(e);            // SIGSEGV
let p = {};
for (let i = 0; i < 1e5; i++) p = new Proxy(p, {});
console.log(p);            // SIGSEGV

A ~20k-deep non-cyclic JSX children chain crashes the same way. Node prints all three.

Cause

print_as_prelude only ran stack_check.is_safe_to_recurse() and the visited-map insert for tags in can_have_circular_references(). Tag::JSX was not in that set, so the JSX printer recursed into props / children with neither guard. Tag::Proxy was also not in the set, and print_proxy recursed through format() on the target, so a deep Proxy chain blew the native stack before ever reaching a guarded tag. There is no JS frame on this path, so JSC's recursion limiter never fires.

Fix

  • Add Tag::JSX to can_have_circular_references() (in both ConsoleObject.rs and the test-runner pretty_format.rs) so cyclic JSX prints [Circular].
  • Move the stack_check.is_safe_to_recurse() check ahead of the can_circ early return in print_as_prelude, so every recursive print_as call is guarded regardless of tag. Deep non-cyclic JSX now throws a catchable RangeError instead of segfaulting.
  • Make print_proxy peel nested Proxy targets in a loop instead of recursing, so a 100k-deep Proxy chain prints the innermost target without consuming stack.

Verification

$ bun-debug -e 'const e={$$typeof:Symbol.for("react.element"),type:"div",key:null,ref:null,props:{}};e.props.children=e;console.log(e)'
<div>
  [Circular]
</div>

$ bun-debug -e 'let p={ok:1};for(let i=0;i<1e5;i++)p=new Proxy(p,{});console.log(p)'
{
  ok: 1,
}

New tests in test/js/bun/util/inspect.test.js run the crashing inputs in subprocesses so a regression fails the suite rather than killing the runner.

Related: #10886. That issue's scenario (a happy-dom element printed by a jest-dom matcher) is a different bug: the element is a plain object, so it already goes through the circular set and depth limit and prints as a bounded but very long object dump. It is not fixed here; #35712 (DOM nodes printed as markup) is the fix for it. This PR is the SIGSEGV class only, so it does not close #10886.

Overlaps with #34889, which carries the Tag::JSX circular entry and the stack-check reordering from here plus a live StackCheck in Formatter::new() and in pretty_format; the Proxy peeling loop, Tag::Event in pretty_format's circular set, and the Event re-dispatch map.remove fix (with their tests) exist only in this PR. One of the two should absorb the other's remaining pieces.

Supersedes #29170


no test proof · iteration 19 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/util/inspect.test.js

@robobun

robobun commented Apr 25, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 9:50 AM PT - Aug 13th, 2026

@robobun, your commit 02da7db has 1 failures in Build #94501 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29709

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

bun-29709 --bun

@coderabbitai

coderabbitai Bot commented Apr 25, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 2 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d5c84d54-41e4-49d5-9741-4ab38141ad06

📥 Commits

Reviewing files that changed from the base of the PR and between b7a0431 and 02da7db.

📒 Files selected for processing (3)
  • src/jsc/ConsoleObject.rs
  • src/runtime/test_runner/pretty_format.rs
  • test/js/bun/util/inspect.test.js

Walkthrough

Adds JSX to circular-reference detection and makes JSX props inspection safe when props is not an object, avoiding invalid access and skipping prop printing in those cases.

Changes

Cohort / File(s) Summary
Formatter: circular detection & JSX props
src/bun.js/test/pretty_format.zig, src/bun.js/ConsoleObject.zig
Treat JSX values as potentially circular by adding .JSX to canHaveCircularReferences. Change JSX element rendering to obtain props safely (use orelse break :props) and skip printing when props is not an object instead of asserting.
Tests: JSX inspect cases
test/js/bun/util/inspect.test.js
Add tests for Bun.inspect with JSX elements that have circular props, children, or key, verify [Circular] output, and add a test ensuring non-object props are ignored (renders <div />).
🚥 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 identifies the Bun.inspect recursion fix and the stack overflow failure mode.
Description check ✅ Passed The description explains the problem, cause, proposed fixes, behavior changes, verification steps, and testing limitations.

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

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. bun test with react DOM shows infinite error log on expect failed #10886 - React DOM elements contain circular references (parent/child relationships); the missing .JSX tag in canHaveCircularReferences() caused infinite recursion when inspecting them for test failure messages, producing the "infinite error log" described in this issue.

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

Fixes #10886

🤖 Generated with Claude Code

Comment thread src/jsc/ConsoleObject.zig Outdated
@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Fix stack overflow when inspecting JSX elements with circular references #29170 - Fixes the same stack overflow in Bun.inspect() on circular JSX elements by adding .JSX to canHaveCircularReferences() and guarding non-object props; also applies the fix to pretty_format.zig

🤖 Generated with Claude Code

@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 the current code and only fix it if needed.

Inline comments:
In `@test/js/bun/util/inspect.test.js`:
- Around line 351-353: The test creates el2 with props: 42 and currently only
asserts that an error is thrown when comparing it to {}
(expect(el2).toEqual({})), which allows any thrown error (including ones whose
message incorrectly contains "[Circular]"). Update the assertion for the thrown
error to also assert the error message does NOT contain the string "[Circular]"
so the test fails if a circular-related message is produced; target the existing
el2 setup and the current expect(...).toThrow invocation when making this
change.
🪄 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: 34ba822f-7961-4803-84d6-bf95dc3fc265

📥 Commits

Reviewing files that changed from the base of the PR and between f04366c and f26c1c3.

📒 Files selected for processing (2)
  • src/bun.js/test/pretty_format.zig
  • test/js/bun/util/inspect.test.js

Comment thread test/js/bun/util/inspect.test.js Outdated
@robobun

robobun commented Apr 25, 2026

Copy link
Copy Markdown
Collaborator Author

The darwin-x64-build-cpp failure is GitHub returning HTTP 502 when fetching vendor tarballs (tinycc, zlib) — unrelated infra flake. This PR only touches Zig + test JS.

Supersedes #29170 (same fix, this one has more test coverage).

@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 pretty_format.zig mirror fix and the diff-formatter regression test address my earlier comment.

Extended reasoning...

Overview

This PR closes a fuzzer-found native stack overflow in the JSX inspection path. It touches two near-duplicate formatters — src/bun.js/ConsoleObject.zig (used by Bun.inspect/console.log) and src/bun.js/test/pretty_format.zig (used by the test diff renderer) — plus test/js/bun/util/inspect.test.js. In each formatter the change is two lines: add .JSX to canHaveCircularReferences() so the existing visited-map guard fires, and replace props.getObject().? with orelse break :props so a non-object props is treated as no-props instead of panicking. Five new tests cover circular props/children/key, non-object props, and the expect().toEqual() diff-formatter path.

Security risks

None. This is purely output-formatting / crash-avoidance code; it does not touch auth, filesystem, network, or parsing. Both edits are strictly defensive — they only add a cycle guard for one more tag and remove a force-unwrap.

Level of scrutiny

Low-to-medium. The change follows the exact pattern already used for .Array/.Object/.Map/.Set in the same function, so there's no novel logic. The labeled break :props exits after the defer this.quote_strings = prev_quote_strings is registered, so state is restored correctly. The only behavioural surface beyond crash-fix is that JSX values now occupy a slot in the visited map during recursion, which is the intended effect.

Other factors

My previous review flagged that pretty_format.zig was unpatched; the author applied the same fix in f26c1c3 and added a test that drives JestPrettyFormat via expect(el).toEqual({}). No CODEOWNERS cover these files. A bot flagged a possible duplicate (#29170), which maintainers may want to close, but that doesn't affect this PR's correctness.

@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 mirrored fix in pretty_format.zig addresses my earlier comment, and both code paths now have test coverage.

Extended reasoning...

Overview

This PR fixes a fuzzer-found native stack overflow when Bun.inspect() (and the test-runner diff formatter) encounters a self-referential JSX element. The change touches two near-identical formatters — src/jsc/ConsoleObject.zig and src/test_runner/pretty_format.zig — adding .JSX to canHaveCircularReferences() so the existing visited-map / [Circular] guard applies, and replacing a props.getObject().? force-unwrap with orelse break :props so a non-object props is skipped instead of asserting. Five new tests in inspect.test.js cover circular props/children/key, non-object props, and the expect().toEqual() diff path.

Security risks

None. This is purely output-formatting / crash-hardening code with no auth, crypto, filesystem, or network surface. The change strictly narrows behavior from a segfault to printing [Circular] or skipping invalid props.

Level of scrutiny

Low-to-medium. The fix is two mechanical edits per file: one enum case added to an existing switch, and one optional-unwrap converted to a labeled-block bailout. The defer this.quote_strings = prev_quote_strings placed before the break :props still fires on early exit, so state is correctly restored. The same pattern (visited-map guarded by canHaveCircularReferences()) already protects .Array/.Object/.Map/.Set, so adding .JSX follows the established design exactly.

Other factors

I previously flagged that the original revision only patched ConsoleObject.zig; the author applied the identical fix to pretty_format.zig in f26c1c3 and added a regression test exercising the diff-formatter path (expect(circularJsx).toEqual({})). That comment is resolved. CodeRabbit's remaining suggestion to tighten the props: 42 diff-formatter assertion is a minor test-quality nit — the test as written already regresses the crash (prior behavior was a segfault, not a thrown error). The CI failures listed by robobun are unrelated flakes (vendor-tarball 502 on darwin-x64, plus known-flaky tests in heap-snapshot/bake/webview/v8-date-parser that this PR does not touch). The bug-hunting system found no issues on the current revision.

@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 mirrored fix in pretty_format.zig addresses my earlier comment; the remaining .Event note is pre-existing and non-blocking.

Extended reasoning...

Overview

This PR fixes a fuzzer-found native stack overflow in Bun.inspect() for self-referential JSX elements. It makes two small changes, mirrored across src/jsc/ConsoleObject.zig and src/test_runner/pretty_format.zig: (1) add .JSX to canHaveCircularReferences() so the visited-map / [Circular] guard applies, and (2) replace the props.getObject().? force-unwrap with orelse break :props so a non-object props no longer panics. Five new tests in test/js/bun/util/inspect.test.js cover circular props, children, key, non-object props, and the test-diff-formatter path.

Security risks

None. This is purely a crash-hardening change in the value-formatter; it does not touch auth, network, filesystem, or parsing of untrusted input beyond making an existing code path more defensive.

Level of scrutiny

Low. Each Zig change is a one-line edit on a well-understood guard, applied identically in two near-duplicate formatters. The labelled-block break :props is the idiomatic Zig pattern already used elsewhere in these files. The change can only reduce crashes — it cannot regress correct output for well-formed JSX, since real React elements always have an object props and the visited-map check is a no-op on the first visit.

Other factors

My earlier review asked for the fix to be mirrored into pretty_format.zig; the author did so and added a regression test that exercises the diff-formatter path (expect(el).toEqual({})). The one new inline finding (.Event still omitted from pretty_format.zig's circular-reference list) is explicitly flagged as pre-existing, requires Object.defineProperty to trigger, and is a one-token follow-up the author can take or leave — it does not block this fuzzer fix. CodeRabbit's test-tightening suggestion is a minor nit. CI failure was an unrelated GitHub 502 on vendor tarball fetch.

Comment thread src/test_runner/pretty_format.zig 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.

LGTM — targeted crash fix with good test coverage; the .Event note is pre-existing and can be a follow-up.

Extended reasoning...

Overview

This PR fixes a fuzzer-found native stack overflow in Bun.inspect() when formatting self-referential JSX elements. The change is ~6 lines of logic across two near-duplicate formatters (src/jsc/ConsoleObject.zig and src/test_runner/pretty_format.zig): adding .JSX to canHaveCircularReferences() so the existing visited-map guard fires, and replacing a props.getObject().? force-unwrap with orelse break :props so non-object props is skipped instead of asserting. Five new test cases in inspect.test.js cover circular props/children/key, non-object props, and the test-diff-formatter path.

Security risks

None. This is output-formatting code with no auth, crypto, filesystem, or network surface. The change strictly narrows behavior (turning two crash paths into bounded [Circular] / no-op output) and introduces no new inputs or side effects.

Level of scrutiny

Low-to-moderate. The fix is mechanical — it extends an existing enum allowlist by one variant and replaces a panic-on-null with an early break, both well-established patterns already used for other tags in the same functions. The labelled-block break :props correctly preserves the defer this.quote_strings = prev_quote_strings restore. Both copies of the formatter received identical edits.

Other factors

My earlier review flagged that pretty_format.zig needed the same fix; the author applied it and the thread is resolved. The remaining open comments are (1) CodeRabbit's minor test-tightening suggestion and (2) my own note that .Event is still missing from pretty_format.zig's list — both are non-blocking nits about pre-existing behavior, not regressions introduced here. Test coverage directly exercises the previously-crashing inputs in both formatter code paths.

Comment thread test/js/bun/util/inspect.test.js 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.

LGTM — thanks for addressing the test nits; the remaining .Event note is pre-existing and can be a follow-up.

Extended reasoning...

Overview

This PR fixes a fuzzer-found native stack overflow in Bun.inspect() / the test diff formatter when given self-referential JSX-shaped objects. The change is two-fold across both src/jsc/ConsoleObject.zig and src/test_runner/pretty_format.zig: (1) add .JSX to canHaveCircularReferences() so the existing visited-map guard applies, and (2) replace the props.getObject().? force-unwrap with orelse break :props so non-object props is treated as no-props instead of asserting. Three subprocess regression tests are added to test/js/bun/util/inspect.test.js.

Security risks

None. This is a defensive fix in output-formatting code — it strictly reduces crash surface by adding cycle detection and removing an unsafe unwrap. No new inputs, no auth/crypto/permission logic.

Level of scrutiny

Low. The Zig changes are mechanical: one enum tag appended to an existing comptime allowlist (identical pattern already used for .Array/.Object/etc.), and one .? swapped for orelse break :props on a labeled block. Both edits are mirrored verbatim across the two duplicate formatters. The visited-map machinery being enabled is pre-existing and well-exercised by other tags.

Other factors

  • My earlier feedback (mirror the fix into pretty_format.zig; test-convention nits around assertion order, tempDir, and the negative panic check) has all been addressed in commits 39e75db and 8f62f57.
  • The one open note I left — .Event still missing from pretty_format.zig's canHaveCircularReferences() — is a pre-existing gap with a contrived trigger and is reasonable to defer to a follow-up; it does not block this fix.
  • The reported CI failures on 56394e7 are build-infra (scripts/build/ci.ts, vendor tarball fetches) unrelated to the Zig/test changes here, consistent with the two "Retrigger CI" commits.
  • Test coverage is solid: circular via props, children (single + array), and key; non-object props; and the expect().toEqual() diff path — all run in subprocesses so a regression segfault fails the test rather than killing the runner.

@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 — thanks for addressing the test-convention nits. The remaining .Event note on pretty_format.zig:329 is pre-existing and fine as a follow-up.

Extended reasoning...

Overview

Adds .JSX to canHaveCircularReferences() in both src/jsc/ConsoleObject.zig and src/test_runner/pretty_format.zig, and replaces the props.getObject().? force-unwrap with orelse break :props in both files. Three subprocess regression tests are added to test/js/bun/util/inspect.test.js covering circular props/children/key, non-object props, and the test-diff-formatter path.

Security risks

None. This is a defensive change in display/formatting code — it strictly removes a native stack overflow and an assertion failure on malformed JSX-shaped objects. No new inputs are trusted, no parsing/auth/crypto is touched.

Level of scrutiny

Low-to-medium. The fix is a one-token enum addition plus replacing a panic-on-null with a graceful early-out, mirrored across two near-identical formatters. The pattern matches how the other recursive tags (.Array, .Object, .Map, .Set) already participate in the visited-map guard, and ConsoleObject.zig already listed several other tags here. The labelled-block break :props correctly preserves the defer this.quote_strings = prev_quote_strings cleanup.

Other factors

  • All earlier review feedback has been addressed: the duplicate formatter in pretty_format.zig was patched (f26c1c3), and the test-convention nits (assertion order, tempDir, dropping the negative panic check) were fixed in 8f62f57.
  • The one open inline comment (.Event missing from pretty_format.zig:329) is my own and explicitly marked as a pre-existing issue of the same class — it does not block this fuzzer-found crash fix.
  • Tests run the crashing inputs in subprocesses so a regression would fail the suite rather than segfault the runner.
  • Reported CI failures on earlier commits were infra-level build-zig/build-cpp flakes unrelated to these Zig/JS-only changes; the author has retriggered.

@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 — minimal, well-tested crash fix; all prior review feedback addressed. The remaining .Event note is pre-existing and fine to defer.

Extended reasoning...

Overview

This PR fixes a fuzzer-found native stack overflow in Bun.inspect() (and the Jest diff formatter) when a JSX-shaped object references itself. The Zig changes are tiny and surgical: (1) add .JSX to canHaveCircularReferences() in both src/jsc/ConsoleObject.zig and src/test_runner/pretty_format.zig so the existing visited-map / [Circular] guard fires, and (2) replace props.getObject().? with orelse break :props so a non-object props no longer panics. Three new subprocess-based tests in test/js/bun/util/inspect.test.js cover circular props/children/key, non-object props, and the test-runner diff path.

Security risks

None. This is read-only formatting/inspection code; the change strictly narrows crash surface by adding cycle detection and removing a force-unwrap. No auth, crypto, filesystem, or network paths touched.

Level of scrutiny

Low–medium. The logic change is ~4 lines per file and follows the exact pattern already used for .Array/.Object/etc. The orelse break :props is strictly safer than the prior .? unwrap. Tests run in subprocesses so a regression segfault would still produce a clean failure rather than killing the suite.

Other factors

All earlier review feedback has been addressed: the fix was mirrored into pretty_format.zig (f26c1c3), and the test-convention nits — assertion order, tempDir over tmpdirSync, dropping the negative panic check — were applied in 8f62f57. My outstanding 🟣 note about .Event in pretty_format.zig is a pre-existing, contrived-trigger issue I explicitly flagged as optional; not a blocker for this PR. The single CI failure (test-http-should-emit-close-when-connection-is-aborted.ts timeout on Windows) is unrelated to these changes.

@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 — thanks for addressing the test-convention nits. The pre-existing .Event gap in pretty_format.rs can be a follow-up.

Extended reasoning...

Overview

Two one-line additions: Tag::JSX is added to the can_have_circular_references() allowlist in both src/jsc/ConsoleObject.rs (Bun.inspect / console.log) and src/runtime/test_runner/pretty_format.rs (expect().toEqual() diff renderer). This enables the existing visited-map / [Circular] guard for JSX elements, fixing a fuzzer-found native stack overflow when a React element references itself. ~80 lines of subprocess-based regression tests cover circular props, children, key, and non-object props in both formatters.

Security risks

None. This is a defensive hardening change in output-formatting code — it gates more inputs through an existing cycle guard rather than adding new logic. No auth, crypto, parsing, or untrusted-input boundaries are touched beyond making inspection of pathological objects safer (turning a segfault into [Circular] text).

Level of scrutiny

Low-to-moderate. The Rust changes are mechanically trivial — adding one enum variant to two matches!() arms to opt into pre-existing safety machinery. There is no new control flow; the visited-map logic itself is unchanged. The test additions follow repo conventions (subprocess isolation so a regression fails the suite rather than segfaulting the runner, tempDir with using, stdout asserted before exitCode).

Other factors

All prior review feedback has been addressed: the fix was mirrored into pretty_format.rs after my first-round comment, and the latest revision applies all three test-convention nits from my second-round comment (assertion order, removed not.toContain("panic"), tempDir instead of tmpdirSync). The one remaining note — .Event still missing from pretty_format.rs's allowlist — is a pre-existing issue with a contrived trigger that I explicitly marked as optional; it should not block this fuzzer-fix PR. CodeRabbit's test-tightening suggestion is a minor nice-to-have that does not affect correctness. No bugs were found by the bug-hunting system on the current revision.

@robobun
robobun force-pushed the farm/f27ac5e7/fix-jsx-inspect-circular branch from 8150329 to 68babdc Compare July 1, 2026 22:31

@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 test-convention nits are addressed and the two-line native fix is straightforward; the pre-existing .Event gap I flagged earlier can land separately.

Extended reasoning...

Overview

This PR adds Tag::JSX to can_have_circular_references() in two places — src/jsc/ConsoleObject.rs (the Bun.inspect/console.log formatter) and src/runtime/test_runner/pretty_format.rs (the expect().toEqual() diff formatter) — so that the visited-map cycle guard applies when recursing into JSX props/key/children. Without it, a self-referential React element overflowed the native stack and segfaulted. Three subprocess-isolated regression tests are added to test/js/bun/util/inspect.test.js covering circular props, circular children (single and array), circular key, non-object props, and the test-runner diff path. I verified that pretty_format.rs:2191 already handles non-object props via let Some(props_obj) = props.get_object() else { return Ok(false); }, so the second half of my original 2026-04-25 feedback is covered even though it doesn't appear in this diff.

Security risks

None. This is output-formatting code with no auth, network, filesystem, or crypto surface. The change strictly narrows behavior (adds a cycle guard); it cannot expose data or bypass anything.

Level of scrutiny

Low. The native change is a one-token addition to a matches!() arm in each of two files, following the exact pattern already used for Array/Object/Map/Set (and Function/Error/Class/Event in ConsoleObject). The mechanism — insert into the visited map before recursing, print [Circular] on hit — is unchanged; only the set of tags it applies to grows by one.

Other factors

All feedback from my earlier reviews has been addressed: the pretty_format mirror fix is in, assertion order now puts stdout before exitCode, the not.toContain("panic") line is gone, and the diff-formatter test uses using dir = tempDir(...). The one open item is my 🟣 note that Tag::Event is still missing from pretty_format.rs's list — I confirmed at line 509 it's still Array | Object | Map | Set | JSX — but I explicitly flagged that as pre-existing and non-blocking, and it's a contrived trigger (Object.defineProperty on a DOM event) unrelated to the fuzzer finding this PR fixes. CodeRabbit's unresolved nit about tightening the toThrow() assertion is covered in practice by the outer "2 pass" + exitCode === 0 checks. No new bugs found in this run.

@robobun robobun changed the title Fix stack overflow in Bun.inspect() on circular JSX elements console: guard JSX/Proxy recursion in Bun.inspect (stack overflow SIGSEGV) Jul 21, 2026
Comment thread test/js/bun/util/inspect.test.js Outdated
Comment thread test/js/bun/util/inspect.test.js Outdated
@robobun

robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #34889, which includes this change and also moves the stack check ahead of the can_have_circular_references gate so deep-but-acyclic JSX and Proxy chains are bounded too.

Comment thread src/runtime/test_runner/pretty_format.rs
Comment on lines +506 to +510
pub const fn can_have_circular_references(self) -> bool {
matches!(self, Tag::Array | Tag::Object | Tag::Map | Tag::Set)
matches!(
self,
Tag::Array | Tag::Object | Tag::Map | Tag::Set | Tag::JSX | Tag::Event
)

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.

🟣 The stack_check.is_safe_to_recurse() reorder from ConsoleObject.rs has no analogue here — pretty_format.rs has no stack_check at all, so a deep non-cyclic chain (JSX or plain Object/Array) routed through expect(deep).toEqual({}) / snapshot diffs still overflows the native stack and segfaults bun test. This is pre-existing (the file has never had a native-stack guard for any tag) and adding one means introducing a StackCheck field the file does not have today, so likely follow-up scope — just noting that the description's "Deep non-cyclic JSX now throws a catchable RangeError" only holds for the Bun.inspect / console.log path, not the diff formatter.

Extended reasoning...

What the gap is

This PR applies two independent recursion guards in src/jsc/ConsoleObject.rs:

  1. Adding Tag::JSX to can_have_circular_references() so the visited-map / [Circular] guard fires on cyclic JSX.
  2. Moving self.stack_check.is_safe_to_recurse() ahead of the if !can_circ { return Ok(true) } early return in print_as_prelude, so every recursive print_as call — regardless of tag — checks the native stack and throws a RangeError before it overflows.

Fix (1) is mirrored into src/runtime/test_runner/pretty_format.rs (this hunk). Fix (2) is not — and cannot be trivially mirrored — because pretty_format.rs has no stack_check field, no is_safe_to_recurse() call, and no StackCheck import anywhere in the file (grep confirms zero hits). Its print_as prelude's only recursion defence is the visited map, gated on FORMAT.can_have_circular_references().

Why the visited map is not sufficient

The visited map catches cycles (the same JSValue appearing twice on the current recursion stack). It does nothing for a deep chain of distinct objects, because every insert is a fresh key that never hits. A ~20k-deep non-cyclic JSX children chain — one of the three crashing repros the PR description enumerates — is exactly this shape: 20k distinct react-element objects, each the sole child of the previous one. The Tag::JSX arm of JestPrettyFormat::print_as recursively calls self.format(...) on props.children, so this chain drives ~20k native stack frames with nothing to stop it.

Step-by-step proof

  1. Build a deep non-cyclic JSX tree in a test file:
    let el = { $$typeof: Symbol.for("react.element"), type: "div", props: {}, key: null };
    for (let i = 0; i < 20000; i++)
      el = { $$typeof: Symbol.for("react.element"), type: "div", props: { children: el }, key: null };
    expect(el).toEqual({});
  2. toEqual fails and diff_format.rs calls JestPrettyFormat::format(received, …) to render el.
  3. Tag::get(el)Tag::JSX; print_as is entered.
  4. FORMAT.can_have_circular_references() is now true (this PR added Tag::JSX), so the visited map is consulted — but el has never been seen (each of the 20k elements is a distinct object), so it inserts and continues.
  5. The Tag::JSX arm reads props.children, finds the next distinct element, and calls self.format(...) on it → back to step 3 with a fresh value.
  6. There is no stack_check.is_safe_to_recurse() anywhere on this path, and no JS frame for JSC's recursion limiter to see. After ~20k frames the native stack is exhausted → SIGSEGV in bun test.

The same holds for a 20k-deep {a:{a:{a:...}}} plain-object chain — Tag::Object was already in can_have_circular_references() before this PR, and the visited map likewise never hits on distinct objects.

Why this is pre-existing, not a defect this PR introduced

pretty_format.rs has never had a native-stack recursion guard, for any tag. Deep non-cyclic Object / Array / Map / Set chains through the diff formatter could segfault bun test before this PR and still can after it; Tag::JSX is just one more tag with the same long-standing exposure. The ConsoleObject.rs fix was a reorder of an existing stack_check field; mirroring it here means adding a StackCheck field to JestPrettyFormat, initializing it, and gating print_as on it — net-new infrastructure rather than the one-token additions this PR made to the file. The PR is a strict improvement (cyclic JSX through the diff formatter no longer crashes) and does not make the non-cyclic case worse.

Impact

A user who passes a very deep non-cyclic structure to a failing expect().toEqual() / toMatchSnapshot() will still see bun test die with SIGSEGV rather than a catchable RangeError. The trigger — a ~20k-deep tree of distinct objects handed to a failing matcher — is contrived compared to the cyclic-JSX case a real React DOM produces (which this PR does fix). The PR description's statement "Deep non-cyclic JSX now throws a catchable RangeError instead of segfaulting" is accurate for Bun.inspect / console.log (all three repros in the description are bun -e '…console.log(e)'), but does not hold for the JestPrettyFormat path.

Suggested follow-up

Add a stack_check: bun_core::StackCheck field to JestPrettyFormat and, at the top of print_as (before the visited-map block), do the same if !self.stack_check.is_safe_to_recurse() { …throw RangeError… } gate ConsoleObject.rs:3391 now runs unconditionally. That closes the deep-non-cyclic case for every tag in the diff formatter in one place. Reasonable to land as a follow-up PR given it is net-new infrastructure in a file this PR only touched for the visited-map fix.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed this is follow-up scope: pretty_format.rs has no StackCheck field today, so adding one is net-new infrastructure rather than the one-line enum additions this PR makes. The Bun.inspect/console.log path (which all three PR-description repros use) is covered. Leaving the diff-formatter stack guard for a separate change.

@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 — all prior feedback addressed.

What was reviewed:

  • print_as_prelude reorder: stack check now runs for every tag; Ok(false) path sets self.failed and callers bail cleanly.
  • print_proxy loop: Proxy chains cannot be cyclic (target is fixed at construction), so the unbounded loop terminates; Tag::get errors propagate via ?.
  • pretty_format.rs Event fallback: verified the map.remove(&value) before Object re-dispatch is correct and the double-remove at line 2603 afterward is a harmless no-op; MessageEvent/ErrorEvent arms recurse on .data/.error (different value) so no remove needed there.
  • Checked neither JSX printer re-dispatches on the same value, so adding Tag::JSX to can_have_circular_references cannot produce spurious [Circular].
Extended reasoning...

Overview

Three files: src/jsc/ConsoleObject.rs (add Tag::JSX to can_have_circular_references, move stack_check.is_safe_to_recurse() ahead of the !can_circ early return, convert print_proxy recursion to a peel loop), src/runtime/test_runner/pretty_format.rs (add Tag::JSX and Tag::Event to can_have_circular_references, add map.remove(&value) before the Event→Object re-dispatch), and six new subprocess tests in test/js/bun/util/inspect.test.js. Net +186/-13.

Security risks

None. This is output-formatting code with no auth/crypto/permission surface. The change strictly hardens against native stack overflow on adversarial input to Bun.inspect / console.log — replacing SIGSEGV with either [Circular], a catchable RangeError, or successful output.

Level of scrutiny

Moderate. Crash fix in a hot-ish diagnostic path, but the mechanism is straightforward: (1) one enum arm added to a const matches!(), (2) two adjacent guard blocks swapped so the cheaper stack check runs unconditionally, (3) tail recursion turned into a loop. The one non-obvious hazard — spurious [Circular] when a newly-guarded tag re-dispatches on the same value — was caught in the previous review round for Tag::Event and is now fixed with a regression test; I re-checked both JSX printers and neither re-dispatches on value itself.

Other factors

This is my fourth pass. All prior findings (test assertion order, tempDir vs tmpdirSync, empty-stderr assertion, missing deep-non-cyclic-JSX test, missing Tag::Event, spurious [Circular] on CloseEvent/CustomEvent) have been addressed in the current diff. The gate evidence in the PR body shows fails-on-main / passes-on-PR under both ASAN debug and release. The one remaining note (no stack_check in pretty_format.rs) is pre-existing across all tags, not a regression, and the author has correctly scoped it to a follow-up. The bug-hunting system found nothing new this run.

Comment on lines +1876 to +1877
// Re-dispatch as Object on the same value: remove it from the
// visited map first so the inner call doesn't see a spurious cycle.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed in 68e4811.

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

The Event re-dispatch fix from my last review (ed29686) looks correct — map.remove(&value) is in place before the print_as::<Object> fallback, and the new "Event in test diff formatter" test covers CloseEvent/CustomEvent (properties render) plus circular MessageEvent (still [Circular]). All prior review feedback is addressed. Deferring the merge call to a human given the stated overlap with #34889 ("one of the two should absorb the other's remaining pieces").

Reviewed: the Tag::JSX addition doesn't introduce a spurious-[Circular] re-dispatch in print_jsx (it only recurses into child values, never value itself); the print_proxy loop can't cycle since Proxy targets are fixed at construction; and the stack-check reorder now guards every tag on entry.

Extended reasoning...

Overview

This PR fixes three native-stack-overflow SIGSEGVs in Bun.inspect / console.log: cyclic JSX props.children, deep non-cyclic JSX chains, and deep Proxy chains. Changes span src/jsc/ConsoleObject.rs (add Tag::JSX to can_have_circular_references(), move stack_check.is_safe_to_recurse() ahead of the !can_circ early return, convert print_proxy from recursion to a peel loop), src/runtime/test_runner/pretty_format.rs (add Tag::JSX | Tag::Event to the circular set, plus map.remove(&value) before the Event→Object re-dispatch), and six new subprocess tests in test/js/bun/util/inspect.test.js.

Prior review resolution

I've reviewed this PR three times previously. All substantive concerns are now addressed: test-convention nits (assertion order, tempDir, no-panic checks) were fixed; the missing deep-non-cyclic-JSX test and the stderr === "" flake vector were fixed in e0d1df6; and my 🔴 about Tag::Event causing spurious [Circular] on CloseEvent/CustomEvent in the diff formatter was fixed in ed29686 with a regression test. I verified print_jsx in ConsoleObject.rs never re-dispatches print_as on the same value (only on key_value/property_value/children), so adding Tag::JSX to the circular set there does not create the analogous hazard. The print_proxy loop terminates because Proxy [[ProxyTarget]] is set once at construction and only ever transitions to null on revoke — no cycle is constructible.

Security risks

None. This is output formatting; no parsing of untrusted data into privileged operations, no auth/crypto/permission surfaces.

Level of scrutiny

Medium-high. ConsoleObject.rs is the formatter behind every console.log and Bun.inspect call, so a regression is highly visible. That said, the diff is small and mechanical (one enum arm, a two-block reorder, recursion→loop), the mechanism is clearly explained, and the evidence block shows both ASAN-debug and release lanes passing with the new tests failing on main.

Other factors

Two open items keep this from an auto-approve: (1) the PR description explicitly flags overlap with #34889 and says one PR should absorb the other — that merge-ordering decision needs a maintainer; (2) a comment-cop bot flag landed today on the two-line comment at pretty_format.rs:1876-1877. The comment is short and explains a non-obvious map.remove (mirroring the documented remove_before_recurse pattern in ConsoleObject.rs), so I don't think it warrants a code change, but it's a fresh unresolved item. My earlier 🟣 note about pretty_format.rs lacking a native StackCheck remains acknowledged as follow-up scope and is not blocking.

…SEGV)

Tag::JSX was missing from can_have_circular_references() in both the
console formatter and the test diff formatter, so a React element that
referenced itself through props, key, or children recursed until the
native stack overflowed.

Also run the stack-depth check for every tag (not only those tracked in
the visited map), unwrap nested Proxy targets iteratively, add
Tag::Event to the diff formatter's visited set, and remove the value
before the Event->Object re-dispatch so CloseEvent/CustomEvent do not
render as [Circular].
Comment thread test/js/bun/util/inspect.test.js Outdated
Comment thread test/js/bun/util/inspect.test.js 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.

LGTM — all prior review feedback has been addressed and the latest commit (02da7db) drains both pipes in every new subprocess test.

What was reviewed:

  • print_as_prelude reorder: stack check now runs unconditionally before the !can_circ early return; is_safe_to_recurse() is side-effect-free so this is safe for all tags.
  • print_proxy loop: Proxy target chains cannot cycle (each target must exist before the Proxy wrapping it), so the peel loop terminates; revoked proxies get a distinct tag and exit via format.
  • pretty_format.rs Event re-dispatch: map.remove(&value) before the Tag::Object re-entry mirrors ConsoleObject's remove_before_recurse pattern; the new CloseEvent/CustomEvent/circular-MessageEvent test locks it in.
  • Six subprocess tests: async Bun.spawn + it.concurrent, both stdout and stderr drained in Promise.all, signalCode asserted falsy on the deep-chain crashers.
Extended reasoning...

Overview

This PR fixes three native-stack-overflow SIGSEGVs in Bun.inspect / console.log: cyclic JSX props.children, deep non-cyclic JSX chains, and deep Proxy target chains. Changes touch src/jsc/ConsoleObject.rs (three small hunks: add Tag::JSX to can_have_circular_references, move the stack-safety check ahead of the !can_circ early return in print_as_prelude, and convert print_proxy's target recursion to a loop), src/runtime/test_runner/pretty_format.rs (add Tag::JSX | Tag::Event to the circular set and map.remove(&value) before the Event→Object re-dispatch), and six new subprocess crash-guard tests in test/js/bun/util/inspect.test.js.

Security risks

None. This is the console/inspect formatter — it renders values, does not parse untrusted input into privileged operations, and the fix strictly reduces attack surface (a hostile object graph could previously SIGSEGV the process; now it prints [Circular] or throws a catchable RangeError). No auth, crypto, filesystem, or network paths touched.

Level of scrutiny

Medium. The formatter is user-visible but not correctness-critical to program semantics — it only affects diagnostic output. The changes are minimal and mechanical: an enum-match arm addition, a two-block reorder of side-effect-free checks, and a recursion→loop conversion. I traced each:

  • The stack-check reorder is safe because is_safe_to_recurse() only reads the stack pointer; running it for non-circular tags adds no behavior beyond the intended guard.
  • The Proxy peel loop cannot infinite-loop: Proxy target chains are acyclic by construction (each target must exist before the Proxy is created), and a revoked nested proxy would return a non-Tag::Proxy from Tag::get and exit via format.
  • The map.remove(&value) before re-dispatch in pretty_format.rs mirrors the exact remove_before_recurse pattern already documented in ConsoleObject.rs:3335 for the same hazard.

Other factors

This PR has been through five review rounds; every prior finding (missing deep-non-cyclic-JSX test, exact-empty-stderr assertion, spurious-[Circular] on Event re-dispatch, spawnSync vs async, undrained pipes) was addressed with a follow-up commit and a regression test where applicable. The one open informational note (pretty_format.rs lacks a StackCheck field entirely, so deep non-cyclic chains through the diff formatter can still overflow) is pre-existing, acknowledged by the author as follow-up scope, and not introduced by this PR. The description flags overlap with #34889 for maintainer coordination, but the code here is independently correct and mergeable.

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.

bun test with react DOM shows infinite error log on expect failed

1 participant