Skip to content

console: walk JSX children with many holes by the indexes they hold - #47526

Open
robobun wants to merge 2 commits into
mainfrom
robobun/68373995/jsx-children-sparse-holes
Open

robobun wants to merge 2 commits into
mainfrom
robobun/68373995/jsx-children-sparse-holes

Conversation

@robobun

@robobun robobun commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • console.log(el) does not finish when el.props.children is a sparse array (c = ["a"]; c[4294967294] = "x"): 966 MB in 5 s. Bun.inspect(el) aborts.
  • An arguments object as children with length above 2^32 - 1 ends in panic: int cast: TryFromIntError(PosOverflow).
  • Cause: print_jsx (src/jsc/ConsoleObject.rs:5246) prints one line for each index below length. Found by fuzzing, no user report.

Fix

  • The children arm reads own slots and, at the first hole, counts the indexes the list holds. Past 65,536 holes it walks those indexes, and a run of holes is one N x empty items line.
  • Fewer holes: the text is as before. 20,000 of 20,000 random lists match main.
  • Bun__JSArray__nextPresentIndex moves from bindings.cpp to the new PresentIndexes.cpp, the one walk of JSC index storage.
  • Verified: test/js/bun/util/inspect.test.js (37 new tests, main fails 30), console suites. Self-reviewed: 30 concerns raised, 26 addressed, 4 rejected (Notes).

Background

  • A hole is an index below length that the list does not hold: Array(5) has five.
  • JSC keeps elements in a vector, or in a hash map for far indexes and frozen arrays.
  • Considered empty item for every hole. It changes test.each titles and stored matcher messages, so it is a separate decision.

Downsides

Notes

Reproduce (main 34cbc70 and 1.4.3-canary, linux x64)

const element = children => ({ $$typeof: Symbol.for("react.element"), type: "div", key: null, ref: null, props: { children } });
const c = ["a"];
c[4294967294] = "x";
console.log(element(c)); // main: 966 MB in 5 s, exit 124 under `timeout 5`. Here: 48 bytes.
const a = (function () { return arguments; })(1, 2, 3);
a.length = 2 ** 33;
console.log(element(a)); // main: the panic after about 200 s. Here: 18 ms.

Bun.inspect(element(c)) on main fills memory. Under a 6 GB limit it ends in memory allocation of 2147483648 bytes failed, exit 134.

What prints

children main this PR
no hole one line for each child the same
up to 65,536 holes one line for each index. A hole shows undefined, or what the prototype chain holds the same
more than 65,536 holes one undefined line for each hole empty item or N x empty items for each run of holes, as an array prints them
  • 65,536: main prints that many holes in 3.5 ms and 786 KB. A list that main prints in practice keeps its text.
  • The budget is one number for the list, counted once at the first hole. It is not a cutoff for each run.
  • Past the budget the indexes are taken once. A child prints the value that its index holds when its turn comes. A hole that a child fills while the list prints stays a hole.
  • A child after a long run of holes can wrap differently: the wrap estimate adds the width of the hole line, not 9 for each hole.

Numbers. Release builds of main 34cbc70 and of this PR. Instructions and cycles are user mode, read through perf_event_open, BUN_JSC_useJIT=0. perf, valgrind and bloaty are not installed in the build container.

  • dense JSX child: getIndex 1 -> 0, getDirectIndex 0 -> 1, lister calls 0 (gdb hit counts, 1000 children, plain and frozen, main 34cbc70 vs PR)
  • instructions per dense JSX child: plain 644.8 -> 634.8, frozen 791.4 -> 740.4 (instructions:u, spread +/- 0.1, main vs PR). Cycles per child: plain 119 to 129 -> 122 to 126, frozen 153 to 188 -> 144 to 152.
  • allocator calls per print vs main: dense +0, frozen dense +0, ['a', , 'bb'] +0, 80,000 children with one hole +0 (gdb hit counts on mimalloc entry points). Past the budget: +1, 4 bytes for each index after the first hole.
  • a hole within the budget, per print, 9 interleaved rounds with main measured twice as control: ['a', , 'bb'] 11,375 -> 11,561 instructions, cycles 1.021x (control 0.999x). Frozen with one hole in 100 / 1,000 / 10,000 / 160,000 children: instructions -1.4% / -2.2% / -1.5% / -1.5%, cycles 0.984x / 0.982x / 1.021x / 0.905x (control 0.987x / 1.006x / 1.024x / 1.070x).
  • hole path past the budget, instructions per doubling: butterfly 2.07x 1.99x 1.97x, frozen alternating 2.06x 2.01x 2.03x (n = 80k to 640k), frozen 1-in-10 2.01x 2.01x 2.08x (n = 20k to 160k), arguments 2.01x 2.01x 2.01x (n = 4k to 32k). length 2^20 vs 2^32-1 with 20,000 held: 27.35M to 27.46M vs 27.44M to 27.47M instructions
  • print_jsx frame: 0x1b8 -> 0x1b8. Cold method frame 0x88. Nesting depth to RangeError 13,544 -> 13,548 with dense children, 13,548 -> 10,291 with a hole in the children at each level. Linux x64 release only.
  • release binary: +6,720 bytes text (size, main 34cbc70 vs PR). print_jsx 6,455 + 6,874 -> 6,420 + 6,808 bytes, cold method 1,160 x 2, hole text 505 x 2, two helpers 129 + 140, lister 2,675, Bun__JSArray__nextPresentIndex 405 -> 453. Externs +1, host functions +0, bindings.cpp -66 lines.
  • repro: exit 0, 48 bytes written (was exit 124, 966 MB in 5 s). Arguments length 2^33: exit 0 (was exit 134)
  • print_array (Rust unchanged, walker moved), instructions per print: [1, 2, 3] 6,194.7 -> 6,194.5. [1, , 3] 6,168.7 -> 6,169.4. 100 runs of holes 142,466 -> 142,561. Frozen with 100 runs 393,764 -> 394,368. That is +1 to +9 for each run of holes: the storage switch compiles to bit tests where bindings.cpp had a jump table.
  • jsx_child_at exists for the frame. With the read called in place, print_jsx::<false> has a frame of 0x1c8 and the dense nesting depth is 13,194. No test pins this.
  • Cycle counts of two builds are not comparable at this scale. A bare [1, 2, 3] runs the same 6,194 instructions and 1,145 branches on both builds and none of this code. It takes 1,382 cycles on the main build and 1,631 on this one, with 35 against 67 L1 instruction cache misses per print. On the previous base the two builds were at 1,534 and 1,562.

Checks

  • Differential, seeds 1 to 20,000, eight kinds of list (array, subclass, frozen, sealed, non-extensible, three kinds of arguments object) with random delete, set, far set, length, getter and non-enumerable steps. As built, 20,000 of 20,000 lists print the same bytes on both builds: as JSX children without and with colors, and as a bare value. 11,544 of them have holes.
  • The same lists with 70,000 more holes on this build: 16,775 of 16,775 comparable lists print the text of main for the short list with each run of holes as one line. 3,225 lists hold an index past their length and are not comparable.
  • Mutants of PresentIndexes.cpp, each built and run: no sort (4 tests fail), no skip of an argument that is in its slot and in the butterfly (2), no end bound on the contiguous vector (1), on the ArrayStorage vector (1), on the sparse map (4), no start bound on the sparse map (4), nextPresentIndex without the sparse map (the test of inspect: don't probe every hole when printing a sparse array #33158 fails).
  • On main 7 of the 37 new tests pass by design: four lists within the budget, two errors thrown by a getter, and the single-line case.
  • BUN_JSC_validateExceptionChecks=1 reports nothing on the new paths.
  • Suites on the debug ASAN build: inspect.test.js, snapshot.test.ts (with FORCE_COLOR=1), test/js/bun/console/, test/js/web/console/, md-react, 14477, inspect-error, reportError, expect.test.js: 868 pass, 0 fail. test/internal/source-lints/: 194 pass.

Self-review. A first version printed empty item for every hole and kept a second copy of the storage walk. That text reaches stored snapshots by two routes: test.each titles with %p, and matcher messages in toThrowErrorMatchingSnapshot. With snapshots written by main and CI=true, the first version fails both and this version passes both. This version keeps the text of main for every list within the budget and has one walk. Rejected:

  1. Take the arguments branch of print_array here too. inspect: don't probe every index of an arguments object with a huge length #42247 owns it. The lister here already knows the argument slots, so that PR can use it.
  2. Stop the expansion of an arguments object as children. It would change what such an element prints today.
  3. Wait for Print Bun's snapshot format with the console.log formatter #46729. Either order gives the same arm.
  4. empty item for every hole now. It is a text change and a separate decision. For that decision: main prints the same Array(3) as [ 3 x empty items ] when the element has a second child, and as three undefined lines only when it is the single child.

Other PRs

Still hit, not in this PR

The JSX printer read one child for every index below `length` and wrote
one line for each, holes included. A sparse array as `props.children`
did not finish, and an arguments object with `length` above 2^32 - 1
ended in `panic: int cast: TryFromIntError(PosOverflow)`.

The children arm now reads own slots. At the first hole it counts the
indexes that the list holds. A list with at most 65,536 holes prints as
before, one line for each index. A list with more holes is walked by the
indexes it holds, and each run of holes is one `N x empty items` line.

PresentIndexes.cpp is the one walk of the indexed storage of an array or
an arguments object. Bun__JSArray__nextPresentIndex moves there from
bindings.cpp and shares its loops.
@robobun

robobun commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review (#47526).

CI (build 136012, finished): 183 of 188 jobs passed. The 5 failed jobs all fail on one test, test/aot/engine/ir-6.test.ts. That test fails on main at 34cbc70 too (build 135971), and this diff does not touch the ahead-of-time compiler. The tests of this PR pass on every lane, Windows, macOS and ASAN included. The other failures passed on a retry. The mordant check reports one unused pub in src/glob/lib.rs, which is on main.

Reproduce

const element = children => ({ $$typeof: Symbol.for("react.element"), type: "div", key: null, ref: null, props: { children } });
const c = ["a"];
c[4294967294] = "x";
console.log(element(c)); // main: does not finish, 966 MB in 5 s. This branch: 48 bytes.

Run the tests with bun bd test test/js/bun/util/inspect.test.js -t "JSX children with holes". On main 30 of the 37 fail.

@robobun

robobun commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:22 AM PT - Oct 31st, 2026

❌ @robobun, your commit 5caf3e5 has 1 failures in Build #136012 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 47526

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

bun-47526 --bun

@coderabbitai

coderabbitai Bot commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 4256940 and 5caf3e5.

Files selected for processing: 1
  • test/js/bun/util/inspect.test.js

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

Walkthrough

JSX child formatting reads initial contiguous children directly, then uses indexed reads or present-index traversal for the remaining range. Large consecutive holes render as summarized lines. New JSC bindings enumerate present indexes for arrays and arguments objects.

Changes

JSX sparse-child formatting

Layer / File(s) Summary
Present-index traversal bindings
src/jsc/bindings/PresentIndexes.cpp, src/jsc/bindings/bindings.cpp, src/jsc/JSValue.rs
Native traversal counts and collects present indexes for arrays and arguments objects. Rust helpers expose the range lookup. The prior Bun__JSArray__nextPresentIndex implementation is removed from bindings.cpp.
JSX child-range formatting
src/jsc/ConsoleObject.rs, test/js/bun/util/inspect.test.js
The formatter reads initial children directly, then processes the remaining range with indexed reads or present-index traversal. Consecutive holes can render as summarized lines. Tests cover array and arguments-object storage, inherited values, mutations, nested elements, and large lengths.

Possibly related PRs

  • oven-sh/bun#42247: Adds present-index traversal for inspection of arguments objects with very large length values.

Suggested reviewers: alii, jarred-sumner

Merge Risk: Minimal · up to 5caf3

The test now checks inherited-getter calls after printing the element. No actionable merge-blocking risk was identified; merge after normal checks pass.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title names the main change: print_jsx (JSX children printing in console) now walks children by the indexes they hold when a list has many holes. The title matches the raw summary, which describes…
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 is complete and relevant. It explains the problem, the fix, trade-offs, known limitations, and verification results. It does not use the exact template headings, but the required conte…
Full details: Title check

Explanation

The title names the main change: print_jsx (JSX children printing in console) now walks children by the indexes they hold when a list has many holes. The title matches the raw summary, which describes the indexed-read and present-index walk for JSX children with holes, and matches the objectives, which describe reading own slots and walking held indexes past 65,536 holes. The title is specific and concise, is one sentence, and does not use vague terms.

Full details: Description check

Explanation

The description is complete and relevant. It explains the problem, the fix, trade-offs, known limitations, and verification results. It does not use the exact template headings, but the required content appears under equivalent sections: “Fix” covers what the PR does, and “Checks” covers verification.

  • 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

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

Beyond the inline findings, I also checked two things in the new hole path and found them sound: the index list from present_indexes_in going stale when a child's formatting runs user code (each index is re-read with get_direct_index and an emptied slot is folded into the next hole run, so no stale value is printed), and the std::sort being skipped on overflow passes in Bun__JSObject__presentIndexesInRange (the Rust retry loop always ends with a pass where count <= capacity, so the returned list is sorted).

Extended reasoning...

The change rewrites the JSX children arm of the console formatter in src/jsc/ConsoleObject.rs, adds a C ABI index-walker in the new src/jsc/bindings/PresentIndexes.cpp (moving the old nextPresentIndex helper out of bindings.cpp), and adds Rust wrappers plus 37 tests. It touches no auth, crypto or injection surface; the risk is memory/exception safety in unsafe FFI and the sparse-array walk. Inline findings are posted, so a human look is already signaled; this note only records what else was examined.

Comment thread test/js/bun/util/inspect.test.js Resolved
Comment thread src/jsc/ConsoleObject.rs Resolved

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

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟡 src/jsc/ConsoleObject.rs — nit, pre-existing: a user who inspects an element whose arguments-object children has no length (or length = Infinity) now gets a line reading 2251799813685244 x empty items instead of a hang. children.get_length at src/jsc/ConsoleObject.rs:5237 turns the C++ sentinel infinity for "no length property" into 2^51-1, and print_jsx_children_from_hole prints that as a hole run. Fix: treat a missing or non-finite length as zero (or as the arguments the object holds) before the hole walk, and pin the output in the test at test/js/bun/util/inspect.test.js:1741-1743 rather than asserting only typeof.

    Why this was flagged

    Trigger: const a = (function(){return arguments})(1,2,3); delete a.length; console.log(element(a)), or a.length = Infinity, reached through print_jsx's Tag::Array children arm. JSC__JSValue__getLengthIfPropertyExistsInternal at src/jsc/bindings/bindings.cpp:3098-3112 returns std::numeric_limits<double>::infinity() when the object has no length; JSValue::get_length at src/jsc/JSValue.rs:1366-1371 only maps f64::MAX to 0 and clamps infinity to 2^51-1. print_jsx then calls print_jsx_children_from_hole at src/jsc/ConsoleObject.rs:5271 with length 2251799813685247, which prints 1, 2, 3 and 2251799813685244 x empty items. The base hangs on the same input, so the PR turns a hang into a nonsensical count. The new test at test/js/bun/util/inspect.test.js:1741-1743 runs this exact case and asserts only typeof ... === "string", so the output is unpinned.

    Verification: Trigger: a JSX element whose props.children is an arguments object with length deleted or set to Infinity. JSValue::get_length (src/jsc/JSValue.rs:1366-1371) clamps the infinity sentinel to 2^51-1, so print_jsx_children_from_hole prints "2251799813685244 x empty items". On the base branch the same input hangs rather than prints. test/js/bun/util/inspect.test.js:1741-1743 leaves the output unverified.

@robobun

robobun commented Oct 31, 2026

Copy link
Copy Markdown
Collaborator Author

About the arguments object with no length (or length = Infinity): the count comes from JSValue::get_length, which gives 2^51 - 1 for both. This PR does not change that read. #38910 is the open PR for it, and it changes what a missing length gives. For that reason the test asserts only that the print returns: any pinned text would change again with #38910. The case is now in the Notes of the PR body, under Other PRs.

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