Skip to content

console.table: walk an array by its own properties instead of trusting length - #41119

Open
robobun wants to merge 1 commit into
mainfrom
robobun/32111cda/console-table-rows-from-elements
Open

robobun wants to merge 1 commit into
mainfrom
robobun/32111cda/console-table-rows-from-elements

Conversation

@robobun

@robobun robobun commented Sep 1, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • console.table(px) and Bun.inspect.table(px) never return when px is a Proxy whose get trap reports length 4294967295 for a 2 element array. With length 16M, Bun.inspect.table returns a 486,539,380 char string. Bun.inspect.table(new Array(50_000_000)) returns 1.25 GB in 16 s, and a = []; a[1e9] = 1; console.table(a) hangs. Node prints the real rows, or an empty table, at once.
  • TablePrinter::print_table (src/jsc/ConsoleObject.rs:904) reads every object that has Symbol.iterator through JSC::forEachInIterable. The Array iterator re-reads length on each step and yields undefined for every index that has no element. Nothing bounds the row count by the elements that exist.

Fix

  • An array (through a Proxy too, per IsArray) and any object whose Symbol.iterator is the intrinsic Array.prototype.values (arguments, a plain array-like) now take the own-property walk that plain objects already use. That is Node's model: console.table uses Object.keys for anything that is not a Map or a Set. The new Bun__JSValue__isNonArrayIterable binding makes the decision. Sets, Maps, generators, typed arrays and other iterables keep the iterator path.
  • Correct because the row set is now bounded by what exists: the array's storage, or the own keys an object (or its Proxy handler) reports. A reported length is never read. Cell values still go through [[Get]], so a Proxy get trap is honored, as in Node.
  • Holes are skipped and an array's named enumerable properties become rows (arr.foo), both as in Node. Dense arrays, the existing snapshots and the generator test render unchanged.
  • Verified: test/js/bun/console/console-table.test.ts (9 new tests, 8 fail on stock bun 1.4.1). Also bun-inspect-table.test.ts and test/js/bun/util/inspect.test.js.

Background

  • TablePrinter collects all rows in one width pass, then renders them. Rows come from one of two walks: the iterator protocol, or own enumerable properties through JSPropertyIterator (getOwnPropertyNames with DontEnum excluded, the same key set as Object.keys). This PR only changes which objects take which walk.
  • The intrinsic Array iterator is the realm's arrayProtoValuesFunction. Its next does ToLength(Get(O, "length")) on every step. For a non-array O that value is whatever the object says. The check compares the method against its own realm's intrinsic, so cross-realm objects (node:vm) are covered.
  • JSC::isArray is the spec IsArray: true for Array, an Array subclass, and a Proxy whose target is one.
Notes

Behavior changes, all match Node:

  • A hole is no longer rendered as an undefined row. console.table([1, , 3]) prints rows 0 and 2.
  • An array's named enumerable properties are rows: Object.assign([1, 2], { foo: 3 }) prints rows 0, 1, foo.
  • An array with a custom Symbol.iterator is walked by its elements, not by that iterator.

Unchanged: Set, Map, Map/Set iterators, generators, typed arrays, String objects, Headers, arguments (same rows, now through the property walk), a Proxy around a Set (TypeError, as before), a non-callable Symbol.iterator (TypeError, as before). A revoked Proxy still throws a TypeError, now from IsArray.

Alternatives considered:

  • A row cap. Arbitrary, and it still prints thousands of wrong undefined rows for the Proxy case.
  • Unwrapping the Proxy to its target, as the formatter does for console.log. That bypasses get traps, which Node honors, and does nothing for sparse arrays.
  • A dedicated present-index walk for arrays with Bun__JSArray__nextPresentIndex (the formatter's sparse array helper). Its output is identical to the property walk, but it rescans the whole sparse map per call, so a frozen array (Object.freeze moves every element into the sparse map) of 10k rows took 17 s in a debug build against 0.8 s for the property walk.

Cost of the property walk for real arrays: one atomized index string per row. In a debug build a dense and a frozen 10k row table both take about 0.8 s. The 50M hole case spends its time allocating the array, not in the table.

Measurements on stock bun 1.4.1 (release, linux x64): the 2^32-1 Proxy was killed at 10 s; new Array(50_000_000) took 16.4 s and returned 1,250,000,100 chars. With the fix both return in under 5 ms of table work.

Self-reviewed: 8 concerns raised. Addressed: the first version kept a separate present-index walk for arrays (quadratic on sparse maps, and a bare array hid arr.foo while a Proxy around it did not) and used isJSArray (misses Array subclasses and Proxies). Both are gone.


[human-review] gate passed · iteration 0 · 4 files touched

fails on main (without fix)
ASAN without fix: 8 failed, 1 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/console/console-table.test.ts
bun test v1.4.1 (a6c4cc276)

test/js/bun/console/console-table.test.ts:
┌───┐
│   │
├───┤
└───┘
┌───┐
│   │
├───┤
└───┘
(pass) console.table > throws when second arg is invalid [7.91ms]
(pass) console.table > expected output for: not object (number) [8.89ms]
(pass) console.table > expected output for: not object (string) [1.70ms]
(pass) console.table > expected output for: object - empty [1.40ms]
(pass) console.table > expected output for: object [2.15ms]
(pass) console.table > expected output for: array - empty [1.16ms]
(pass) console.table > expected output for: array - plain [1.64ms]
(pass) console.table > expected output for: array - object [1.55ms]
(pass) console.table > expected output for: array - objects with diff props [2.00ms]
(pass) console.table > expected output for: array - mixed [2.17ms]
(pass) console.table > expected output for: set [1.61ms]
(pass) console.table > expected output for: map [2.44ms]
(pass) console
... (truncated)

release without fix: 8 failed, 1 skipped
bun test v1.4.1-canary.1 (a6c4cc276)

test/js/bun/console/console-table.test.ts:
┌───┐
│   │
├───┤
└───┘
┌───┐
│   │
├───┤
└───┘
(pass) console.table > throws when second arg is invalid [0.10ms]
(pass) console.table > expected output for: not object (number) [0.39ms]
(pass) console.table > expected output for: not object (string) [0.02ms]
(pass) console.table > expected output for: object - empty [0.02ms]
(pass) console.table > expected output for: object [0.03ms]
(pass) console.table > expected output for: array - empty [0.01ms]
(pass) console.table > expected output for: array - plain [0.02ms]
(pass) console.table > expected output for: array - object [0.01ms]
(pass) console.table > expected output for: array - objects with diff props [0.01ms]
(pass) console.table > expected output for: array - mixed [0.02ms]
(pass) console.table > expected output for: set [0.02ms]
(pass) console.table > expected output for: map [0.02ms]
(pass) console.table > expected output for: properties [0.02ms]
(pass) console.table > expected output for: properties - empty [0.01ms]
(pass) console.table > expected output for:
... (truncated)
passes on PR (with fix)
ASAN with fix: 1 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/console/console-table.test.ts
bun test v1.4.1 (a6c4cc276)

test/js/bun/console/console-table.test.ts:
┌───┐
│   │
├───┤
└───┘
┌───┐
│   │
├───┤
└───┘
(pass) console.table > throws when second arg is invalid [7.10ms]
(pass) console.table > expected output for: not object (number) [8.84ms]
(pass) console.table > expected output for: not object (string) [1.58ms]
(pass) console.table > expected output for: object - empty [1.38ms]
(pass) console.table > expected output for: object [2.11ms]
(pass) console.table > expected output for: array - empty [1.14ms]
(pass) console.table > expected output for: array - plain [1.74ms]
(pass) console.table > expected output for: array - object [1.58ms]
(pass) console.table > expected output for: array - objects with diff props [2.19ms]
(pass) console.table > expected output for: array - mixed [2.18ms]
(pass) console.table > expected output for: set [1.56ms]
(pass) console.table > expected output for: map [2.52ms]
(pass) console
... (truncated)

release with fix: 1 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     27135adc86
  features     baseline

23 deps, 131 codegen, 1172 objects in 611ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1244] install /workspace/bun
bun install v1.4.1-canary.1 (a6c4cc276)

Checked 26 installs across 63 packages (no changes) [3.00ms]
[2/1244] gen bindgenv2
[3/1244] install /workspace/bun/packages/bun-error
bun install v1.4.1-canary.1 (a6c4cc276)

Checked 1 install across 2 packages (no changes) [1.00ms]
[4/1244] gen ErrorCode+*.h
[5/1244] install /workspace/bun/src/node-fallbacks
bun install v1.4.1-canary.1 (a6c4cc276)

Checked 111 installs across 104 packages (no changes) [6.00ms]
[6/1244] fetch zlib
[zlib] up to date
[7/1244] fetch tinycc
[tinycc] up to date
[8/1243] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[9/1243] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[10/1216] gen .bind.ts → GeneratedBindings.cpp
[11/1216] gen node-fallbacks/react-refresh.js
Bundled 1 module in 8ms

  react-refresh.js  4.81 K
... (truncated)
diff hotspot
src/jsc/ConsoleObject.rs                  |  6 ++-
 src/jsc/JSValue.rs                        |  6 +++
 src/jsc/bindings/bindings.cpp             | 32 ++++++++++++
 test/js/bun/console/console-table.test.ts | 83 +++++++++++++++++++++++++++++++
 4 files changed, 126 insertions(+), 1 deletion(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                       reads  edits  tests
src/jsc/ConsoleObject.rs                       4      6     15
src/jsc/JSValue.rs                             1      3     16
src/jsc/bindings/bindings.cpp                  1      4     15
test/js/bun/console/console-table.test.ts      3      3     15

…g length

The table printer read every array-like through the iterator protocol.
The Array iterator trusts the length the object reports and yields
undefined for every index that has no element. A Proxy that reports
length 2^32 - 1 for a 2 element array, or a sparse array with a large
length, produced one row per reported index and never returned.

Arrays (through a Proxy too) and objects that borrow the intrinsic Array
iterator, such as arguments, now take the own enumerable property walk
that plain objects already use, like Node's console.table. Sets, Maps,
generators and other iterables keep the iterator path.
@robobun

robobun commented Sep 1, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Reproduced on stock bun 1.4.1 (linux x64):

  • Bun.inspect.table(new Proxy([{a:1},{a:2}], { get: (t, p, r) => p === "length" ? 4294967295 : Reflect.get(t, p, r) })) did not return in 10 s.
  • Bun.inspect.table(new Array(50_000_000)) returned a 1,250,000,100 char string after 16.4 s.

With this branch both return in under 5 ms of table work. 8 of the 9 new tests in test/js/bun/console/console-table.test.ts fail on stock bun and pass with the fix. All 42 tests in that file, bun-inspect-table.test.ts and test/js/bun/util/inspect.test.js pass.

CI (build 109075): 181 of 182 jobs pass. The one red job is test/cli/run/require-cache.test.ts on debian 13 x64-asan, a leak test that times out on main too and does not touch this change. The other entries in the build are flakes that passed on retry. The console.table tests pass on every lane.

@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 — focused fix that routes arrays/array-likes through the own-property walk so a lied-about length or sparse holes can't inflate console.table output.

What was reviewed:

  • Exception-scope discipline in Bun__JSValue__isNonArrayIterable: RETURN_IF_EXCEPTION follows both isArray and getMethod; the tail (dynamicDowncast, pointer compare) can't throw, so no RELEASE_AND_RETURN needed.
  • Cross-realm correctness: comparing against function->globalObject()'s own arrayProtoValuesFunction intrinsic, not the caller's realm — a node:vm array's iterator still matches.
  • Iterable classification: typed arrays, Map/Set, String, Headers keep the iterator path (their @@ iterator is not %Array.prototype.values%); arguments and Proxied arrays fall to the property walk as intended.
  • Tests follow harness conventions (concurrent pipe drain, await using, combined-object assertion) and cover the variant matrix including frozen-sparse and Array-subclass-with-custom-iterator.
Extended reasoning...

Overview

This PR fixes a hang/OOM in console.table and Bun.inspect.table when the input is a Proxy that reports an inflated length, or a very sparse array. The root cause was that TablePrinter used the iterator protocol for anything with Symbol.iterator, and the intrinsic Array iterator trusts the reported length and yields undefined for every hole. The fix adds a new predicate is_non_array_iterable (Rust wrapper + C++ binding) that returns false for arrays (via spec IsArray, so Proxy-aware) and for anything whose Symbol.iterator resolves to the intrinsic %Array.prototype.values% (covers arguments). Those objects now take the own-enumerable-property walk that plain objects already used — the same model as Node's Object.keys-based console.table. Four files touched: one-line switch in ConsoleObject.rs, a 6-line Rust wrapper in JSValue.rs, a ~30-line C++ binding in bindings.cpp, and 9 new tests appended to the existing console-table.test.ts.

Security risks

None. console.table is a formatting/diagnostic path with no auth, crypto, filesystem, or network implications. If anything, this reduces a resource-exhaustion surface: previously a Proxy reporting length 2^32-1 would hang the process; now the row set is bounded by the object's own keys. The new C++ binding calls getMethod, which can invoke a Proxy get trap for Symbol.iterator, but that was already the case in the pre-existing hasIteratorMethod path — no new user-code entry point is introduced.

Level of scrutiny

Moderate. The change adds a new FFI binding with JSC exception-scope handling, which REVIEW.md flags as a high-attention area. I traced the throw scope: DECLARE_THROW_SCOPE at the top, RETURN_IF_EXCEPTION after JSC::isArray (which can throw on a revoked Proxy) and after getMethod (which can throw if Symbol.iterator is present but not callable, or if a Proxy trap throws). The remaining code — dynamicDowncast<JSFunction> and a pointer comparison against arrayProtoValuesFunctionConcurrently() — cannot throw, so the plain return is correct without RELEASE_AND_RETURN. The [[ZIG_EXPORT(check_slow)]] attribute is applied per the src/CLAUDE.md convention for bindings that may throw. The cross-realm handling is correct: the intrinsic is looked up on the function's own global, not the caller's, so an array from node:vm still matches.

Other factors

Test coverage is thorough for the variant matrix REVIEW.md asks for: Proxy inflating length, Proxy get trap rewriting a cell (confirms [[Get]] still honored), sparse arrays, hole-only arrays, frozen arrays (elements moved to sparse map), the properties filter over sparse input, arrays with named own properties (bare and via Proxy), an Array subclass with a custom iterator (confirms IsArray wins over the iterator check), and one end-to-end console.table spawn. Tests are appended to the existing module test file, use bunExe/bunEnv, drain subprocess pipes concurrently with Promise.all, and assert a combined {stdout, stderr, exitCode} object. The user-visible behavior changes (holes skipped, named array properties become rows, custom iterator on Array subclass ignored) all move toward Node's behavior and are explicitly enumerated in the PR description. No CODEOWNERS entries cover the changed paths. The bug hunt ran to a dry streak with no findings.

@coderabbitai

coderabbitai Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Review 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: 7f9a5d7d-d6a3-48f8-bece-6c71c7cd5b27

📥 Commits

Reviewing files that changed from the base of the PR and between 4057f64 and 27135ad.

📒 Files selected for processing (4)
  • src/jsc/ConsoleObject.rs
  • src/jsc/JSValue.rs
  • src/jsc/bindings/bindings.cpp
  • test/js/bun/console/console-table.test.ts

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


Walkthrough

Changes

The console table implementation now excludes arrays from iterator-based row traversal. It uses own enumerable properties for arrays and other non-iterable inputs. The change adds a native iterable classifier and tests for array and proxy edge cases.

Console table traversal

Layer / File(s) Summary
Array-aware iterable classification
src/jsc/bindings/bindings.cpp, src/jsc/JSValue.rs
Adds Bun__JSValue__isNonArrayIterable and the JSValue::is_non_array_iterable wrapper. The classifier excludes arrays, array proxies, and intrinsic array iterators.
Table printer traversal and coverage
src/jsc/ConsoleObject.rs, test/js/bun/console/console-table.test.ts
TablePrinter uses the new classifier and documents row collection rules. Tests cover sparse, frozen, proxied, filtered, subclassed, and custom-iterator arrays.

Suggested reviewers: jarred-sumner, dylan-conway, alii

Merge Risk: ⚪ Minimal · up to 27135

The PR makes a localized console.table row-enumeration change, with the supplied test suites passing; no actionable merge-blocking risk remains beyond normal review.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the primary change: console.table now traverses an array's own properties instead of trusting its length.
Description check ✅ Passed The description explains the problem, implementation, behavior changes, alternatives, and verification results. It does not use the exact template headings, but it provides the required change summary…
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.
Full details: Description check

Explanation

The description explains the problem, implementation, behavior changes, alternatives, and verification results. It does not use the exact template headings, but it provides the required change summary and verification details.


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

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