Skip to content

console: cap Map/Set entries at 100 in the native formatter - #35826

Closed
robobun wants to merge 6 commits into
mainfrom
claude/farm/7c2f8502/console-accessor-guards
Closed

robobun wants to merge 6 commits into
mainfrom
claude/farm/7c2f8502/console-accessor-guards

Conversation

@robobun

@robobun robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator

What

The native console formatter rendered every Map/Set entry with no truncation, so a million-entry Map produced ~17 MB of output from a single console.log / Bun.inspect call. Node caps Map/Set at maxArrayLength (default 100) and emits ... N more items, matching what Bun's array printer already does.

Repro

const m = new Map();
for (let i = 0; i < 1_000_000; i++) m.set(i, i);
Bun.inspect(m).length;
// before: 17_777_796
// after:     1_021   (ends with "... 999900 more items")

Fix

src/jsc/ConsoleObject.rs: add a MAP_SET_ENTRY_CAP = 100 check at the top of the MapIteratorCtx / SetIteratorCtx for_each callbacks. Past the cap, entries are skipped but count keeps incrementing; after for_each returns the caller emits a single ... N more item(s) marker (singularized at 1) from the final count. The cap keys on the real iterated count, so a subclass that lies about size still truncates. print_map_iterator_like stays uncapped via the existing IS_ITERATOR const (iterator size is unknown up front).

SetIteratorCtx.is_first: bool becomes count: usize so the same check works there; the one caller that read is_first now reads count > 0.

Verification

New describe("Map/Set entry count is capped") block in test/js/bun/util/inspect.test.js (7 tests): 150-entry Map/Set truncate at 100 with the right marker, 101-entry singularizes to 1 more item, exactly-100 is unchanged, single-line mode has the right separators, a 10k-entry Map's output stays under 4 KB, and a 500-entry Map subclass with get size() { return 1 } still truncates at 100.

bun bd test test/js/bun/util/inspect.test.js: 80 pass. USE_SYSTEM_BUN=1: 6 of the 7 new tests fail (the one that passes is the exactly-100 control). Existing test/js/web/console/ and test/js/bun/console/ suites pass unchanged.

Related

The Event/AggregateError throwing-accessor guards originally in this branch are in #35816 (opened first, also covers pretty_format.rs). #35288 adds the orthogonal depth cap to Map/Set/Array. #31809 wires maxArrayLength through to the array printer; once that lands MAP_SET_ENTRY_CAP can become the same configurable field.

src/runtime/test_runner/pretty_format.rs (the expect().toEqual() diff formatter) is intentionally left uncapped: its array printer does not truncate either, and truncating a diff could hide the mismatched entry. If that formatter gains an entry/length cap it should cover arrays + Map/Set together.


[review] gate passed · iteration 1 · 2 files touched

fails on main (without fix)
ASAN without fix: 6 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/util/inspect.test.js
bun test v1.4.0 (ed0d36893)

test/js/bun/util/inspect.test.js:
(pass) prototype [581.07ms]
(pass) getters [10.19ms]
(pass) setters [6.83ms]
(pass) getter/setters [4.61ms]
(pass) Timeout [10.58ms]
(pass) when prototype defines the same property, don't print the same property twice [5.62ms]
(pass) Blob inspect [17.44ms]
(pass) utf16 property name [114.25ms]
(pass) latin1 [7.55ms]
(pass) Request object [5.40ms]
(pass) MessageEvent [4.85ms]
(pass) MessageEvent with no data set [3.40ms]
(pass) MessageEvent with deleted data [5.10ms]
(pass) TypedArray prints [197.30ms]
(pass) BigIntArray [70.65ms]
(pass) Float32Array 42.68000030517578 [8.34ms]
(pass) Float32Array 42.68 [8.72ms]
(pass) Float64Array 42.68000030517578 [5.01ms]
(pass) Float64Array 42.68 [2.58ms]
(pass) jsx with two elements [69.46ms]
(pass) jsx with anon component [4.43ms]
(pass) jsx with fragment [11.65ms]
(pass) inspect [137.20ms]
(pass) latin1 supplemental > latin1 (input) "äbc" [ "äbc" ] [5.45ms]
(pass) latin1 supplemental > latin1 (input) 
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (ed0d36893)

test/js/bun/util/inspect.test.js:
(pass) prototype [49.71ms]
(pass) getters [0.26ms]
(pass) setters [0.09ms]
(pass) getter/setters [0.05ms]
(pass) Timeout [3.38ms]
(pass) when prototype defines the same property, don't print the same property twice [0.18ms]
(pass) Blob inspect [41.06ms]
(pass) utf16 property name [2.07ms]
(pass) latin1 [0.08ms]
(pass) Request object [28.22ms]
(pass) MessageEvent [0.22ms]
(pass) MessageEvent with no data set [0.08ms]
(pass) MessageEvent with deleted data [0.11ms]
(pass) TypedArray prints [34.68ms]
(pass) BigIntArray [0.98ms]
(pass) Float32Array 42.68000030517578 [0.53ms]
(pass) Float32Array 42.68 [0.10ms]
(pass) Float64Array 42.68000030517578 [0.02ms]
(pass) Float64Array 42.68 [0.01ms]
(pass) jsx with two elements [2.92ms]
(pass) jsx with anon component [0.05ms]
(pass) jsx with fragment [0.08ms]
(pass) inspect [9.87ms]
(pass) latin1 supplemental > latin1 (input) "äbc" [ "äbc" ] [0.09ms]
(pass) latin1 supplemental > latin1 (input) "cbä" [ "cbä" ]
(pass) latin1 supplemental > latin1 (input) "cäb" [ "cäb" ]
(pass) latin1 supplemental > latin1 (input) "äbc äbc" [ "äbc äbc" ]
(pass) latin1 s
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/util/inspect.test.js
bun test v1.4.0 (ed0d36893)

test/js/bun/util/inspect.test.js:
(pass) prototype [431.18ms]
(pass) getters [7.80ms]
(pass) setters [6.09ms]
(pass) getter/setters [3.18ms]
(pass) Timeout [7.69ms]
(pass) when prototype defines the same property, don't print the same property twice [3.98ms]
(pass) Blob inspect [10.50ms]
(pass) utf16 property name [69.76ms]
(pass) latin1 [4.60ms]
(pass) Request object [2.81ms]
(pass) MessageEvent [2.07ms]
(pass) MessageEvent with no data set [3.57ms]
(pass) MessageEvent with deleted data [2.92ms]
(pass) TypedArray prints [109.28ms]
(pass) BigIntArray [33.26ms]
(pass) Float32Array 42.68000030517578 [4.57ms]
(pass) Float32Array 42.68 [4.86ms]
(pass) Float64Array 42.68000030517578 [1.51ms]
(pass) Float64Array 42.68 [2.73ms]
(pass) jsx with two elements [33.22ms]
(pass) jsx with anon component [2.91ms]
(pass) jsx with fragment [5.00ms]
(pass) inspect [76.01ms]
(pass) latin1 supplemental > latin1 (input) "äbc" [ "äbc" ] [1.99ms]
(pass) latin1 supplemental > latin1 (input) "cbä
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1072ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/32] gen cpp.rs (cppbind)
[2/32] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (13 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts
  - FileSystemRouter (5 fields)
  - FrameworkFileSystemRouter (2 fields)
  - MatchedRoute (8 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Glob.classes.ts
  - Glob (5 fields)
Found 1 classes from /workspace/bun/src/runtime/api/h2.classes.ts
  - H2FrameParser (31 fields)
Found 8 classes from /workspace/bun/src/runtime/api/html_rewriter.classes.ts
  - HTMLRewriter (3 fields)
  - TextChunk (7 f
... (truncated)
diff hotspot
src/jsc/ConsoleObject.rs         | 65 +++++++++++++++++++++++++++++++++-----
 test/js/bun/util/inspect.test.js | 67 ++++++++++++++++++++++++++++++++++++++++
 2 files changed, 125 insertions(+), 7 deletions(-)

gate history · 2 passed · 0 rejected · iteration 1

evidence per changed file
file                              reads  edits  tests
src/jsc/ConsoleObject.rs             22     31      0
test/js/bun/util/inspect.test.js      4      2      0

…p Map/Set entries

The native console formatter's "never throw because of the value being
printed" law had five unguarded accessor reads and an uncapped Map/Set
entry loop.

print_event read type/message/data/error through the user-visible getter
with ?-propagation, so a subclass or own-property accessor that throws
aborted the whole console.log/Bun.inspect call. Each read now catches
the throw, clears it off the VM, and treats the value as absent, mirroring
the existing print_to_json catch-then-clear pattern.

print_errorlike_object's AggregateError branch reads .errors via getDirect,
which for an own accessor yields the raw GetterSetter cell (not an object).
Passing that to forEachInIterable either hit an assertion (debug) or threw
a TypeError that was left pending on the VM for the next print_as_prelude
to re-throw. It is now guarded by is_object() and the for_each result
clears any pending exception.

print_map_like and print_set rendered every entry; a million-entry Map
produced 17 MB of output. Both iterator callbacks now stop formatting at
100 entries and emit a "... N more item(s)" elision marker, matching the
array printer's existing cap and Node's default maxArrayLength behaviour
for Map/Set.
@coderabbitai

coderabbitai Bot commented Jul 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: 8 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: 5925a240-39d8-422e-9106-9929b2397873

📥 Commits

Reviewing files that changed from the base of the PR and between 916492f and ed0d368.

📒 Files selected for processing (2)
  • src/jsc/ConsoleObject.rs
  • test/js/bun/util/inspect.test.js

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

@robobun

robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: diff is green. Build #81800: 195 jobs passed, 1 failed. The only hard failure is :package: binary-size, which is comparing against the stale main canary #79916 (ae4b17de6d): this branch's base 916492fdc3 is 11 commits ahead of that, including the node:quic/HTTP3 (#32602), node:repl (#31827), and node:inspector (#31823) merges that account for the ~530 KB growth on every target. A 65-line formatter change does not add half a megabyte. The four [flaky]-tagged tests (multi-run, filter-workspace, no-orphans, 20144) all passed on retry and are unrelated CLI/process tests.

Gate: test fails without the fix and passes with it on both ASAN and release. Ready for a maintainer.

Reproduce on main:

bun -e 'const m = new Map(); for (let i=0;i<1e6;i++) m.set(i,i); console.log(Bun.inspect(m).length)'
# 17777796

@robobun

robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:23 PM PT - Jul 25th, 2026

❌ @robobun, your commit ed0d368 has 1 failures in Build #81800 (All Failures):

  • 📦 Binary size — 12 over 0.50 MB
  • targetthis build canary: main #79916
    sizeΔ
    ❌ bun-darwin-aarch6458.13 MB57.58 MB+564.9 KB
    ❌ bun-darwin-x6463.48 MB62.95 MB+544.5 KB
    ❌ bun-linux-aarch6470.98 MB70.42 MB+576.0 KB
    ❌ bun-linux-x6472.47 MB71.95 MB+528.0 KB
    ❌ bun-linux-aarch64-musl64.88 MB64.32 MB+576.0 KB
    ❌ bun-linux-x64-musl66.98 MB66.45 MB+544.0 KB
    ❌ bun-linux-aarch64-android78.47 MB77.97 MB+512.0 KB
    ❌ bun-linux-x64-android80.62 MB80.10 MB+529.2 KB
    ❌ bun-freebsd-x6483.07 MB82.56 MB+528.0 KB
    ❌ bun-freebsd-aarch6484.84 MB84.31 MB+544.0 KB
    ❌ bun-windows-x6480.26 MB79.70 MB+571.5 KB
    ❌ bun-windows-aarch6470.86 MB70.34 MB+534.0 KB

    Add [skip size check] to the commit message if this increase is intentional.


🧪   To try this PR locally:

bunx bun-pr 35826

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

bun-35826 --bun

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

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. console: guard throwing Event/AggregateError property reads in the native formatters #35816 - Guards throwing Event/AggregateError property reads in native formatters; directly overlaps with both the print_event throwing-accessor fix and the AggregateError .errors guarding in this PR
  2. console: guard AggregateError .errors recursion (cycle, depth, tampered property) #35825 - Guards AggregateError .errors recursion (cycle, depth, tampered property) in VirtualMachine.rs; overlaps with the AggregateError .errors is_object + clear_exception fix
  3. console: apply depth cap to Map/Set/Array entries and Error cause chains #35288 - Applies depth cap to Map/Set/Array entries in ConsoleObject.rs; overlaps with the Map/Set entry cap (MAP_SET_ENTRY_CAP) added here
  4. Guard AggregateError .errors printing against self-reference #35820 - Guards AggregateError .errors self-reference in VirtualMachine.rs; subset of the AggregateError .errors guarding in this PR
  5. Guard print_errorlike_object against unbounded AggregateError recursion #34892 - Guards deep AggregateError recursion in VirtualMachine.rs; subset of the AggregateError .errors guarding in this PR

🤖 Generated with Claude Code

Comment thread src/jsc/ConsoleObject.rs Outdated
@robobun robobun changed the title console: swallow throwing accessors in print_event/AggregateError; cap Map/Set entries console: cap Map/Set entries at 100 in the native formatter Jul 25, 2026

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/jsc/ConsoleObject.rs:2950-2957 — The cap guard this.length > MAP_SET_ENTRY_CAP && this.count >= MAP_SET_ENTRY_CAP keys off length, which is populated from the user-visible size getter (ConsoleObject.rs:4803/4950) — a subclass with get size() { return 1; } holding a million real entries makes the first conjunct false and every entry still prints. Since count is incremented for every callback regardless, the cap could be driven by count alone (with the ... N more items marker printed after the loop from the final count), gating the whole thing on !IS_ITERATOR instead of length: 0 to keep iterators uncapped. Nit — adversarial input only; the honest-large-Map case this PR targets is fixed.

    Extended reasoning...

    What the bug is

    The new Map/Set entry cap in MapIteratorCtx::for_each / SetIteratorCtx::for_each is guarded by:

    if this.length > MAP_SET_ENTRY_CAP && this.count >= MAP_SET_ENTRY_CAP {

    where length is threaded through from print_map_like / print_set:

    let length_value = value.get(self.global_this, "size")?...;
    let length = length_value.coerce_to_i32(self.global_this)?;

    value.get(global, "size") walks the prototype chain and invokes user getters. A Map subclass (or an own-property defineProperty) that reports size ≤ 100 while the map actually holds far more entries makes this.length > MAP_SET_ENTRY_CAP evaluate to false, so the cap branch is never taken and for_each continues to render every real entry.

    Step-by-step proof

    class M extends Map { get size() { return 1; } }
    const m = new M();
    for (let i = 0; i < 1_000_000; i++) m.set(i, i);
    Bun.inspect(m);
    1. print_map_like calls value.get(global, "size") → invokes M.prototype.size → returns 1.
    2. length = 1.max(0) as usize = 1 is stored on MapIteratorCtx.
    3. value.for_each(...) iterates the real backing store (JSC's forEachInIterable walks the internal HashMapImpl, not the reported size), so the callback fires 1,000,000 times.
    4. On every callback, this.length > MAP_SET_ENTRY_CAP is 1 > 100 → false, so the guard short-circuits and the entry is printed.
    5. Result: all 1,000,000 entries render — the same ~17 MB output the PR set out to bound.

    The existing regression test Set/Map with overridden size property in this same file already establishes that size is user-overridable on this exact code path, so no new mechanism is being hypothesized here.

    Why the guard is written this way

    The length > CAP conjunct isn't gratuitous: print_map_iterator_like reuses MapIteratorCtx with length: 0 precisely so that Map/Set iterators (whose size is unknown up front) stay uncapped. Dropping the conjunct outright would inadvertently cap iterators too. That's why the fix isn't just "delete the first half of the &&."

    Suggested fix

    Since the callback is invoked for every real entry regardless of what size reports, count ends up equal to the true entry count. The cap can therefore key on count alone:

    • In the callback: if !IS_ITERATOR && this.count >= MAP_SET_ENTRY_CAP { this.count += 1; return; } (Map already has the IS_ITERATOR const; Set doesn't need it since print_set is the only caller).
    • After for_each returns, if iter.count > MAP_SET_ENTRY_CAP, call print_more_items_marker with iter.count as total. This also means the ... N more items count reflects the true remaining entries even when size lies in the other direction (over-reports).

    This removes the need for the length field on both context structs entirely.

    Why this is a nit

    This only manifests with a deliberately hostile Map/Set subclass. Console output size is not a security boundary — an attacker who can construct such a subclass can already do console.log('x'.repeat(1e8)) or install a custom [Symbol.for('nodejs.util.inspect.custom')] that returns arbitrary-length strings. REVIEW.md's "assume userland is hostile" section explicitly scopes itself to security-relevant paths (rejectUnauthorized, credential merging), not display formatting. The PR's motivating case — an honest million-entry Map producing 17 MB — is correctly fixed, and the author has already noted a formatter-wide byte-budget backstop as deferred follow-up that would defeat this and every other adversarial-width vector uniformly. Not worth blocking merge; worth folding in if there's another revision.

  • 🟡 src/jsc/ConsoleObject.rs:4833 — Same-class sites left unguarded: print_map_like / print_set still ?-propagate on the .get("size")? / coerce_to_i32(...)? reads (lines 4802-4805, 4949-4952) and on the four value.for_each(...)? calls (lines 4841-4845, 4860-4864, 4988-4992, 5007-5011) — a Map/Set with a throwing size getter or a throwing Symbol.iterator/next() still aborts Bun.inspect. Since this PR already edits both functions to thread the new length field and defines get_swallowing_throw + the for_each().is_err() → clear_exception() pattern for exactly this, consider routing these sibling reads through the same helpers. Pre-existing behavior, adversarial-only — not blocking.

    Extended reasoning...

    What

    The PR's stated goal is that user-installed accessors/hooks that throw must not abort console.log / Bun.inspect. It adds two mechanisms for this: get_swallowing_throw / fast_get_swallowing_throw (used for the four print_event reads) and the for_each(...).is_err() → clear_exception() wrapper (used for AggregateError's errors iterator in VirtualMachine.rs). But the sibling Map/Set printers — which this PR is already editing to add the new length field at lines 4833 and 4980 — still leave both patterns unguarded.

    Sites

    size getter (print_map_like 4802-4805, print_set 4949-4952):

    let length_value = value
        .get(self.global_this, "size")?
        .unwrap_or_else(|| JSValue::js_number_from_int32(0));
    let length = length_value.coerce_to_i32(self.global_this)?;

    Both .get(...)? and .coerce_to_i32(...)? propagate a user exception straight out of Bun.inspect.

    Iterator drive (print_map_like 4841-4845 / 4860-4864, print_set 4988-4992 / 5007-5011):

    value.for_each(
        global_this,
        (&raw mut iter).cast::<c_void>(),
        MapIteratorCtx::<C, false, true>::for_each,
    )?;

    JSValue::for_each → JSC__JSValue__forEach (bindings.cpp:4025) → JSC::forEachInIterable, which looks up the user-visible Symbol.iterator, calls it, and drives next(). A throwing iterator surfaces as Err here and the ? aborts the whole inspect.

    Step-by-step repro

    // size getter
    const m = new Map([[1, 1]]);
    Object.defineProperty(m, "size", { get() { throw new Error("boom"); } });
    Bun.inspect(m);            // still throws "boom" after this PR
    Bun.inspect([m, "after"]); // "after" is never printed
    
    // Symbol.iterator
    const m2 = new Map([[1, 1]]);
    m2[Symbol.iterator] = () => { throw new Error("boom"); };
    Bun.inspect(m2);           // still throws after this PR
    
    // next() throws
    const s = new Set([1]);
    s[Symbol.iterator] = () => ({ next() { throw new Error("boom"); } });
    Bun.inspect(s);            // still throws after this PR

    Trace for the first case: m is a real JSMap, so Tag::get routes to print_map_like. Line 4803 calls value.get(self.global_this, "size"); the own accessor shadows Map.prototype.size, its getter throws, get returns Err, ? propagates out through format → Bun.inspect throws. The existing regression test in inspect.test.js (setWithOverriddenSize) only covers a data-property size override (value: Set), which coerce_to_i32 handles without throwing — it does not guard the throwing-getter case.

    For the iterator case: print_map_like classifies via value.js_type() (line 4811), so a real Map with an own Symbol.iterator still routes here and reaches value.for_each(...)? at line 4845. The PR's own "AggregateError with errors whose iterator throws" test proves for_each honours a user-installed Symbol.iterator and returns Err when it throws — that's exactly why VirtualMachine.rs:4850-4856 now wraps it. The four Map/Set call sites are the same shape but were left with ?.

    Why this belongs in this PR

    REVIEW.md, Correctness: "Fix the whole class in the same PR (same-class sites are ONE concern, not scope creep). Grep for every sibling site sharing the pattern." Both print_map_like and print_set are edited by this diff (the let length = length.max(0) as usize; insertions and the ctx-struct threading), the new get_swallowing_throw helper is defined 240 lines up in the same impl block, and the for_each().is_err() → clear_exception() fix pattern is demonstrated for AggregateError. These are the direct siblings of the Event and AggregateError sites the PR fixes.

    Note the two sub-issues are distinct: routing size through get_swallowing_throw alone would not fix a throwing Symbol.iterator (execution would proceed to for_each(...)? and still abort), and vice-versa.

    Fix

    • Replace the size reads with self.get_swallowing_throw(value, "size") and swallow the coerce failure (default length to 0), mirroring the Event reads.
    • Replace each value.for_each(...)?; with if value.for_each(...).is_err() { global_this.clear_exception(); }, mirroring VirtualMachine.rs:4850-4856.

    Severity

    Nit / pre-existing. This is not a regression — the ?-propagation on these lines predates the PR — and requires a deliberately hostile own-property override on a real Map/Set to trigger. The PR title explicitly scopes the accessor-swallowing to print_event/AggregateError, and merging as-is is a strict improvement. Flagging only because the author already has both fix patterns in hand and REVIEW.md's same-class rule applies to functions the diff touches; fine to defer alongside the formatter-wide byte-budget follow-up mentioned in the PR.

A Map subclass that reports size <= 100 while holding more entries defeated
the cap because length came from the user-visible size getter. The callback
now keys on count alone (incremented for every real entry) and the
'... N more items' marker is emitted after for_each from the final count,
so the remaining-items number is correct regardless of what size reports.
Iterators stay uncapped via the existing IS_ITERATOR const; the length
field and its threading are removed.
@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the first review finding in 848d562: the cap now keys on count alone (incremented for every real entry the callback sees) and the ... N more items marker is emitted after for_each from the final count, so a subclass with get size() { return 1 } holding 500 entries still truncates at 100. Added a test for that case. Iterators stay uncapped via the existing IS_ITERATOR const; the length field and its threading are gone.

The second finding (throwing size getter / throwing Symbol.iterator on Map/Set) references get_swallowing_throw and the for_each().is_err() -> clear_exception() pattern, both of which were in the pre-rescope commit and now live in #35816 instead. Leaving those sibling reads to that PR so the accessor-swallowing helper is defined in one place.

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/jsc/ConsoleObject.rs:2940-2950 — The cap gates on this.length > MAP_SET_ENTRY_CAP, but length comes from value.get(global, "size") — a normal property lookup that respects own-property overrides and subclass getters. A Map with 1M real entries and Object.defineProperty(m, 'size', {value: 1}) (or class M extends Map { get size(){return 1} }) reports length = 1, so the guard never fires and for_each still prints every entry. Since MapIteratorCtx already has an IS_ITERATOR const generic and SetIteratorCtx is only instantiated from print_set, the guard could be !IS_ITERATOR && this.count >= MAP_SET_ENTRY_CAP (Map) / this.count >= MAP_SET_ENTRY_CAP (Set) instead of overloading length — no need to trust user-visible .size at all.

    Extended reasoning...

    What the bug is

    The new truncation guard in both MapIteratorCtx::for_each and SetIteratorCtx::for_each is:

    if this.length > MAP_SET_ENTRY_CAP && this.count >= MAP_SET_ENTRY_CAP {

    this.length is populated from value.get(self.global_this, "size")?.coerce_to_i32(...) at ConsoleObject.rs:4768-4771 (Map) and 4915-4918 (Set). .get() is an ordinary JS property lookup — it walks the prototype chain and respects own-property shadows and subclass accessor overrides. It does not read JSC's internal JSMap/JSSet bucket count. Meanwhile, value.for_each() iterates the internal storage regardless of what .size reports.

    Step-by-step proof

    1. const m = new Map(); for (let i = 0; i < 1_000_000; i++) m.set(i, i);
    2. Object.defineProperty(m, 'size', { value: 1 }); — shadows Map.prototype.size with an own data property.
    3. Bun.inspect(m) → print_map reads length = m.size = 1.
    4. length.max(0) as usize → 1, stored on the ctx.
    5. for_each visits internal bucket 0…999999. On each call, this.length (1) > MAP_SET_ENTRY_CAP (100) is false, so the truncation branch is skipped.
    6. All 1,000,000 entries print — ~17 MB of output, exactly the behavior this PR set out to fix.

    The same holds for class M extends Map { get size() { return 1; } }, and symmetrically for Set. The existing regression test in inspect.test.js only exercises overridden size on an empty Map/Set (where length == 0 short-circuits to Map {}), so it doesn't cover this.

    Why the length > CAP conjunct exists

    It's doing double duty as the sentinel for the Map-iterator path: print_map_iterator_like passes length: 0 to keep MapIterator/SetIterator output uncapped (their size isn't known upfront). So dropping the conjunct outright would start truncating iterators.

    Why this is a nit, not a blocker

    • The reliance on user-visible .size is pre-existing — it's already used for the Map(N) header and the length == 0 early-return. This PR extends that reliance, it doesn't introduce it.
    • Triggering the bypass requires deliberately tampering with .size, which is self-inflicted; console.log is not a security boundary.
    • Even in the tampered case, output is no worse than the pre-PR status quo (unbounded). This PR is a strict improvement for every non-adversarial input.

    Suggested fix

    Don't overload length as the iterator sentinel — use the const generic that already exists:

    • MapIteratorCtx already carries const IS_ITERATOR: bool (print_map uses false, print_map_iterator_like uses true). Change the guard to if !IS_ITERATOR && this.count >= MAP_SET_ENTRY_CAP, and compute remaining in the marker as this.length.saturating_sub(MAP_SET_ENTRY_CAP) (which just affects the printed count, not whether truncation happens).
    • SetIteratorCtx is only ever constructed from print_set (grep confirms — 4948/4967 are the only instantiation sites), so its guard can simply be if this.count >= MAP_SET_ENTRY_CAP with no length check at all.

    This makes the cap depend solely on the actual iteration count, which for_each controls and userland cannot spoof.

Comment thread src/jsc/ConsoleObject.rs

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

No issues found — the cap logic, is_first → count refactor, and marker placement all check out, and the test coverage is thorough. Deferring to a human because this is a user-visible default-behavior change (anyone currently relying on Bun.inspect(bigMap) dumping every entry now gets truncated output), and the hardcoded 100 is a product decision worth a maintainer nod even if it matches Node and the array printer.

What was reviewed:

  • count > MAP_SET_ENTRY_CAP boundary vs count >= MAP_SET_ENTRY_CAP in the callback — off-by-one is correct (exactly 100 → no marker).
  • IS_ITERATOR gating keeps print_map_iterator_like uncapped as intended.
  • Single-line separator ordering in print_more_items_marker vs the trailing if count > 0 { " " } — no double-space or missing comma.
  • The array printer's marker (line ~4514) doesn't singularize; this one does — minor inconsistency, not a bug.
Extended reasoning...

Overview

Two files: src/jsc/ConsoleObject.rs adds a MAP_SET_ENTRY_CAP = 100 constant, an early-return in MapIteratorCtx::for_each / SetIteratorCtx::for_each once count reaches the cap (still incrementing count so the caller can compute the remainder), a new print_more_items_marker helper, and post-loop marker emission at the four Map/Set call sites. SetIteratorCtx.is_first: bool becomes count: usize with the one reader updated. test/js/bun/util/inspect.test.js gains a 7-test describe block covering truncation, the exact-100 boundary, singularization, compact-mode separators, an output-size bound, and a lying size getter.

Security risks

None. Pure output-formatting logic; no new JS re-entrancy, no allocation changes, no external input parsing. If anything this reduces a mild resource-exhaustion vector (17 MB of output from one console.log).

Level of scrutiny

Medium. The mechanics are simple and follow the existing array-truncation pattern in the same file, but this changes the default output of console.log / Bun.inspect for any Map/Set over 100 entries — a user-visible behavior change. It matches Node's maxArrayLength default and Bun's own array printer, so it's well-motivated, but a maintainer should confirm the hardcoded 100 (vs waiting for #31809's configurable maxArrayLength) is the shape they want.

Other factors

  • Prior review round already caught and fixed the size-getter bypass; the cap now keys purely on the iterated count, verified by a dedicated test.
  • The pretty_format.rs sibling exclusion is documented in the PR description with a rationale (diff formatter shouldn't hide the mismatched entry).
  • I noted the array printer's ... N more items at line ~4514 always pluralizes while this one singularizes at 1 — a cosmetic inconsistency, not worth blocking on.
  • CI build #81709 was still marked "building" in the timeline; worth confirming green before merge.

@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 size-getter bypass from the first pass is fixed (cap keys on the iterated count, marker emitted post-loop) and the pretty_format.rs sibling is now called out as an intentional exclusion in the description.

What was reviewed:

  • Boundary math: callback gates on count >= 100, caller on count > 100, so exactly-100 emits no marker and 101 emits 1 more item — matches the tests.
  • SetIteratorCtx has no IS_ITERATOR const, but that's fine: Tag::SetIterator routes through print_map_iterator_like (which uses MapIteratorCtx<_, true, _>), so real Set iterators stay uncapped and SetIteratorCtx is only reached from print_set.
  • Marker styling (<r><d>...<r>) and wording match the existing array-printer marker at ~L4514.
Extended reasoning...

Overview

Two files: src/jsc/ConsoleObject.rs adds MAP_SET_ENTRY_CAP = 100 with an early-return in the MapIteratorCtx/SetIteratorCtx for_each callbacks and a new print_more_items_marker helper called from the four Map/Set call sites (single-line × multi-line); SetIteratorCtx.is_first: bool becomes count: usize. test/js/bun/util/inspect.test.js adds a 7-test describe block covering truncation, singularization, exact-cap, single-line separators, output-size bound, and a lying-size-getter subclass.

Security risks

None. This is output-formatting only — no parsing of untrusted input, no allocation sized from external data (saturating_sub guards the marker arithmetic), no new FFI surface.

Level of scrutiny

Low-to-medium. It's a self-contained formatter change that strictly reduces output volume, mirrors what the array printer already does at ~L4506–4520, and matches Node's default behavior. The only subtle bits are the off-by-one at the cap boundary and the separator sequencing in single-line vs multi-line mode, both of which I traced against the callback bodies and both of which are pinned by exact-string test assertions (toEndWith("98, 99, ... 5 more items }"), toContain("... 1 more item,"), exactly-100 negative case).

Other factors

  • My prior review's two concerns are resolved: the cap now keys on the real iterated count (not the user-visible size getter), with a dedicated test for a subclass that lies about size; and pretty_format.rs is explicitly excluded in the PR description with a stated rationale.
  • I confirmed SetIteratorCtx doesn't need an IS_ITERATOR gate because Tag::SetIterator dispatches to print_map_iterator_like (which uses MapIteratorCtx with IS_ITERATOR=true), so SetIteratorCtx is only ever reached from print_set.
  • The comment-cop bot flags are all resolved and were on earlier-commit doc comments that no longer exist in the diff.
  • Tests were verified locally per the description (80 pass; 6/7 new tests fail under USE_SYSTEM_BUN=1).

@robobun

robobun commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator Author

#42264 includes this 100-entry limit for Map and Set, and also stops the walk at the limit. Here the callback skips the print past the limit but forEachInIterable keeps running, so a replaced Symbol.iterator that never reports done still never returns (Map.prototype[Symbol.iterator] = function* () { for (;;) yield [1, 2]; }; console.log(new Map([[1, 2]]))). If #42264 lands, this PR is covered.

robobun added a commit that referenced this pull request Sep 11, 2026
Carries over the two cases from #35826 that this branch did not cover yet.
@robobun

robobun commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #42264. It has the same 100-entry limit for Map and Set, with the same ... N more items marker: singular for one entry, and the same separators in compact mode. It also stops the underlying walk at the limit, so a replaced iterator that never finishes can no longer hang the formatter. This PR only skips the print past the limit and still walks every entry.

Five of the seven test cases here already had an equivalent in #42264. The other two (a 10,000-entry Map, and a Map subclass whose size getter undercounts) are carried over in cf82ac4. One expectation differs by design. With the undercounting getter, #42264 prints ... more items without a count, because it does not walk past the limit to count the rest.

Please reopen this PR if #42264 does not land.

@robobun robobun closed this Sep 11, 2026
robobun added a commit that referenced this pull request Sep 11, 2026
Carries over the two cases from #35826 that this branch did not cover yet.
@robobun

robobun commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator Author

#42264 no longer carries the 100-entry limit for Map and Set, so this PR is not covered by it any more.

A review of #42264 found that the limit is not needed to stop the hang there, and that it is a change of default output with no opt-out: console.dir(map, { maxArrayLength: null }) prints every entry today. #42264 now only bounds the iterator protocol. Map and Set output that ends today stays as it is.

The limit fits better here as its own change, on top of #31809 so that maxArrayLength: null or Infinity lifts it (#15193). If #42264 lands first, its for_each_limited helper closes the iterator when a walk stops early, which lets this PR stop the walk at the limit and not only skip the print. The two tests carried over to #42264 from here (the 10,000-entry Map and the size getter that undercounts) are removed there again, because they test the limit.

Reopening so that the question of the default stays visible.

@robobun robobun reopened this Sep 11, 2026

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

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-26, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

@robobun robobun closed this Sep 13, 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.

2 participants