Skip to content

Fix stack overflow when inspecting JSX elements with circular references#29170

Closed
robobun wants to merge 1 commit into
mainfrom
farm/c40b8deb/fix-jsx-inspect-circular
Closed

Fix stack overflow when inspecting JSX elements with circular references#29170
robobun wants to merge 1 commit into
mainfrom
farm/c40b8deb/fix-jsx-inspect-circular

Conversation

@robobun

@robobun robobun commented Apr 11, 2026

Copy link
Copy Markdown
Collaborator

Fuzzer found a segfault when calling Bun.inspect() on a React element whose props (or key, or props.children) contains a reference back to the element itself:

const el = { $$typeof: Symbol.for("react.element"), type: "div", key: null, ref: null };
el.props = el;
Bun.inspect(el); // segfault (stack overflow)

The .JSX formatter tag was missing from canHaveCircularReferences(), so neither the visited-map cycle detection nor the stack guard was applied when formatting JSX elements. The formatter would recurse into props → format the same JSX element → recurse into props → … until the stack blew.

While in the area, also fixed a related panic: props.getObject().? assumed props is always an object, but a user-constructed element can have props: 42 and would hit attempt to use null value. Now it just skips props iteration and renders <div />.

Same fixes applied to both ConsoleObject.zig (Bun.inspect/console.log) and test/pretty_format.zig (test matcher formatting).

Fuzzer fingerprint: 2b73926abbed374a

Bun.inspect() on a React element whose props, key, or children point back
to the element itself would recurse until the stack overflowed because the
.JSX tag was not included in canHaveCircularReferences(). Add it so the
existing cycle detection and stack guard apply.

Also guard against props being a non-object value instead of unwrapping
getObject() with .?, which panicked on primitives.
@robobun

robobun commented Apr 11, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:31 AM PT - Apr 11th, 2026

@robobun, your commit 96c3fb1 has 1 failures in Build #45090 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29170

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

bun-29170 --bun

@coderabbitai

coderabbitai Bot commented Apr 11, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 13b0d5e2-c5c4-4d68-bf47-df008cd4e88e

📥 Commits

Reviewing files that changed from the base of the PR and between 4b1d889 and 96c3fb1.

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

Walkthrough

This pull request enhances JSX value handling in the console formatting system by enabling circular-reference detection for JSX values and improving props extraction to safely handle non-object props. Regression tests validate the new behavior for circular references and non-object props scenarios.

Changes

Cohort / File(s) Summary
JSX Circular Reference & Props Handling
src/bun.js/ConsoleObject.Zig, src/bun.js/test/pretty_format.zig
Updated canHaveCircularReferences to treat .JSX values as eligible for circular tracking. Changed props retrieval from forced object extraction to guarded extraction using props.getObject() orelse break :props_block, allowing safe skipping of props rendering when props is not an object.
JSX Inspection Regression Tests
test/js/bun/util/inspect.test.js
Added two new regression tests for JSX-like React element objects: one verifying [Circular] detection in circular props/children/key without crashing, and another ensuring inspection of elements with non-object props (value 42) returns "<div />" without throwing.
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Fix stack overflow when inspecting JSX elements with circular references' directly and clearly summarizes the main fix in the changeset.
Description check ✅ Passed The description comprehensively covers what the PR does (bug details, root cause, secondary fix) but lacks explicit documentation of verification methods used.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

Comment on lines 326 to 330
}

pub inline fn canHaveCircularReferences(tag: Tag) bool {
return tag == .Array or tag == .Object or tag == .Map or tag == .Set;
return tag == .Array or tag == .Object or tag == .Map or tag == .Set or tag == .JSX;
}

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 fix in pretty_format.zig is incomplete compared to ConsoleObject.zig in two ways: (1) canHaveCircularReferences() adds .JSX but still omits .Event, leaving circular MessageEvent/ErrorEvent (where data/error points back to the event) able to trigger infinite recursion via any Jest matcher; (2) pretty_format.zig has no stack-depth guard (no stack_check field, no isSafeToRecurse() call) unlike ConsoleObject.zig which guards all recursive types against deep-but-non-circular trees. Both gaps should be closed before merging.

Extended reasoning...

Missing .Event in canHaveCircularReferences (pretty_format.zig)

After this PR, pretty_format.zig canHaveCircularReferences() returns true for .Array, .Object, .Map, .Set, and .JSX — but not .Event. ConsoleObject.zig correctly includes .Event in the same list. The .Event branch in printAs() recurses for both MessageEvent (formats the .data property) and ErrorEvent (formats the .error property). If data/error holds a back-reference to the event itself, Tag.get() returns .Event, and printAs(.Event) is called again with no visited-map check, producing unbounded recursion and a stack overflow. This is directly triggerable from any Jest matcher:

const e = new MessageEvent("message");
Object.defineProperty(e, "data", { value: e });
expect(e).toMatchSnapshot(); // infinite recursion -> crash

Execution path: printAs(.Event, e) -> fastGet(.data) returns e -> Tag.get(e) -> .Event -> format(.Event, e) -> printAs(.Event, e) -> ... with no canHaveCircularReferences guard to break the cycle.

No stack-depth guard in pretty_format.zig

ConsoleObject.zig carries two additional fields on its Formatter struct: stack_check: bun.StackCheck and can_throw_stack_overflow: bool. Inside printAs(), before processing any type in canHaveCircularReferences(), it calls isSafeToRecurse() and sets this.failed = true if the stack is too deep.

pretty_format.zig has no such guard at all — grep returns zero hits for stack_check, isSafeToRecurse, or can_throw_stack_overflow in that file. The visited-map only prevents cycles (same object identity); it does nothing for deeply-nested but non-circular trees. A JSX tree thousands of levels deep, or a deeply-nested Array/Object/Map/Set, will recurse unboundedly and overflow the Zig stack when processed through any Jest matcher (toMatchSnapshot, toEqual, etc.).

Why existing code does not prevent this

The visited-map is only consulted when canHaveCircularReferences() returns true for the compile-time Format tag. Because .Event returns false, printAs(.Event) never inserts the value into the map and never checks for an existing entry. The stack-depth guard is simply absent — it was never ported to pretty_format.zig from ConsoleObject.zig.

Impact

Any Jest test that calls expect() on (a) a MessageEvent or ErrorEvent with a circular data/error property, or (b) a deeply-nested React element tree or Object/Array/Map/Set, can crash the test process with a Zig stack overflow. Since this formatter is used by all Jest matchers, the crash surface is wide.

How to fix

  1. Add .Event to canHaveCircularReferences() in pretty_format.zig alongside .JSX.
  2. Add a stack_check: bun.StackCheck field to the Formatter struct in pretty_format.zig and call isSafeToRecurse() at the top of the canHaveCircularReferences() guard block in printAs(), mirroring the pattern already present in ConsoleObject.zig.

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.

Both of these are pre-existing gaps on main that predate this PR — neither .Event nor a stack_check guard exist in pretty_format.zig today. This PR targets the specific fuzzer crash (2b73926abbed374a) in Bun.inspect() on circular JSX, and mirrors the same minimal JSX fix to pretty_format.zig since the identical code pattern lives there.

Widening the scope to also port .Event cycle detection and the full StackCheck mechanism into the Jest formatter is a separate change (the stack guard in particular needs new struct fields + init plumbing). Keeping this one focused on the JSX crash; happy to track those as a follow-up.

@robobun

robobun commented Apr 25, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #29709 which has the same fix plus additional test coverage for the diff formatter path.

@robobun robobun closed this Apr 25, 2026
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