Skip to content

inspect: don't probe every index of an arguments object with a huge length - #42247

Open
robobun wants to merge 4 commits into
mainfrom
robobun/0ac6db53/inspect-arguments-huge-length
Open

robobun wants to merge 4 commits into
mainfrom
robobun/0ac6db53/inspect-arguments-huge-length

Conversation

@robobun

@robobun robobun commented Sep 10, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • console.log / Bun.inspect of an arguments object with a huge length never returns: const a = (function () { return arguments; })(1, 2, 3); a.length = 2 ** 32; console.log(a).
  • Cause: print_array (src/jsc/ConsoleObject.rs:4307) probes every index up to get_length(), which for an arguments object is its writable length property. The hole-skipping walk from inspect: don't probe every hole when printing a sparse array #33158 was is_array() only. Past 2^32 - 1 the u32 index also overflowed.

Fix

  • Bun__JSArray__nextPresentIndex becomes Bun__JSObject__nextPresentIndex: the same butterfly walk, plus the argument slots that DirectArguments / ScopedArguments keep outside the butterfly. print_array uses it for arguments objects too.
  • The probe loop stops at MAX_ARRAY_INDEX. Nothing above it is an indexed property, so the rest of length counts as holes and the index cannot overflow.
  • Output is unchanged for every input that used to complete (7500 random mutation sequences print identically on 1.4.3). The cost is now bounded by the storage that exists.
  • Verified: test/js/bun/util/inspect.test.js (the child-process test times out on 1.4.3). Also test/js/bun/console/, test/js/web/console/. Self-reviewed: all surviving concerns addressed, see Notes.

Background

  • JSC has three arguments objects. DirectArguments (sloppy function) stores the arguments inline. ScopedArguments (a closure captures a parameter) aliases them into the scope. ClonedArguments (strict function) keeps them in the butterfly.
  • The butterfly is JSC's out-of-line storage for indexed properties: a vector, plus a sparse map in ArrayStorage mode.
  • MAX_ARRAY_INDEX is 2^32 - 2. A larger integer key is a string-named property. Only an arguments object's length can exceed it.
Notes

Scope and what is left

  • Found by fuzzing, not from a user report. a.length = 1e9 takes ~13 s on 1.4.3. A length getter returning 1e12, console.log({ a }), and an Error with such a property all hang the same way. They all reach print_array. Node prints [Arguments] { '0': 1, '1': 2, '2': 3 } immediately for the same object (it never reads length for an arguments object).
  • console.table / Bun.inspect.table of the same object is not fixed here. That path does not go through print_array: it builds one row per claimed index (1e6 rows in 630 ms on 1.4.3), so it still does not return for length = 2 ** 32. console.table: walk an array by its own properties instead of trusting length #41119 (open) owns that path.
  • The JSX-children path (ConsoleObject.rs:5233) also iterates up to get_length. It prints every child, so its cost is proportional to its output. With length > 2 ** 32 it ends in panic: int cast: TryFromIntError(PosOverflow), but only after 2^32 iterations (about 9 minutes). Pre-existing, left alone to keep this PR to one loop.
  • A string-keyed "index" above MAX_ARRAY_INDEX (for example a["4294967295"] with length = 2 ** 33) is counted as a hole, not printed. Arrays can never have one below their length. For arguments objects the old loop read it once and then wrapped the counter.

How the walk works

  • For the first two kinds a deleted argument is "unmapped", and an index written past the argument count goes to the butterfly like on any object. getDirectIndex on a GenericArgumentsImpl consults the mapped argument slots (isMappedArgument(i) for i < internalLength()) and then the ordinary butterfly path. So "next present index" is the minimum over both, the same two sources GenericArgumentsImpl::getOwnPropertyNames enumerates. ClonedArguments needs nothing special: its elements are butterfly elements.
  • next_present_index can scan the whole sparse map on each call. print_array bounds the number of calls (it stops after 100 present elements). A caller that wants a full walk should snapshot and sort the keys instead, as JSObject::getOwnIndexedPropertyNames does. The doc comment says so.

Evidence

  • Timings on the debug build, all three kinds (real DirectArguments / ScopedArguments / ClonedArguments, three distinct JSC structures): length of 2^32, 1e9, 2^32 ± k, 2^53, Infinity, a 2^50 getter, and an element at index 2^32 - 2 under a 2^33 length all format in 2 ms or less. -1 and NaN give [] as before.
  • Differential check: 7500 seeded random mutation sequences over the three kinds (delete, set, defineProperty with accessor and data descriptors, length up to 5000, freeze / seal / preventExtensions) print byte-identical output on 1.4.3 and on this branch.
  • Mutation check of the new C++: a mutant that stops the slot walk at the first unmapped slot fails only the "runs of deleted arguments" row. A mutant that ignores the butterfly result when a mapped slot is found fails only the "re-assigned index" row. Both rows are in the per-kind test.

Related open PRs

Self-review

  • Concerns that survived: no test made the slot walk step over two deleted arguments in a row or take the butterfly side of the minimum (two rows added, each kills a mutant), the cost of next_present_index was undocumented (doc comment), and the Related list above was incomplete (fixed). The child test at first only produced ClonedArguments because -e code is a strict module (fixed with new Function, and both tests now assert which kinds are sloppy).

no test proof · iteration 0 · 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

…ength

print_array walks an array-like up to its length and, since #33158, skips a
run of holes in one step by asking the array's storage for the next present
index. That fast path was arrays-only: arguments objects still probed index
by index because "their length is small". It is not: an arguments object's
length is an ordinary writable property, so console.log / Bun.inspect of one
with length = 2 ** 32 (or a getter returning 1e12) never returned, and past
2^32 - 1 the u32 index counter wrapped.

Bun__JSArray__nextPresentIndex becomes Bun__JSObject__nextPresentIndex: the
same butterfly walk, plus the argument slots that DirectArguments and
ScopedArguments keep outside the butterfly (mirroring
GenericArgumentsImpl::getOwnPropertyNames). print_array uses it for every
hole, and stops probing at MAX_ARRAY_INDEX, past which nothing can be an
indexed property, counting the rest of the claimed length as holes.
@coderabbitai

coderabbitai Bot commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: e23afc7f-cb1a-4b36-af28-066ef16a1fcc

📥 Commits

Reviewing files that changed from the base of the PR and between 2ffa891 and 82a3508.

📒 Files selected for processing (5)
  • src/jsc/ConsoleObject.rs
  • src/jsc/JSValue.rs
  • src/jsc/bindings/bindings.cpp
  • src/jsc/lib.rs
  • test/js/bun/util/inspect.test.js

Included review availability: Your plan provides up to 10 included reviews per hour; 1 remains after this review.


Walkthrough

Changes

The indexed-property binding now supports arbitrary objects and mapped arguments slots. Rust inspection uses the generalized scanner and limits traversal at MAX_ARRAY_INDEX. Tests cover sparse, deleted, reassigned, accessor-based, and large-length arguments objects.

Indexed-property inspection

Layer / File(s) Summary
Generalize indexed-property scanning
src/jsc/bindings/bindings.cpp
The exported scanner now handles butterfly-backed storage and mapped slots in direct and scoped arguments objects.
Use object scanning during inspection
src/jsc/lib.rs, src/jsc/JSValue.rs, src/jsc/ConsoleObject.rs
Rust uses Bun__JSObject__nextPresentIndex. Array formatting stops at MAX_ARRAY_INDEX and records the remaining range as holes.
Validate arguments-object inspection
test/js/bun/util/inspect.test.js
Tests cover arguments-object representations, holes, deleted and reassigned indexes, sparse properties, accessors, and large lengths.

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to 82a35

The arguments-object inspection change has no identified merge-blocking risk.

🚥 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 and concisely describes the primary fix: preventing per-index probing for arguments objects with very large lengths.
Description check ✅ Passed The description explains the problem, implementation, scope, limitations, and verification results. It does not use the exact template headings, but it provides the required information and is substan…

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

@robobun

robobun commented Sep 10, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:29 AM PT - Sep 11th, 2026

✅ @robobun, your commit 82a3508f9a55917a6cb1edba526acf2dd3edf83c passed in Build #114200! 🎉


🧪   To try this PR locally:

bunx bun-pr 42247

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

bun-42247 --bun

@robobun

robobun commented Sep 10, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. All review threads are resolved and the self-review is done. Its surviving concerns are addressed (they are listed in the Notes of the PR body).

Where this came from (so it can be weighed accordingly)

  • Found by fuzzing, not from a user report. A tracker search for the symptom (console.log / Bun.inspect of an arguments object hanging, x empty items, huge length) finds no issue for it.
  • The array case that inspect: don't probe every hole when printing a sparse array #33158 fixed did have an outside report (Very slow console.log on large sparse arrays compared to Node and Deno #29175, console.log(new Array(1_677_721_600)) taking 16 s). This PR is the same loop on the other JSTypes that reach print_array. It has no equivalent report.
  • Reach is narrow. It needs in-process code that takes an arguments object, overwrites or redefines its length to roughly 1e8 or more, and then logs it. Such an object cannot arrive as data: structuredClone rejects it (DataCloneError) and a JSON round trip yields a plain object, which prints fine. Plain array-likes ({ length: 2 ** 32 }) do not take this path.
  • What it buys: the formatter's cost is bounded by storage that exists for every type that reaches print_array, and the u32 index can no longer wrap (past 2^32 - 1 the old loop never terminated rather than just being slow).

Reproduce

// sloppy script (a DirectArguments); a strict function (ClonedArguments) hangs the same way
const a = new Function("return arguments")(1, 2, 3);
a.length = 2 ** 32;
console.log(a); // 1.4.3: never returns. This branch: [ 1, 2, 3, 4294967293 x empty items ]

Verified

  • bun bd test test/js/bun/util/inspect.test.js: 85 pass, 0 fail on this branch.
  • On released bun 1.4.3, Bun.inspect arguments object with a huge length summarizes holes without iterating them times out (the child never exits). It passes here in about 300 ms on the debug build.
  • That child test now builds its sloppy functions with new Function (code given to -e is a strict module, so the first version only produced ClonedArguments). Both tests assert which kinds are sloppy. Checked separately: the three kinds are three distinct JSC structures, and each one alone hangs on 1.4.3 with length = 2 ** 32 and returns in 2 ms or less here.
  • The three it.each cases (direct / scoped / cloned) pass on both 1.4.3 and this branch. They are characterization guards that pin unchanged output for the shapes that already terminated, not failing-before tests.
  • Mutation check of the new C++ (temporary build, not committed): a mutant that stops the argument-slot walk at the first deleted argument fails only the new "runs of deleted arguments" row. A mutant that ignores the butterfly result when a mapped argument is found fails only the new "re-assigned index" row. Both rows pass on 1.4.3 and here.
  • Differential check for "output unchanged": 7500 seeded random mutation sequences over the three kinds (delete, set, defineProperty with accessor and data descriptors, length up to 5000, freeze / seal / preventExtensions) print byte-identical Bun.inspect output on 1.4.3 and on this branch.
  • Measured on 1.4.3: length = 1e7 83 ms, 1e8 810 ms, 2 ** 32 never returns. Node prints [Arguments] { '0': 1, '1': 2, '2': 3 } at once.

Not fixed here / related

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

Comment thread test/js/bun/util/inspect.test.js Outdated
Code passed to -e without a require() is loaded as a module, so its
functions are strict and all three cases were ClonedArguments. Build the
direct and scoped functions with new Function, as the in-process test does,
and assert in both tests that the first two kinds are sloppy.
Comment thread src/jsc/ConsoleObject.rs Outdated
Comment thread src/jsc/ConsoleObject.rs Outdated
Comment thread src/jsc/JSValue.rs Outdated
Comment thread src/jsc/bindings/bindings.cpp Outdated
Comment thread src/jsc/bindings/bindings.cpp Outdated
Comment thread src/jsc/lib.rs 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 — the earlier note about the -e subprocess test only exercising ClonedArguments is now addressed (it uses new Function for the sloppy kinds and asserts isSloppyArguments returns true/true/false, so the test self-verifies it hits all three JSC arguments-object types).

What was reviewed:

  • Bun__JSObject__nextPresentIndex: butterfly walk unchanged in behavior, uncheckedDowncast guarded by object->type() switch, std::min of mapped-argument slots and butterfly matches how GenericArgumentsImpl::getOwnPropertyNames enumerates the same two sources; nothing here allocates, throws, or enters JS, so no ThrowScope needed.
  • print_array loop: i > MAX_ARRAY_INDEX guard runs before get_direct_index/next_present_index(i + 1), so the u32 index cannot wrap even when len is 2^50.
  • Tests pin exact output for delete/defineProperty/length-shrink/index-past-args across all three kinds, and the huge-length subprocess case drains pipes concurrently and asserts the combined {stdout, stderr, exitCode}.
Extended reasoning...

Overview

The PR generalizes the sparse-array hole-skipping optimization in Bun.inspect/console.log so it also applies to arguments objects, whose length is a plain writable property that can be set arbitrarily large with only a few backing elements. On the C++ side, Bun__JSArray__nextPresentIndex is renamed to Bun__JSObject__nextPresentIndex, the existing butterfly walk is extracted into a static helper, and a small template walks internalLength()/isMappedArgument() for DirectArguments/ScopedArguments (which store argument slots outside the butterfly). The result is std::min of the two sources — exactly the pair GenericArgumentsImpl::getOwnPropertyNames enumerates. On the Rust side, next_present_index now accepts any object, print_array drops the is_array() special-case, and a MAX_ARRAY_INDEX guard stops the loop before the u32 index can overflow. A new MAX_ARRAY_INDEX constant is added to src/jsc/lib.rs.

Security risks

None. The change is read-only traversal of JSC-managed storage during formatting; it does not allocate, does not call anything that can throw or re-enter JS, and holds no pointers across GC. The uncheckedDowncast calls are gated on the exact JSType tag, which is the standard JSC pattern. The added bound check strictly reduces the work done on adversarial input (huge length).

Level of scrutiny

Moderate. This is native JSC-binding code, but it is a focused generalization of an existing, already-reviewed function (from #33158) rather than a new mechanism. The butterfly-walk logic is byte-for-byte the same modulo the JSArray* → JSObject* receiver widening; the new arguments-slot walk is four lines mirroring a named JSC function. I traced the u32 overflow concern: the i > MAX_ARRAY_INDEX check fires at i == u32::MAX, which is the only value reachable past 0xFFFF_FFFE (via i += 1 after processing the element at MAX_ARRAY_INDEX); next_present_index(i + 1) is only reached with i <= MAX_ARRAY_INDEX, so i + 1 never wraps.

Other factors

My one prior inline comment (the -e subprocess test ran as a module, so all three "kinds" were ClonedArguments) was addressed in the follow-up commit: the subprocess now builds direct/scoped via new Function(...) and bakes an isSloppyArguments assertion into the expected stdout (true/true/false), so the test would fail if the kinds ever collapsed again. Test coverage is thorough across the variant matrix REVIEW.md asks for — all three arguments kinds × delete/defineProperty/length-shrink/length-grow/index-past-args, plus the huge-length cases (2^32 and a 2^50 getter) in a subprocess with concurrent pipe draining and a combined {stdout, stderr, exitCode} assertion. No CODEOWNERS entry covers these paths, and there are no outstanding third-party review objections.

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

Code review found no issues

No high-confidence issues detected in this change.

…ts shapes

The two new rows make the argument-slot walk step over consecutive deleted
arguments, and put a re-assigned index (regular indexed storage) between a
hole and an argument that is still mapped, so both sources of the minimum
in Bun__JSObject__nextPresentIndex are exercised.

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

Code review found no issues

No high-confidence issues detected in this change.

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.

2 participants