From a8aabec6aec886108fd88eddc387d29c3a3af274 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 11 Sep 2026 02:08:35 +0000 Subject: [PATCH 1/9] console: bound the iteration of Maps, Sets and iterators in the formatter The native formatter walked a Map, Set, iterator preview, AggregateError `errors` list and console.table input with JSC::forEachInIterable, which ends only when the iterator reports done. A replaced Symbol.iterator or next() that never reports it made console.log, an uncaught error, and console.table run forever. - Read a Map or a Set from its own storage, so user code cannot redirect or extend the walk. - Stop a Map, Set or iterator preview at 100 entries and print '... N more items', the limit arrays already have. - Print at most 100 members of AggregateError.errors. - console.table reads at most 1000 rows from an iterable that has no length of its own. An array, typed array, Map or Set still prints every row. --- src/jsc/ConsoleObject.rs | 162 ++++++++++++++++++++-- src/jsc/JSValue.rs | 13 +- src/jsc/VirtualMachine.rs | 25 +++- src/jsc/bindings/ConsoleObject.cpp | 72 ++++++++++ test/js/bun/console/console-table.test.ts | 98 +++++++++++++ test/js/bun/util/inspect.test.js | 145 +++++++++++++++++++ 6 files changed, 488 insertions(+), 27 deletions(-) diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index 2dbefc463781..79a6f7ab04b2 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -559,6 +559,60 @@ fn message_with_type_and_level_( Ok(()) } +// ─────────────────────────────────────────────────────────────────────────── +// Bounded iteration +// ─────────────────────────────────────────────────────────────────────────── + +/// What [`for_each_limited`] did. +pub(crate) struct LimitedForEach { + /// The number of elements passed to the callback. + pub(crate) visited: u32, + /// True when the iterable holds an element past the limit. + pub(crate) truncated: bool, +} + +impl LimitedForEach { + /// The number of elements that were not visited, given the `size` the + /// collection reports. `size` is a property read, so it can disagree with + /// what the walk found. `None` means the count is not known. + fn hidden_of(&self, size: i32) -> Option { + u32::try_from(size) + .ok()? + .checked_sub(self.visited) + .filter(|hidden| *hidden > 0) + } +} + +/// [`JSValue::for_each`] for a formatter, which prints values it does not +/// control and so cannot wait for a user iterator to report `done`. The walk +/// stops after `limit` elements. See `Bun__ConsoleObject__forEachLimited`. +pub(crate) fn for_each_limited( + iterable: JSValue, + global: &JSGlobalObject, + limit: u32, + ctx: *mut c_void, + callback: jsc::ForEachCallback, +) -> JsResult { + unsafe extern "C" { + // safe: `JSGlobalObject` is an opaque handle, `truncated` is a live + // `&mut bool` that C++ only writes, and `ctx` is an opaque round-trip + // pointer that C++ only forwards to `callback`. + safe fn Bun__ConsoleObject__forEachLimited( + iterable: JSValue, + global: &JSGlobalObject, + limit: u32, + truncated: &mut bool, + ctx: *mut c_void, + callback: jsc::ForEachCallback, + ) -> u32; + } + let mut truncated = false; + let visited = jsc::host_fn::from_js_host_call_generic(global, || { + Bun__ConsoleObject__forEachLimited(iterable, global, limit, &mut truncated, ctx, callback) + })?; + Ok(LimitedForEach { visited, truncated }) +} + // ─────────────────────────────────────────────────────────────────────────── // TablePrinter // ─────────────────────────────────────────────────────────────────────────── @@ -646,6 +700,11 @@ struct CollectedRow { const PADDING: u32 = 1; +/// The number of rows `console.table` reads from an iterable that has no +/// length of its own, such as a generator. Chrome applies the same limit to +/// every `console.table` call (`InjectedScript::wrapTable` in V8). +const MAX_ROWS_FROM_ITERATOR: u32 = 1000; + impl<'a> TablePrinter<'a> { pub fn init( global_object: &'a JSGlobalObject, @@ -898,6 +957,7 @@ impl<'a> TablePrinter<'a> { // ranges, so no property is re-read and no value is re-formatted. let mut cell_text: Vec = Vec::new(); let mut rows: Vec = Vec::new(); + let mut rows_truncated = false; { if self.is_iterable { struct Ctx<'c, 'a> { @@ -943,14 +1003,28 @@ impl<'a> TablePrinter<'a> { } ctx.idx += 1; } - tabular_data.for_each_with_context( + // User code decides when an iterator ends. A Map or a Set is + // read from its own storage, and an array has no more rows + // than its length. Any other iterable gets a row budget. + let jstype = ctx.this.jstype; + let limit = if jstype.is_array_like() { + u32::try_from(tabular_data.get_length(global_object)?).unwrap_or(u32::MAX) + } else if jstype.is_map() || jstype.is_set() { + u32::MAX + } else { + MAX_ROWS_FROM_ITERATOR + }; + let collected = for_each_limited( + tabular_data, global_object, + limit, (&raw mut ctx).cast::(), callback::, )?; if let Some(err) = ctx.err { return Err(err); } + rows_truncated = collected.truncated; } else { let tabular_obj = self.tabular_data.to_object(global_object)?; let rows_iter = jsc::JSPropertyIterator::init( @@ -1063,6 +1137,11 @@ impl<'a> TablePrinter<'a> { let _ = writer.write_all("┘\n".as_bytes()); } + if rows_truncated { + let _ = + writer.write_all(pfmt!("... more rows\n", ENABLE_ANSI_COLORS).as_bytes()); + } + Ok(()) } } @@ -2745,8 +2824,41 @@ pub mod formatter { self.estimated_line_length += 1; Ok(()) } + + /// Writes the marker that ends a truncated Map, Set or iterator + /// preview: `... N more items`, or `... more items` when `hidden` is + /// `None` because the count is not known. `wrote_entry` tells the + /// single-line form whether it needs a separator first. + fn print_more_entries( + &mut self, + writer: &mut dyn bun_io::Write, + hidden: Option, + wrote_entry: bool, + ) { + if !self.single_line { + let _ = self.write_indent(writer); + } else if wrote_entry { + let _ = self.print_comma::(writer); + let _ = writer.write_all(b" "); + } + let (dim, reset) = (pfmt!("", C), pfmt!("", C)); + let _ = match hidden { + Some(1) => write!(writer, "{dim}... 1 more item{reset}"), + Some(n) => write!(writer, "{dim}... {n} more items{reset}"), + None => write!(writer, "{dim}... more items{reset}"), + }; + if !self.single_line { + let _ = writer.write_all(b"\n"); + } + } } + /// The number of elements a collection prints before the + /// `... N more items` marker. This is the default of node's + /// `maxArrayLength`, which node applies to arrays, Maps, Sets and iterator + /// previews alike. + const MAX_ENTRIES_SHOWN: u32 = 100; + // ─────────────────────────────────────────────────────────────────────── // MapIterator / SetIterator / PropertyIterator (forEach callback contexts) // ─────────────────────────────────────────────────────────────────────── @@ -4326,7 +4438,7 @@ pub mod formatter { } continue; } - if nonempty_count >= 100 { + if nonempty_count >= MAX_ENTRIES_SHOWN { writer.print_comma::(); writer.write_all(b"\n"); // we want the line break to be unconditional here *writer.estimated_line_length = 0; @@ -4601,8 +4713,10 @@ pub mod formatter { writer: writer_, count: 0, }; - value.for_each( + let shown = for_each_limited( + value, global_this, + MAX_ENTRIES_SHOWN, (&raw mut iter).cast::(), MapIteratorCtx::::for_each, )?; @@ -4610,7 +4724,10 @@ pub mod formatter { if iter.formatter.failed { return Ok(()); } - if count > 0 { + if shown.truncated { + self.print_more_entries::(writer_, shown.hidden_of(length), count > 0); + } + if count > 0 || shown.truncated { let _ = writer_.write_all(b" "); } } else { @@ -4619,14 +4736,19 @@ pub mod formatter { writer: writer_, count: 0, }; - value.for_each( + let shown = for_each_limited( + value, global_this, + MAX_ENTRIES_SHOWN, (&raw mut iter).cast::(), MapIteratorCtx::::for_each, )?; if iter.formatter.failed { return Ok(()); } + if shown.truncated { + self.print_more_entries::(writer_, shown.hidden_of(length), true); + } } } if !self.single_line { @@ -4660,8 +4782,10 @@ pub mod formatter { writer: writer_, count: 0, }; - value.for_each( + let shown = for_each_limited( + value, global_this, + MAX_ENTRIES_SHOWN, (&raw mut iter).cast::(), MapIteratorCtx::::for_each, )?; @@ -4669,6 +4793,9 @@ pub mod formatter { if iter.formatter.failed { return Ok(()); } + if shown.truncated { + self.print_more_entries::(writer_, None, count > 0); + } // Only the MapIterator case writes a trailing space. if count > 0 && label == "MapIterator" { let _ = writer_.write_all(b" "); @@ -4679,8 +4806,10 @@ pub mod formatter { writer: writer_, count: 0, }; - value.for_each( + let shown = for_each_limited( + value, global_this, + MAX_ENTRIES_SHOWN, (&raw mut iter).cast::(), MapIteratorCtx::::for_each, )?; @@ -4691,6 +4820,9 @@ pub mod formatter { if count > 0 { let _ = writer_.write_all(b"\n"); } + if shown.truncated { + self.print_more_entries::(writer_, None, count > 0); + } } } if !self.single_line { @@ -4743,8 +4875,10 @@ pub mod formatter { writer: writer_, is_first: true, }; - value.for_each( + let shown = for_each_limited( + value, global_this, + MAX_ENTRIES_SHOWN, (&raw mut iter).cast::(), SetIteratorCtx::::for_each, )?; @@ -4752,7 +4886,10 @@ pub mod formatter { if iter.formatter.failed { return Ok(()); } - if !is_first { + if shown.truncated { + self.print_more_entries::(writer_, shown.hidden_of(length), !is_first); + } + if !is_first || shown.truncated { let _ = writer_.write_all(b" "); } } else { @@ -4761,14 +4898,19 @@ pub mod formatter { writer: writer_, is_first: true, }; - value.for_each( + let shown = for_each_limited( + value, global_this, + MAX_ENTRIES_SHOWN, (&raw mut iter).cast::(), SetIteratorCtx::::for_each, )?; if iter.formatter.failed { return Ok(()); } + if shown.truncated { + self.print_more_entries::(writer_, shown.hidden_of(length), true); + } } } if !self.single_line { diff --git a/src/jsc/JSValue.rs b/src/jsc/JSValue.rs index e9852689e0a6..b75ed9a4debd 100644 --- a/src/jsc/JSValue.rs +++ b/src/jsc/JSValue.rs @@ -2210,7 +2210,7 @@ struct SerializedScriptValueExternal { handle: *mut c_void, } -/// Callback signature for [`JSValue::for_each`] / [`JSValue::for_each_with_context`]. +/// Callback signature for [`JSValue::for_each`]. pub type ForEachCallback = extern "C" fn(vm: *mut crate::VM, global: &JSGlobalObject, ctx: *mut c_void, next: JSValue); @@ -2443,17 +2443,6 @@ impl JSValue { JSC__JSValue__forEach(self, global, ctx, callback) }) } - /// `JSValue.forEachWithContext` — typed-ctx wrapper (callers pass - /// `*mut c_void` directly). - #[inline] - pub(crate) fn for_each_with_context( - self, - global: &JSGlobalObject, - ctx: *mut c_void, - callback: ForEachCallback, - ) -> JsResult<()> { - self.for_each(global, ctx, callback) - } /// `JSValue.forEachProperty` — enumerate own props, /// invoking `callback` per (key, value, is_symbol, is_private_symbol). /// diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 6155a2b59163..e7949b25340d 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -5439,11 +5439,26 @@ impl VirtualMachine { allow_side_effects, printed_member: false, }; - if errors - .for_each(global_ref, (&raw mut ctx).cast(), agg_iter) - .is_err() - { - global_ref.clear_exception(); + // `errors` is whatever the user put on the property, so it can be + // an iterable that never ends. + const MAX_MEMBERS_PRINTED: u32 = 100; + match crate::console_object::for_each_limited( + errors, + global_ref, + MAX_MEMBERS_PRINTED, + (&raw mut ctx).cast(), + agg_iter, + ) { + Ok(printed) if printed.truncated => { + let marker = if allow_ansi_color { + bun_core::pretty_fmt!("... more errors\n", true) + } else { + bun_core::pretty_fmt!("... more errors\n", false) + }; + let _ = writer.write_all(marker.as_bytes()); + } + Ok(_) => {} + Err(_) => global_ref.clear_exception(), } if ctx.printed_member { return; diff --git a/src/jsc/bindings/ConsoleObject.cpp b/src/jsc/bindings/ConsoleObject.cpp index 5c0a357fb120..b693fec4123e 100644 --- a/src/jsc/bindings/ConsoleObject.cpp +++ b/src/jsc/bindings/ConsoleObject.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -129,3 +130,74 @@ void ConsoleObject::profileEnd(JSC::JSGlobalObject* globalObject, const String& } } + +// A bounded `JSC::forEachInIterable` for the console formatter. That function +// ends only when the iterator reports `done`, and a replaced `Symbol.iterator` +// or `next` can withhold it forever. Here a Map or a Set is read from its own +// storage, which user code cannot redirect. Any other iterable gets at most +// `limit` protocol steps and is then closed, like `break` closes a for-of loop. +// `truncated` says whether an element past `limit` exists. Returns the number +// of elements passed to `callback`. +extern "C" uint32_t Bun__ConsoleObject__forEachLimited(JSC::EncodedJSValue encodedIterable, JSC::JSGlobalObject* globalObject, uint32_t limit, bool* truncated, void* ctx, void (*callback)(JSC::VM*, JSC::JSGlobalObject*, void* ctx, JSC::EncodedJSValue)) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + JSC::JSValue iterable = JSC::JSValue::decode(encodedIterable); + uint32_t visited = 0; + *truncated = false; + + auto visitStorageEntry = [&](JSC::VM&, JSC::JSGlobalObject*, JSC::JSValue value) -> WTF::IterationStatus { + if (visited >= limit) { + *truncated = true; + return WTF::IterationStatus::Done; + } + visited++; + callback(&vm, globalObject, ctx, JSC::JSValue::encode(value)); + return WTF::IterationStatus::Continue; + }; + + if (auto* map = dynamicDowncast(iterable)) { + JSC::JSCell* storage = map->storageOrSentinel(vm); + if (storage != vm.orderedHashTableSentinel()) { + JSC::forEachInMapStorage(vm, globalObject, storage, 0, JSC::IterationKind::Entries, visitStorageEntry); + RETURN_IF_EXCEPTION(scope, visited); + } + return visited; + } + + if (auto* set = dynamicDowncast(iterable)) { + JSC::JSCell* storage = set->storageOrSentinel(vm); + if (storage != vm.orderedHashTableSentinel()) { + JSC::forEachInSetStorage(vm, globalObject, storage, 0, visitStorageEntry); + RETURN_IF_EXCEPTION(scope, visited); + } + return visited; + } + + JSC::IterationRecord iterationRecord = JSC::iteratorForIterable(globalObject, iterable); + RETURN_IF_EXCEPTION(scope, visited); + + while (true) { + JSC::JSValue next = JSC::iteratorStep(globalObject, iterationRecord); + RETURN_IF_EXCEPTION(scope, visited); + if (next.isFalse()) + return visited; + + if (visited >= limit) { + *truncated = true; + break; + } + + JSC::JSValue nextValue = JSC::iteratorValue(globalObject, next); + RETURN_IF_EXCEPTION(scope, visited); + + visited++; + callback(&vm, globalObject, ctx, JSC::JSValue::encode(nextValue)); + if (scope.exception()) [[unlikely]] + break; + } + + scope.release(); + JSC::iteratorClose(globalObject, iterationRecord.iterator); + return visited; +} diff --git a/test/js/bun/console/console-table.test.ts b/test/js/bun/console/console-table.test.ts index 32fee910127d..844fd152a0c6 100644 --- a/test/js/bun/console/console-table.test.ts +++ b/test/js/bun/console/console-table.test.ts @@ -363,3 +363,101 @@ console.log("calls=" + calls);`, expect({ stdout, stderr, exitCode }).toEqual({ stdout: box("1") + "calls=1\n", stderr: "", exitCode: 0 }); }); }); + +// Rows that come from an iterator end when user code says so. A Map or a Set +// is read from its own storage, an array has no more rows than its length, and +// any other iterable gets a budget of 1000 rows. +describe("console.table reads a bounded number of rows from an iterator", () => { + function* rows(count: number) { + for (let i = 0; i < count; i++) yield { n: i }; + } + const lastLines = (text: string, count: number) => text.split("\n").slice(-count); + + test("a generator past the budget is cut and marked", () => { + expect(lastLines(Bun.inspect.table(rows(1001)), 4)).toEqual([ + "│ 999 │ 999 │", + "└─────┴─────┘", + "... more rows", + "", + ]); + }); + + test("a generator that ends at the budget is not marked", () => { + expect(lastLines(Bun.inspect.table(rows(1000)), 3)).toEqual(["│ 999 │ 999 │", "└─────┴─────┘", ""]); + }); + + test("an array, a typed array, a Map and a Set print every row", () => { + const dataRows = (table: string) => table.split("\n").length - 5; + expect({ + array: dataRows(Bun.inspect.table(Array.from(rows(1500)))), + typedArray: dataRows(Bun.inspect.table(new Uint8Array(1500))), + map: dataRows(Bun.inspect.table(new Map(Array.from({ length: 1500 }, (_, i) => [i, i])))), + set: dataRows(Bun.inspect.table(new Set(Array.from({ length: 1500 }, (_, i) => i)))), + }).toEqual({ array: 1500, typedArray: 1500, map: 1500, set: 1500 }); + }); + + // Each child counts what its iterator hands out and exits with 2 well past + // the budget, so the unfixed printer fails here at once and does not run + // until memory is gone. + const endless = `let yielded = 0; +function* endless() { + for (;;) { + if (++yielded > 5000) process.exit(2); + yield { t: 1 }; + } +}`; + + async function run(source: string) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", `${endless}\n${source}\nconsole.log("yielded=" + yielded);`], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { lines: stdout.split("\n"), stderr, exitCode }; + } + + test.concurrent("an iterator that never ends", async () => { + const { lines, stderr, exitCode } = await run(`console.table({ host: "db1", [Symbol.iterator]: endless });`); + expect(stderr).toBe(""); + // One read past the budget tells a cut table from one that ends there. + expect(lines.slice(-5)).toEqual(["│ 999 │ 1 │", "└─────┴───┘", "... more rows", "yielded=1001", ""]); + expect(exitCode).toBe(0); + }); + + test.concurrent("an array whose iterator was replaced", async () => { + const { lines, stderr, exitCode } = await run( + `Array.prototype[Symbol.iterator] = endless;\nconsole.table([{ a: 1 }, { a: 2 }]);`, + ); + expect(stderr).toBe(""); + expect(lines).toEqual([ + "┌───┬───┐", + "│ │ t │", + "├───┼───┤", + "│ 0 │ 1 │", + "│ 1 │ 1 │", + "└───┴───┘", + "... more rows", + "yielded=3", + "", + ]); + expect(exitCode).toBe(0); + }); + + test.concurrent("a Map whose iterator was replaced", async () => { + const { lines, stderr, exitCode } = await run( + `Map.prototype[Symbol.iterator] = endless;\nconsole.table(new Map([["a", 1]]));`, + ); + expect(stderr).toBe(""); + expect(lines).toEqual([ + "┌───┬─────┬────────┐", + "│ │ Key │ Values │", + "├───┼─────┼────────┤", + "│ 0 │ a │ 1 │", + "└───┴─────┴────────┘", + "yielded=0", + "", + ]); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/js/bun/util/inspect.test.js b/test/js/bun/util/inspect.test.js index a1a05dbfa3ca..2ff6a21442d8 100644 --- a/test/js/bun/util/inspect.test.js +++ b/test/js/bun/util/inspect.test.js @@ -1082,3 +1082,148 @@ it("object property enumeration scales linearly with property count", () => { expect(l.ms / 30000 / (s.ms / 3000)).toBeLessThan(3); }); }); + +describe("collection previews stop at the display limit", () => { + const entries = length => Array.from({ length }, (_, i) => [i, i]); + const values = length => Array.from({ length }, (_, i) => i); + const lastLines = (text, count) => text.split("\n").slice(-count); + + it("a Map prints 100 entries and the remainder count", () => { + expect(lastLines(Bun.inspect(new Map(entries(150))), 3)).toEqual([" 99: 99,", " ... 50 more items", "}"]); + }); + + it("a Set prints 100 values and the remainder count", () => { + expect(lastLines(Bun.inspect(new Set(values(150))), 3)).toEqual([" 99,", " ... 50 more items", "}"]); + }); + + it("one hidden entry reads as a single item", () => { + expect(lastLines(Bun.inspect(new Map(entries(101))), 2)).toEqual([" ... 1 more item", "}"]); + expect(lastLines(Bun.inspect(new Set(values(101))), 2)).toEqual([" ... 1 more item", "}"]); + }); + + it("an iterator preview says it is cut short", () => { + expect(lastLines(Bun.inspect(new Map(entries(150)).entries()), 3)).toEqual([ + " [ 99, 99 ],", + " ... more items", + "}", + ]); + expect(lastLines(Bun.inspect(new Set(values(150)).values()), 3)).toEqual([" 99,", " ... more items", "}"]); + }); + + it("compact mode keeps the marker on one line", () => { + expect(Bun.inspect(new Map(entries(150)), { compact: true })).toEndWith(" 99: 99, ... 50 more items }"); + expect(Bun.inspect(new Set(values(150)), { compact: true })).toEndWith(" 99, ... 50 more items }"); + expect(Bun.inspect(new Map(entries(150)).entries(), { compact: true })).toEndWith(" [ 99, 99 ], ... more items }"); + expect(Bun.inspect(new Set(values(150)).values(), { compact: true })).toEndWith(" 99, ... more items}"); + }); + + it("a collection that fits prints in full", () => { + expect(Bun.inspect(new Map(entries(100)))).toEndWith(" 99: 99,\n}"); + expect(Bun.inspect(new Set(values(100)))).toEndWith(" 99,\n}"); + expect(Bun.inspect(new Map(entries(100)).entries())).toEndWith(" [ 99, 99 ],\n}"); + }); +}); + +// The formatter used to drive `Symbol.iterator`, so user code decided when the +// walk ended. An iterator that never reports `done` printed forever at 100% +// CPU. A Map or a Set is now read from its own storage, and every other +// iterable is cut at the display limit. +describe.concurrent("inspect survives an iterator that never ends", () => { + // The unfixed formatter prints without end. Stop the read at `limit` bytes + // and kill the child, so that failure reports `runaway` at once. + async function run(source, stream) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", source], + env: bunEnv, + stdout: stream === "stdout" ? "pipe" : "ignore", + stderr: stream === "stderr" ? "pipe" : "ignore", + }); + + const limit = 1024 * 1024; + const decoder = new TextDecoder(); + let output = ""; + let runaway = false; + for await (const chunk of proc[stream]) { + output += decoder.decode(chunk, { stream: true }); + if (output.length > limit) { + runaway = true; + // Keep the assertion diff readable. + output = output.slice(0, 256); + proc.kill(); + break; + } + } + return { output, runaway, exitCode: await proc.exited }; + } + + const count = (text, needle) => text.split(needle).length - 1; + + it("Map.prototype[Symbol.iterator]", async () => { + const { output, runaway, exitCode } = await run( + `Map.prototype[Symbol.iterator] = function* () { for (;;) yield ["k", "v"]; }; + console.log(new Map([["a", 1]]));`, + "stdout", + ); + expect({ output, runaway }).toEqual({ output: 'Map(1) {\n "a": 1,\n}\n', runaway: false }); + expect(exitCode).toBe(0); + }); + + it("Set.prototype[Symbol.iterator]", async () => { + const { output, runaway, exitCode } = await run( + `Set.prototype[Symbol.iterator] = function* () { for (;;) yield "x"; }; + console.log(new Set(["a"]));`, + "stdout", + ); + expect({ output, runaway }).toEqual({ output: 'Set(1) {\n "a",\n}\n', runaway: false }); + expect(exitCode).toBe(0); + }); + + it("a replaced next() on the Map iterator prototype", async () => { + const { output, runaway, exitCode } = await run( + `const map = new Map([["a", 1]]); + Object.getPrototypeOf(map.entries()).next = () => ({ value: ["k", "v"], done: false }); + console.log(map.entries());`, + "stdout", + ); + expect({ entries: count(output, '"k"'), runaway }).toEqual({ entries: 100, runaway: false }); + expect(output).toEndWith(" ... more items\n}\n"); + expect(exitCode).toBe(0); + }); + + it("a replaced next() on the Set iterator prototype", async () => { + const { output, runaway, exitCode } = await run( + `const set = new Set(["a"]); + Object.getPrototypeOf(set.values()).next = () => ({ value: "k", done: false }); + console.log(set.values());`, + "stdout", + ); + expect({ values: count(output, '"k"'), runaway }).toEqual({ values: 100, runaway: false }); + expect(output).toEndWith(" ... more items\n}\n"); + expect(exitCode).toBe(0); + }); + + it("an uncaught error that carries such a Map", async () => { + const { output, runaway, exitCode } = await run( + `Map.prototype[Symbol.iterator] = function* () { for (;;) yield ["k", "v"]; }; + const err = new Error("boom"); + err.ctx = new Map([["a", 1]]); + throw err;`, + "stderr", + ); + expect(runaway).toBe(false); + expect(output).toContain('ctx: Map(1) {\n "a": 1,\n}'); + expect(exitCode).toBe(1); + }); + + it("an uncaught AggregateError whose errors list never ends", async () => { + const { output, runaway, exitCode } = await run( + `const err = new AggregateError([], "boom"); + err.errors = { [Symbol.iterator]: function* () { for (;;) yield new Error("inner"); } }; + throw err;`, + "stderr", + ); + expect({ members: count(output, "error: inner"), runaway }).toEqual({ members: 100, runaway: false }); + expect(output).toContain("... more errors\n"); + expect(exitCode).toBe(1); + }); +}); From 10c7152079ffc2b5f43c0bb2d5da384bd081a13e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 11 Sep 2026 03:05:18 +0000 Subject: [PATCH 2/9] console.table: budget the rows of a WeakMap, WeakSet or ArrayBuffer JSType::is_map() and is_set() also match the weak collections, and is_array_like() matches ArrayBuffer. Only user code can make those iterable, so nothing but the row budget bounds that walk. Take the element count as the limit only for an array, a typed array, a Map or a Set. --- src/jsc/ConsoleObject.rs | 13 +++++++------ test/js/bun/console/console-table.test.ts | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index 79a6f7ab04b2..e752a1fc8c61 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -1003,14 +1003,15 @@ impl<'a> TablePrinter<'a> { } ctx.idx += 1; } - // User code decides when an iterator ends. A Map or a Set is - // read from its own storage, and an array has no more rows - // than its length. Any other iterable gets a row budget. + // User code decides when an iterator ends. An array, a typed + // array, a Map or a Set has no more rows than it has elements. + // Any other iterable gets a row budget. That includes a WeakMap + // or an ArrayBuffer, which only user code can make iterable. let jstype = ctx.this.jstype; - let limit = if jstype.is_array_like() { + let has_element_count = matches!(jstype, jsc::JSType::Map | jsc::JSType::Set) + || (jstype.is_array_like() && jstype != jsc::JSType::ArrayBuffer); + let limit = if has_element_count { u32::try_from(tabular_data.get_length(global_object)?).unwrap_or(u32::MAX) - } else if jstype.is_map() || jstype.is_set() { - u32::MAX } else { MAX_ROWS_FROM_ITERATOR }; diff --git a/test/js/bun/console/console-table.test.ts b/test/js/bun/console/console-table.test.ts index 844fd152a0c6..3e437f78bf0e 100644 --- a/test/js/bun/console/console-table.test.ts +++ b/test/js/bun/console/console-table.test.ts @@ -425,6 +425,22 @@ function* endless() { expect(exitCode).toBe(0); }); + // Only user code can make these iterable, so only the budget bounds the walk. + test.concurrent.each([ + ["a WeakMap", `WeakMap.prototype[Symbol.iterator] = endless;\nconsole.table(new WeakMap());`], + ["a WeakSet", `WeakSet.prototype[Symbol.iterator] = endless;\nconsole.table(new WeakSet());`], + ["an ArrayBuffer", `ArrayBuffer.prototype[Symbol.iterator] = endless;\nconsole.table(new ArrayBuffer(1 << 20));`], + ])("%s that user code made iterable", async (_, source) => { + const { lines, stderr, exitCode } = await run(source); + expect(stderr).toBe(""); + // Three header lines, the rows, the bottom border, then the three lines below. + expect({ rows: lines.length - 7, tail: lines.slice(-3) }).toEqual({ + rows: 1000, + tail: ["... more rows", "yielded=1001", ""], + }); + expect(exitCode).toBe(0); + }); + test.concurrent("an array whose iterator was replaced", async () => { const { lines, stderr, exitCode } = await run( `Array.prototype[Symbol.iterator] = endless;\nconsole.table([{ a: 1 }, { a: 2 }]);`, From 7b1c11538d589bff3457f490ec86d7e2620db640 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 11 Sep 2026 03:09:22 +0000 Subject: [PATCH 3/9] console: shorten the comments on the bounded walk --- src/jsc/ConsoleObject.rs | 35 ++++++------------------------ src/jsc/VirtualMachine.rs | 3 +-- src/jsc/bindings/ConsoleObject.cpp | 8 +------ 3 files changed, 9 insertions(+), 37 deletions(-) diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index e752a1fc8c61..c9b0a7bde90c 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -559,10 +559,6 @@ fn message_with_type_and_level_( Ok(()) } -// ─────────────────────────────────────────────────────────────────────────── -// Bounded iteration -// ─────────────────────────────────────────────────────────────────────────── - /// What [`for_each_limited`] did. pub(crate) struct LimitedForEach { /// The number of elements passed to the callback. @@ -572,9 +568,7 @@ pub(crate) struct LimitedForEach { } impl LimitedForEach { - /// The number of elements that were not visited, given the `size` the - /// collection reports. `size` is a property read, so it can disagree with - /// what the walk found. `None` means the count is not known. + /// Elements left out, going by the `size` property. `None` when `size` does not say. fn hidden_of(&self, size: i32) -> Option { u32::try_from(size) .ok()? @@ -583,9 +577,7 @@ impl LimitedForEach { } } -/// [`JSValue::for_each`] for a formatter, which prints values it does not -/// control and so cannot wait for a user iterator to report `done`. The walk -/// stops after `limit` elements. See `Bun__ConsoleObject__forEachLimited`. +/// A bounded [`JSValue::for_each`] for formatters. `Bun__ConsoleObject__forEachLimited` has the rules. pub(crate) fn for_each_limited( iterable: JSValue, global: &JSGlobalObject, @@ -594,9 +586,7 @@ pub(crate) fn for_each_limited( callback: jsc::ForEachCallback, ) -> JsResult { unsafe extern "C" { - // safe: `JSGlobalObject` is an opaque handle, `truncated` is a live - // `&mut bool` that C++ only writes, and `ctx` is an opaque round-trip - // pointer that C++ only forwards to `callback`. + // safe: C++ only writes `truncated`, and only forwards `ctx` to `callback`. safe fn Bun__ConsoleObject__forEachLimited( iterable: JSValue, global: &JSGlobalObject, @@ -700,9 +690,7 @@ struct CollectedRow { const PADDING: u32 = 1; -/// The number of rows `console.table` reads from an iterable that has no -/// length of its own, such as a generator. Chrome applies the same limit to -/// every `console.table` call (`InjectedScript::wrapTable` in V8). +/// Row budget for an iterable with no element count. Chrome's `console.table` limit (V8 `InjectedScript::wrapTable`). const MAX_ROWS_FROM_ITERATOR: u32 = 1000; impl<'a> TablePrinter<'a> { @@ -1003,10 +991,7 @@ impl<'a> TablePrinter<'a> { } ctx.idx += 1; } - // User code decides when an iterator ends. An array, a typed - // array, a Map or a Set has no more rows than it has elements. - // Any other iterable gets a row budget. That includes a WeakMap - // or an ArrayBuffer, which only user code can make iterable. + // Only user code can make a WeakMap or an ArrayBuffer iterable, so they get the budget too. let jstype = ctx.this.jstype; let has_element_count = matches!(jstype, jsc::JSType::Map | jsc::JSType::Set) || (jstype.is_array_like() && jstype != jsc::JSType::ArrayBuffer); @@ -2826,10 +2811,7 @@ pub mod formatter { Ok(()) } - /// Writes the marker that ends a truncated Map, Set or iterator - /// preview: `... N more items`, or `... more items` when `hidden` is - /// `None` because the count is not known. `wrote_entry` tells the - /// single-line form whether it needs a separator first. + /// Ends a truncated preview with `... N more items`, or `... more items` when `hidden` is `None`. fn print_more_entries( &mut self, writer: &mut dyn bun_io::Write, @@ -2854,10 +2836,7 @@ pub mod formatter { } } - /// The number of elements a collection prints before the - /// `... N more items` marker. This is the default of node's - /// `maxArrayLength`, which node applies to arrays, Maps, Sets and iterator - /// previews alike. + /// Entries printed before `... N more items`. The default of node's `maxArrayLength`. const MAX_ENTRIES_SHOWN: u32 = 100; // ─────────────────────────────────────────────────────────────────────── diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index e7949b25340d..4289d6e9d61e 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -5439,8 +5439,7 @@ impl VirtualMachine { allow_side_effects, printed_member: false, }; - // `errors` is whatever the user put on the property, so it can be - // an iterable that never ends. + // `errors` is user-assigned, so its iterator can be endless. const MAX_MEMBERS_PRINTED: u32 = 100; match crate::console_object::for_each_limited( errors, diff --git a/src/jsc/bindings/ConsoleObject.cpp b/src/jsc/bindings/ConsoleObject.cpp index b693fec4123e..196ba3db599b 100644 --- a/src/jsc/bindings/ConsoleObject.cpp +++ b/src/jsc/bindings/ConsoleObject.cpp @@ -131,13 +131,7 @@ void ConsoleObject::profileEnd(JSC::JSGlobalObject* globalObject, const String& } -// A bounded `JSC::forEachInIterable` for the console formatter. That function -// ends only when the iterator reports `done`, and a replaced `Symbol.iterator` -// or `next` can withhold it forever. Here a Map or a Set is read from its own -// storage, which user code cannot redirect. Any other iterable gets at most -// `limit` protocol steps and is then closed, like `break` closes a for-of loop. -// `truncated` says whether an element past `limit` exists. Returns the number -// of elements passed to `callback`. +// Bounded `JSC::forEachInIterable`: a Map or a Set is read from its own storage, anything else gets `limit` iterator steps and is then closed. extern "C" uint32_t Bun__ConsoleObject__forEachLimited(JSC::EncodedJSValue encodedIterable, JSC::JSGlobalObject* globalObject, uint32_t limit, bool* truncated, void* ctx, void (*callback)(JSC::VM*, JSC::JSGlobalObject*, void* ctx, JSC::EncodedJSValue)) { auto& vm = JSC::getVM(globalObject); From b72e404af3c8786e2a71b171fa5f6b42cdd032c5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 11 Sep 2026 07:02:29 +0000 Subject: [PATCH 4/9] test: cover a 10,000-entry Map and a size getter that undercounts Carries over the two cases from #35826 that this branch did not cover yet. --- test/js/bun/util/inspect.test.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/js/bun/util/inspect.test.js b/test/js/bun/util/inspect.test.js index 2ff6a21442d8..ac2e8a49ba91 100644 --- a/test/js/bun/util/inspect.test.js +++ b/test/js/bun/util/inspect.test.js @@ -1122,6 +1122,25 @@ describe("collection previews stop at the display limit", () => { expect(Bun.inspect(new Set(values(100)))).toEndWith(" 99,\n}"); expect(Bun.inspect(new Map(entries(100)).entries())).toEndWith(" [ 99, 99 ],\n}"); }); + + it("the preview of a large Map stays small", () => { + const lines = Bun.inspect(new Map(entries(10_000))).split("\n"); + // The header, 100 entries, the marker and the closing brace. + expect(lines.length).toBe(103); + expect(lines.slice(-3)).toEqual([" 99: 99,", " ... 9900 more items", "}"]); + }); + + it("the limit counts stored entries, not what a size getter reports", () => { + class Undercount extends Map { + get size() { + return 1; + } + } + const lines = Bun.inspect(new Undercount(entries(500))).split("\n"); + expect(lines.length).toBe(103); + // `size` is below the number of entries printed, so the marker has no count. + expect(lines.slice(-3)).toEqual([" 99: 99,", " ... more items", "}"]); + }); }); // The formatter used to drive `Symbol.iterator`, so user code decided when the From 9958a1fa8e73c79021b3f192b9641a1283fa8795 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:26:23 +0000 Subject: [PATCH 5/9] console: keep Map and Set output as it was, bound only the iterator protocol Read a Map, a Set or an array from its own storage only when user code cannot observe the iteration, as JSC::forEachInIterable does. In every other case run the iterator protocol with a bound: the element count the collection reports, or a step budget when it has none. A Map subclass that keeps its entries elsewhere prints as before. The 100-entry display limit for Map and Set is out of this change. It is a default-output change and belongs with maxArrayLength support. --- src/jsc/ConsoleObject.rs | 124 ++++++++++------------------- src/jsc/VirtualMachine.rs | 8 +- src/jsc/bindings/ConsoleObject.cpp | 84 +++++++++++-------- 3 files changed, 99 insertions(+), 117 deletions(-) diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index c9b0a7bde90c..2d13d4275de4 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -559,48 +559,30 @@ fn message_with_type_and_level_( Ok(()) } -/// What [`for_each_limited`] did. -pub(crate) struct LimitedForEach { - /// The number of elements passed to the callback. - pub(crate) visited: u32, - /// True when the iterable holds an element past the limit. - pub(crate) truncated: bool, -} +/// Steps the formatters give an iterable that has no element count of its own, such as a generator. +const UNSIZED_ITERABLE_BUDGET: u32 = 1000; -impl LimitedForEach { - /// Elements left out, going by the `size` property. `None` when `size` does not say. - fn hidden_of(&self, size: i32) -> Option { - u32::try_from(size) - .ok()? - .checked_sub(self.visited) - .filter(|hidden| *hidden > 0) - } -} - -/// A bounded [`JSValue::for_each`] for formatters. `Bun__ConsoleObject__forEachLimited` has the rules. +/// A bounded [`JSValue::for_each`] for formatters. True when elements were left. `Bun__ConsoleObject__forEachLimited` has the rules. pub(crate) fn for_each_limited( iterable: JSValue, global: &JSGlobalObject, - limit: u32, + budget: u32, ctx: *mut c_void, callback: jsc::ForEachCallback, -) -> JsResult { +) -> JsResult { unsafe extern "C" { - // safe: C++ only writes `truncated`, and only forwards `ctx` to `callback`. + // safe: `ctx` is an opaque round-trip pointer that C++ only forwards to `callback`. safe fn Bun__ConsoleObject__forEachLimited( iterable: JSValue, global: &JSGlobalObject, - limit: u32, - truncated: &mut bool, + budget: u32, ctx: *mut c_void, callback: jsc::ForEachCallback, - ) -> u32; + ) -> bool; } - let mut truncated = false; - let visited = jsc::host_fn::from_js_host_call_generic(global, || { - Bun__ConsoleObject__forEachLimited(iterable, global, limit, &mut truncated, ctx, callback) - })?; - Ok(LimitedForEach { visited, truncated }) + jsc::host_fn::from_js_host_call_generic(global, || { + Bun__ConsoleObject__forEachLimited(iterable, global, budget, ctx, callback) + }) } // ─────────────────────────────────────────────────────────────────────────── @@ -690,9 +672,6 @@ struct CollectedRow { const PADDING: u32 = 1; -/// Row budget for an iterable with no element count. Chrome's `console.table` limit (V8 `InjectedScript::wrapTable`). -const MAX_ROWS_FROM_ITERATOR: u32 = 1000; - impl<'a> TablePrinter<'a> { pub fn init( global_object: &'a JSGlobalObject, @@ -991,26 +970,16 @@ impl<'a> TablePrinter<'a> { } ctx.idx += 1; } - // Only user code can make a WeakMap or an ArrayBuffer iterable, so they get the budget too. - let jstype = ctx.this.jstype; - let has_element_count = matches!(jstype, jsc::JSType::Map | jsc::JSType::Set) - || (jstype.is_array_like() && jstype != jsc::JSType::ArrayBuffer); - let limit = if has_element_count { - u32::try_from(tabular_data.get_length(global_object)?).unwrap_or(u32::MAX) - } else { - MAX_ROWS_FROM_ITERATOR - }; - let collected = for_each_limited( + rows_truncated = for_each_limited( tabular_data, global_object, - limit, + UNSIZED_ITERABLE_BUDGET, (&raw mut ctx).cast::(), callback::, )?; if let Some(err) = ctx.err { return Err(err); } - rows_truncated = collected.truncated; } else { let tabular_obj = self.tabular_data.to_object(global_object)?; let rows_iter = jsc::JSPropertyIterator::init( @@ -2811,11 +2780,10 @@ pub mod formatter { Ok(()) } - /// Ends a truncated preview with `... N more items`, or `... more items` when `hidden` is `None`. + /// Ends a preview whose iterator had more to give than the walk allowed. fn print_more_entries( &mut self, writer: &mut dyn bun_io::Write, - hidden: Option, wrote_entry: bool, ) { if !self.single_line { @@ -2824,21 +2792,13 @@ pub mod formatter { let _ = self.print_comma::(writer); let _ = writer.write_all(b" "); } - let (dim, reset) = (pfmt!("", C), pfmt!("", C)); - let _ = match hidden { - Some(1) => write!(writer, "{dim}... 1 more item{reset}"), - Some(n) => write!(writer, "{dim}... {n} more items{reset}"), - None => write!(writer, "{dim}... more items{reset}"), - }; + let _ = writer.write_all(pfmt!("... more items", C).as_bytes()); if !self.single_line { let _ = writer.write_all(b"\n"); } } } - /// Entries printed before `... N more items`. The default of node's `maxArrayLength`. - const MAX_ENTRIES_SHOWN: u32 = 100; - // ─────────────────────────────────────────────────────────────────────── // MapIterator / SetIterator / PropertyIterator (forEach callback contexts) // ─────────────────────────────────────────────────────────────────────── @@ -4418,7 +4378,7 @@ pub mod formatter { } continue; } - if nonempty_count >= MAX_ENTRIES_SHOWN { + if nonempty_count >= 100 { writer.print_comma::(); writer.write_all(b"\n"); // we want the line break to be unconditional here *writer.estimated_line_length = 0; @@ -4693,10 +4653,10 @@ pub mod formatter { writer: writer_, count: 0, }; - let shown = for_each_limited( + let truncated = for_each_limited( value, global_this, - MAX_ENTRIES_SHOWN, + UNSIZED_ITERABLE_BUDGET, (&raw mut iter).cast::(), MapIteratorCtx::::for_each, )?; @@ -4704,10 +4664,10 @@ pub mod formatter { if iter.formatter.failed { return Ok(()); } - if shown.truncated { - self.print_more_entries::(writer_, shown.hidden_of(length), count > 0); + if truncated { + self.print_more_entries::(writer_, count > 0); } - if count > 0 || shown.truncated { + if count > 0 || truncated { let _ = writer_.write_all(b" "); } } else { @@ -4716,18 +4676,18 @@ pub mod formatter { writer: writer_, count: 0, }; - let shown = for_each_limited( + let truncated = for_each_limited( value, global_this, - MAX_ENTRIES_SHOWN, + UNSIZED_ITERABLE_BUDGET, (&raw mut iter).cast::(), MapIteratorCtx::::for_each, )?; if iter.formatter.failed { return Ok(()); } - if shown.truncated { - self.print_more_entries::(writer_, shown.hidden_of(length), true); + if truncated { + self.print_more_entries::(writer_, true); } } } @@ -4762,10 +4722,10 @@ pub mod formatter { writer: writer_, count: 0, }; - let shown = for_each_limited( + let truncated = for_each_limited( value, global_this, - MAX_ENTRIES_SHOWN, + UNSIZED_ITERABLE_BUDGET, (&raw mut iter).cast::(), MapIteratorCtx::::for_each, )?; @@ -4773,8 +4733,8 @@ pub mod formatter { if iter.formatter.failed { return Ok(()); } - if shown.truncated { - self.print_more_entries::(writer_, None, count > 0); + if truncated { + self.print_more_entries::(writer_, count > 0); } // Only the MapIterator case writes a trailing space. if count > 0 && label == "MapIterator" { @@ -4786,10 +4746,10 @@ pub mod formatter { writer: writer_, count: 0, }; - let shown = for_each_limited( + let truncated = for_each_limited( value, global_this, - MAX_ENTRIES_SHOWN, + UNSIZED_ITERABLE_BUDGET, (&raw mut iter).cast::(), MapIteratorCtx::::for_each, )?; @@ -4800,8 +4760,8 @@ pub mod formatter { if count > 0 { let _ = writer_.write_all(b"\n"); } - if shown.truncated { - self.print_more_entries::(writer_, None, count > 0); + if truncated { + self.print_more_entries::(writer_, count > 0); } } } @@ -4855,10 +4815,10 @@ pub mod formatter { writer: writer_, is_first: true, }; - let shown = for_each_limited( + let truncated = for_each_limited( value, global_this, - MAX_ENTRIES_SHOWN, + UNSIZED_ITERABLE_BUDGET, (&raw mut iter).cast::(), SetIteratorCtx::::for_each, )?; @@ -4866,10 +4826,10 @@ pub mod formatter { if iter.formatter.failed { return Ok(()); } - if shown.truncated { - self.print_more_entries::(writer_, shown.hidden_of(length), !is_first); + if truncated { + self.print_more_entries::(writer_, !is_first); } - if !is_first || shown.truncated { + if !is_first || truncated { let _ = writer_.write_all(b" "); } } else { @@ -4878,18 +4838,18 @@ pub mod formatter { writer: writer_, is_first: true, }; - let shown = for_each_limited( + let truncated = for_each_limited( value, global_this, - MAX_ENTRIES_SHOWN, + UNSIZED_ITERABLE_BUDGET, (&raw mut iter).cast::(), SetIteratorCtx::::for_each, )?; if iter.formatter.failed { return Ok(()); } - if shown.truncated { - self.print_more_entries::(writer_, shown.hidden_of(length), true); + if truncated { + self.print_more_entries::(writer_, true); } } } diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 4289d6e9d61e..0097bbfb56b4 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -5440,15 +5440,15 @@ impl VirtualMachine { printed_member: false, }; // `errors` is user-assigned, so its iterator can be endless. - const MAX_MEMBERS_PRINTED: u32 = 100; + const UNSIZED_ERRORS_BUDGET: u32 = 100; match crate::console_object::for_each_limited( errors, global_ref, - MAX_MEMBERS_PRINTED, + UNSIZED_ERRORS_BUDGET, (&raw mut ctx).cast(), agg_iter, ) { - Ok(printed) if printed.truncated => { + Ok(true) => { let marker = if allow_ansi_color { bun_core::pretty_fmt!("... more errors\n", true) } else { @@ -5456,7 +5456,7 @@ impl VirtualMachine { }; let _ = writer.write_all(marker.as_bytes()); } - Ok(_) => {} + Ok(false) => {} Err(_) => global_ref.clear_exception(), } if ctx.printed_member { diff --git a/src/jsc/bindings/ConsoleObject.cpp b/src/jsc/bindings/ConsoleObject.cpp index 196ba3db599b..a20ecb8e0dd4 100644 --- a/src/jsc/bindings/ConsoleObject.cpp +++ b/src/jsc/bindings/ConsoleObject.cpp @@ -131,61 +131,83 @@ void ConsoleObject::profileEnd(JSC::JSGlobalObject* globalObject, const String& } -// Bounded `JSC::forEachInIterable`: a Map or a Set is read from its own storage, anything else gets `limit` iterator steps and is then closed. -extern "C" uint32_t Bun__ConsoleObject__forEachLimited(JSC::EncodedJSValue encodedIterable, JSC::JSGlobalObject* globalObject, uint32_t limit, bool* truncated, void* ctx, void (*callback)(JSC::VM*, JSC::JSGlobalObject*, void* ctx, JSC::EncodedJSValue)) +// `JSC::forEachInIterable` with the iterator protocol bounded: by the collection's element count when it has one, else by `budget`. True when elements were left. +extern "C" bool Bun__ConsoleObject__forEachLimited(JSC::EncodedJSValue encodedIterable, JSC::JSGlobalObject* globalObject, uint32_t budget, void* ctx, void (*callback)(JSC::VM*, JSC::JSGlobalObject*, void* ctx, JSC::EncodedJSValue)) { auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); JSC::JSValue iterable = JSC::JSValue::decode(encodedIterable); - uint32_t visited = 0; - *truncated = false; + JSC::JSCell* sentinel = vm.orderedHashTableSentinel(); - auto visitStorageEntry = [&](JSC::VM&, JSC::JSGlobalObject*, JSC::JSValue value) -> WTF::IterationStatus { - if (visited >= limit) { - *truncated = true; - return WTF::IterationStatus::Done; - } - visited++; + auto visit = [&](JSC::VM&, JSC::JSGlobalObject*, JSC::JSValue value) { callback(&vm, globalObject, ctx, JSC::JSValue::encode(value)); - return WTF::IterationStatus::Continue; }; - if (auto* map = dynamicDowncast(iterable)) { + // The walks `forEachInIterable` takes when user code cannot observe the iteration. The collection itself bounds them. + if (JSC::getIterationMode(iterable) == JSC::IterationMode::FastArray) { + JSC::forEachInFastArray(globalObject, iterable, uncheckedDowncast(iterable), visit); + RETURN_IF_EXCEPTION(scope, false); + return false; + } + auto* map = dynamicDowncast(iterable); + if (map && map->isIteratorProtocolFastAndNonObservable()) { JSC::JSCell* storage = map->storageOrSentinel(vm); - if (storage != vm.orderedHashTableSentinel()) { - JSC::forEachInMapStorage(vm, globalObject, storage, 0, JSC::IterationKind::Entries, visitStorageEntry); - RETURN_IF_EXCEPTION(scope, visited); + if (storage != sentinel) { + JSC::forEachInMapStorage(vm, globalObject, storage, 0, JSC::IterationKind::Entries, visit); + RETURN_IF_EXCEPTION(scope, false); } - return visited; + return false; } - - if (auto* set = dynamicDowncast(iterable)) { + auto* set = dynamicDowncast(iterable); + if (set && set->isIteratorProtocolFastAndNonObservable()) { JSC::JSCell* storage = set->storageOrSentinel(vm); - if (storage != vm.orderedHashTableSentinel()) { - JSC::forEachInSetStorage(vm, globalObject, storage, 0, visitStorageEntry); - RETURN_IF_EXCEPTION(scope, visited); + if (storage != sentinel) { + JSC::forEachInSetStorage(vm, globalObject, storage, 0, visit); + RETURN_IF_EXCEPTION(scope, false); } - return visited; + return false; + } + + uint64_t bound = budget; + if (map || set) { + // Node's rule for a replaced iterator: it gets as many steps as the collection reports entries. + JSC::JSObject* collection = map ? static_cast(map) : set; + JSC::JSValue sizeValue = collection->get(globalObject, vm.propertyNames->size); + RETURN_IF_EXCEPTION(scope, false); + double size = sizeValue.toNumber(globalObject); + RETURN_IF_EXCEPTION(scope, false); + if (size >= 0) + bound = size < static_cast(UINT32_MAX) ? static_cast(size) : UINT32_MAX; + } else if (auto* array = dynamicDowncast(iterable)) + bound = array->length(); + else if (auto* view = dynamicDowncast(iterable)) + bound = view->length(); + else if (auto* mapIterator = dynamicDowncast(iterable)) { + if (auto* iterated = dynamicDowncast(mapIterator->internalField(JSC::JSMapIterator::Field::IteratedObject).get())) + bound = iterated->size(); + } else if (auto* setIterator = dynamicDowncast(iterable)) { + if (auto* iterated = dynamicDowncast(setIterator->internalField(JSC::JSSetIterator::Field::IteratedObject).get())) + bound = iterated->size(); } JSC::IterationRecord iterationRecord = JSC::iteratorForIterable(globalObject, iterable); - RETURN_IF_EXCEPTION(scope, visited); + RETURN_IF_EXCEPTION(scope, false); - while (true) { + bool truncated = false; + for (uint64_t visited = 0;; visited++) { JSC::JSValue next = JSC::iteratorStep(globalObject, iterationRecord); - RETURN_IF_EXCEPTION(scope, visited); + RETURN_IF_EXCEPTION(scope, false); if (next.isFalse()) - return visited; + return false; - if (visited >= limit) { - *truncated = true; + if (visited >= bound) { + truncated = true; break; } JSC::JSValue nextValue = JSC::iteratorValue(globalObject, next); - RETURN_IF_EXCEPTION(scope, visited); + RETURN_IF_EXCEPTION(scope, false); - visited++; callback(&vm, globalObject, ctx, JSC::JSValue::encode(nextValue)); if (scope.exception()) [[unlikely]] break; @@ -193,5 +215,5 @@ extern "C" uint32_t Bun__ConsoleObject__forEachLimited(JSC::EncodedJSValue encod scope.release(); JSC::iteratorClose(globalObject, iterationRecord.iterator); - return visited; + return truncated; } From 2529bc77302633e9dfa058bbb5a704003576180c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:57:43 +0000 Subject: [PATCH 6/9] test: cover the bounded iterator protocol without the display limit Drop the tests for the 100-entry Map and Set limit, which is no longer part of this change. Add controls that pin unchanged output: a 150-entry Map, Set and iterator, a Map subclass that keeps its entries elsewhere, and an AggregateError with 150 members. --- test/js/bun/console/console-table.test.ts | 16 ++- test/js/bun/util/inspect.test.js | 153 ++++++++++++++-------- 2 files changed, 107 insertions(+), 62 deletions(-) diff --git a/test/js/bun/console/console-table.test.ts b/test/js/bun/console/console-table.test.ts index 3e437f78bf0e..4fb919d34b21 100644 --- a/test/js/bun/console/console-table.test.ts +++ b/test/js/bun/console/console-table.test.ts @@ -364,9 +364,9 @@ console.log("calls=" + calls);`, }); }); -// Rows that come from an iterator end when user code says so. A Map or a Set -// is read from its own storage, an array has no more rows than its length, and -// any other iterable gets a budget of 1000 rows. +// Rows that come from an iterator end when user code says so. A collection +// gives a replaced iterator as many steps as it reports elements. Any other +// iterable gets a budget of 1000 rows. describe("console.table reads a bounded number of rows from an iterator", () => { function* rows(count: number) { for (let i = 0; i < count; i++) yield { n: i }; @@ -462,16 +462,20 @@ function* endless() { test.concurrent("a Map whose iterator was replaced", async () => { const { lines, stderr, exitCode } = await run( - `Map.prototype[Symbol.iterator] = endless;\nconsole.table(new Map([["a", 1]]));`, + `Map.prototype[Symbol.iterator] = function* () { for (const row of endless()) yield ["k", row.t]; }; +console.table(new Map([["a", 1], ["b", 2]]));`, ); expect(stderr).toBe(""); + // Two entries, so two steps, then one more to learn that the iterator had more. expect(lines).toEqual([ "┌───┬─────┬────────┐", "│ │ Key │ Values │", "├───┼─────┼────────┤", - "│ 0 │ a │ 1 │", + "│ 0 │ k │ 1 │", + "│ 1 │ k │ 1 │", "└───┴─────┴────────┘", - "yielded=0", + "... more rows", + "yielded=3", "", ]); expect(exitCode).toBe(0); diff --git a/test/js/bun/util/inspect.test.js b/test/js/bun/util/inspect.test.js index ac2e8a49ba91..96c8cfb59046 100644 --- a/test/js/bun/util/inspect.test.js +++ b/test/js/bun/util/inspect.test.js @@ -1083,70 +1083,94 @@ it("object property enumeration scales linearly with property count", () => { }); }); -describe("collection previews stop at the display limit", () => { +// The formatter drives `Symbol.iterator` for a Map, a Set and their iterators. +// Once user code replaces it, user code decides when the walk ends, and an +// iterator that never reports `done` printed forever at 100% CPU. The walk now +// stops at the number of entries the collection reports, as in node. +describe("inspect bounds a replaced Map or Set iterator", () => { const entries = length => Array.from({ length }, (_, i) => [i, i]); const values = length => Array.from({ length }, (_, i) => i); - const lastLines = (text, count) => text.split("\n").slice(-count); - it("a Map prints 100 entries and the remainder count", () => { - expect(lastLines(Bun.inspect(new Map(entries(150))), 3)).toEqual([" 99: 99,", " ... 50 more items", "}"]); + it("a collection that user code did not touch prints every entry", () => { + expect(Bun.inspect(new Map(entries(150)))).toEndWith(" 148: 148,\n 149: 149,\n}"); + expect(Bun.inspect(new Set(values(150)))).toEndWith(" 148,\n 149,\n}"); + expect(Bun.inspect(new Map(entries(150)).keys())).toEndWith(" 148,\n 149,\n}"); + expect(Bun.inspect(new Set(values(150)).values())).toEndWith(" 148,\n 149,\n}"); }); - it("a Set prints 100 values and the remainder count", () => { - expect(lastLines(Bun.inspect(new Set(values(150))), 3)).toEqual([" 99,", " ... 50 more items", "}"]); - }); - - it("one hidden entry reads as a single item", () => { - expect(lastLines(Bun.inspect(new Map(entries(101))), 2)).toEqual([" ... 1 more item", "}"]); - expect(lastLines(Bun.inspect(new Set(values(101))), 2)).toEqual([" ... 1 more item", "}"]); - }); - - it("an iterator preview says it is cut short", () => { - expect(lastLines(Bun.inspect(new Map(entries(150)).entries()), 3)).toEqual([ - " [ 99, 99 ],", - " ... more items", - "}", - ]); - expect(lastLines(Bun.inspect(new Set(values(150)).values()), 3)).toEqual([" 99,", " ... more items", "}"]); - }); - - it("compact mode keeps the marker on one line", () => { - expect(Bun.inspect(new Map(entries(150)), { compact: true })).toEndWith(" 99: 99, ... 50 more items }"); - expect(Bun.inspect(new Set(values(150)), { compact: true })).toEndWith(" 99, ... 50 more items }"); - expect(Bun.inspect(new Map(entries(150)).entries(), { compact: true })).toEndWith(" [ 99, 99 ], ... more items }"); - expect(Bun.inspect(new Set(values(150)).values(), { compact: true })).toEndWith(" 99, ... more items}"); - }); + // quick-lru is a Map subclass of this shape: it never calls `super.set`, so + // the inherited storage stays empty. + class Elsewhere extends Map { + #items = [ + ["a", 1], + ["b", 2], + ]; + get size() { + return this.#items.length; + } + *[Symbol.iterator]() { + yield* this.#items; + } + } - it("a collection that fits prints in full", () => { - expect(Bun.inspect(new Map(entries(100)))).toEndWith(" 99: 99,\n}"); - expect(Bun.inspect(new Set(values(100)))).toEndWith(" 99,\n}"); - expect(Bun.inspect(new Map(entries(100)).entries())).toEndWith(" [ 99, 99 ],\n}"); + it("a Map subclass that keeps its entries elsewhere prints them", () => { + expect(Bun.inspect(new Elsewhere())).toBe('Map(2) {\n "a": 1,\n "b": 2,\n}'); + expect(Bun.inspect(new Elsewhere(), { compact: true })).toBe('Map(2) { "a": 1, "b": 2 }'); + expect(Bun.inspect.table(new Elsewhere())).toBe( + [ + "┌───┬─────┬────────┐", + "│ │ Key │ Values │", + "├───┼─────┼────────┤", + "│ 0 │ a │ 1 │", + "│ 1 │ b │ 2 │", + "└───┴─────┴────────┘", + "", + ].join("\n"), + ); }); - it("the preview of a large Map stays small", () => { - const lines = Bun.inspect(new Map(entries(10_000))).split("\n"); - // The header, 100 entries, the marker and the closing brace. - expect(lines.length).toBe(103); - expect(lines.slice(-3)).toEqual([" 99: 99,", " ... 9900 more items", "}"]); + it("an iterator that yields more than `size` is cut there and marked", () => { + class Overflow extends Map { + get size() { + return 2; + } + *[Symbol.iterator]() { + for (let i = 0; i < 5; i++) yield [i, i]; + } + } + class OverflowSet extends Set { + get size() { + return 2; + } + *[Symbol.iterator]() { + for (let i = 0; i < 5; i++) yield i; + } + } + expect(Bun.inspect(new Overflow())).toBe("Map(2) {\n 0: 0,\n 1: 1,\n ... more items\n}"); + expect(Bun.inspect(new Overflow(), { compact: true })).toBe("Map(2) { 0: 0, 1: 1, ... more items }"); + expect(Bun.inspect(new OverflowSet())).toBe("Set(2) {\n 0,\n 1,\n ... more items\n}"); + expect(Bun.inspect(new OverflowSet(), { compact: true })).toBe("Set(2) { 0, 1, ... more items }"); }); - it("the limit counts stored entries, not what a size getter reports", () => { - class Undercount extends Map { + it("closes the iterator it cuts short", () => { + let returned = 0; + class Overflow extends Map { get size() { return 1; } + [Symbol.iterator]() { + let i = 0; + return { + next: () => (i < 5 ? { value: [i, i++], done: false } : { value: undefined, done: true }), + return: () => (returned++, {}), + }; + } } - const lines = Bun.inspect(new Undercount(entries(500))).split("\n"); - expect(lines.length).toBe(103); - // `size` is below the number of entries printed, so the marker has no count. - expect(lines.slice(-3)).toEqual([" 99: 99,", " ... more items", "}"]); + expect(Bun.inspect(new Overflow())).toBe("Map(1) {\n 0: 0,\n ... more items\n}"); + expect(returned).toBe(1); }); }); -// The formatter used to drive `Symbol.iterator`, so user code decided when the -// walk ended. An iterator that never reports `done` printed forever at 100% -// CPU. A Map or a Set is now read from its own storage, and every other -// iterable is cut at the display limit. describe.concurrent("inspect survives an iterator that never ends", () => { // The unfixed formatter prints without end. Stop the read at `limit` bytes // and kill the child, so that failure reports `runaway` at once. @@ -1180,43 +1204,46 @@ describe.concurrent("inspect survives an iterator that never ends", () => { it("Map.prototype[Symbol.iterator]", async () => { const { output, runaway, exitCode } = await run( `Map.prototype[Symbol.iterator] = function* () { for (;;) yield ["k", "v"]; }; - console.log(new Map([["a", 1]]));`, + console.log(new Map([["a", 1], ["b", 2]]));`, "stdout", ); - expect({ output, runaway }).toEqual({ output: 'Map(1) {\n "a": 1,\n}\n', runaway: false }); + expect({ output, runaway }).toEqual({ + output: 'Map(2) {\n "k": "v",\n "k": "v",\n ... more items\n}\n', + runaway: false, + }); expect(exitCode).toBe(0); }); it("Set.prototype[Symbol.iterator]", async () => { const { output, runaway, exitCode } = await run( `Set.prototype[Symbol.iterator] = function* () { for (;;) yield "x"; }; - console.log(new Set(["a"]));`, + console.log(new Set(["a", "b"]));`, "stdout", ); - expect({ output, runaway }).toEqual({ output: 'Set(1) {\n "a",\n}\n', runaway: false }); + expect({ output, runaway }).toEqual({ output: 'Set(2) {\n "x",\n "x",\n ... more items\n}\n', runaway: false }); expect(exitCode).toBe(0); }); it("a replaced next() on the Map iterator prototype", async () => { const { output, runaway, exitCode } = await run( - `const map = new Map([["a", 1]]); + `const map = new Map([["a", 1], ["b", 2]]); Object.getPrototypeOf(map.entries()).next = () => ({ value: ["k", "v"], done: false }); console.log(map.entries());`, "stdout", ); - expect({ entries: count(output, '"k"'), runaway }).toEqual({ entries: 100, runaway: false }); + expect({ entries: count(output, '"k"'), runaway }).toEqual({ entries: 2, runaway: false }); expect(output).toEndWith(" ... more items\n}\n"); expect(exitCode).toBe(0); }); it("a replaced next() on the Set iterator prototype", async () => { const { output, runaway, exitCode } = await run( - `const set = new Set(["a"]); + `const set = new Set(["a", "b"]); Object.getPrototypeOf(set.values()).next = () => ({ value: "k", done: false }); console.log(set.values());`, "stdout", ); - expect({ values: count(output, '"k"'), runaway }).toEqual({ values: 100, runaway: false }); + expect({ values: count(output, '"k"'), runaway }).toEqual({ values: 2, runaway: false }); expect(output).toEndWith(" ... more items\n}\n"); expect(exitCode).toBe(0); }); @@ -1230,7 +1257,7 @@ describe.concurrent("inspect survives an iterator that never ends", () => { "stderr", ); expect(runaway).toBe(false); - expect(output).toContain('ctx: Map(1) {\n "a": 1,\n}'); + expect(output).toContain('ctx: Map(1) {\n "k": "v",\n ... more items\n}'); expect(exitCode).toBe(1); }); @@ -1245,4 +1272,18 @@ describe.concurrent("inspect survives an iterator that never ends", () => { expect(output).toContain("... more errors\n"); expect(exitCode).toBe(1); }); + + it("an AggregateError whose errors is a long array prints every member", async () => { + const { output, runaway, exitCode } = await run( + `console.error(new AggregateError(Array.from({ length: 150 }, (_, i) => "member" + i + ";"), "boom"));`, + "stderr", + ); + expect({ + first: output.includes("member0;"), + last: output.includes("member149;"), + marker: output.includes("... more errors"), + runaway, + }).toEqual({ first: true, last: true, marker: false, runaway: false }); + expect(exitCode).toBe(0); + }); }); From 47d38aaafc08fdbd3cd72c6a70a08bd63115cd4e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:22:57 +0000 Subject: [PATCH 7/9] console: trust a reported size only up to what the storage holds Once user code drives the iteration of a Map or a Set, its size property is a claim. A subclass that reports 1e18 entries and never stops yielding still ran for up to 2^32 steps. Trust the claim up to the larger of the stored entry count and the step budget. A subclass with thousands of entries in the inherited storage still prints all of them. --- src/jsc/bindings/ConsoleObject.cpp | 4 +++- test/js/bun/util/inspect.test.js | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/jsc/bindings/ConsoleObject.cpp b/src/jsc/bindings/ConsoleObject.cpp index a20ecb8e0dd4..cafa174ed783 100644 --- a/src/jsc/bindings/ConsoleObject.cpp +++ b/src/jsc/bindings/ConsoleObject.cpp @@ -176,8 +176,10 @@ extern "C" bool Bun__ConsoleObject__forEachLimited(JSC::EncodedJSValue encodedIt RETURN_IF_EXCEPTION(scope, false); double size = sizeValue.toNumber(globalObject); RETURN_IF_EXCEPTION(scope, false); + // `size` is a user-controlled claim once it exceeds what the storage holds, so past that only the budget applies. + uint64_t trusted = std::max(map ? map->size() : set->size(), budget); if (size >= 0) - bound = size < static_cast(UINT32_MAX) ? static_cast(size) : UINT32_MAX; + bound = size < static_cast(trusted) ? static_cast(size) : trusted; } else if (auto* array = dynamicDowncast(iterable)) bound = array->length(); else if (auto* view = dynamicDowncast(iterable)) diff --git a/test/js/bun/util/inspect.test.js b/test/js/bun/util/inspect.test.js index 96c8cfb59046..46d1d58ee18b 100644 --- a/test/js/bun/util/inspect.test.js +++ b/test/js/bun/util/inspect.test.js @@ -1098,6 +1098,13 @@ describe("inspect bounds a replaced Map or Set iterator", () => { expect(Bun.inspect(new Set(values(150)).values())).toEndWith(" 148,\n 149,\n}"); }); + it("a subclass with more stored entries than the budget prints every entry", () => { + class Collection extends Map {} + class Bag extends Set {} + expect(Bun.inspect(new Collection(entries(1500)))).toEndWith(" 1498: 1498,\n 1499: 1499,\n}"); + expect(Bun.inspect(new Bag(values(1500)))).toEndWith(" 1498,\n 1499,\n}"); + }); + // quick-lru is a Map subclass of this shape: it never calls `super.set`, so // the inherited storage stays empty. class Elsewhere extends Map { @@ -1248,6 +1255,23 @@ describe.concurrent("inspect survives an iterator that never ends", () => { expect(exitCode).toBe(0); }); + it("a Map subclass that reports more entries than it can hold", async () => { + const { output, runaway, exitCode } = await run( + `let yielded = 0; + class Lazy extends Map { + get size() { return 1e18; } + *[Symbol.iterator]() { for (;;) { if (++yielded > 5000) process.exit(2); yield [yielded, yielded]; } } + } + console.log(new Lazy()); + console.log("yielded=" + yielded);`, + "stdout", + ); + expect(runaway).toBe(false); + // The storage is empty, so the claim is trusted up to the budget of 1000 steps. + expect(output).toEndWith(" 1000: 1000,\n ... more items\n}\nyielded=1001\n"); + expect(exitCode).toBe(0); + }); + it("an uncaught error that carries such a Map", async () => { const { output, runaway, exitCode } = await run( `Map.prototype[Symbol.iterator] = function* () { for (;;) yield ["k", "v"]; }; From a10ab78fe1c5b048725b124be95ea1d29a534b1f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:47:28 +0000 Subject: [PATCH 8/9] console: take a finite size at its word and read it once A Map subclass that keeps 1500 entries outside the inherited storage reports an honest size, and its output must not change. So a finite size bounds the walk again, as in node. A size that is not a finite count (Infinity, NaN, negative) says nothing about where the walk ends, and the step budget applies to it. print_map_like and print_set already read size for the header. Pass that value down so the getter runs once. --- src/jsc/ConsoleObject.rs | 19 ++++++++++++- src/jsc/VirtualMachine.rs | 1 + src/jsc/bindings/ConsoleObject.cpp | 24 ++++++++-------- test/js/bun/util/inspect.test.js | 45 ++++++++++++++++++++++++++---- 4 files changed, 72 insertions(+), 17 deletions(-) diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index 2d13d4275de4..edb310a92485 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -567,6 +567,7 @@ pub(crate) fn for_each_limited( iterable: JSValue, global: &JSGlobalObject, budget: u32, + size_already_read: Option, ctx: *mut c_void, callback: jsc::ForEachCallback, ) -> JsResult { @@ -576,12 +577,21 @@ pub(crate) fn for_each_limited( iterable: JSValue, global: &JSGlobalObject, budget: u32, + size_already_read: i32, ctx: *mut c_void, callback: jsc::ForEachCallback, ) -> bool; } + let size_already_read = size_already_read.filter(|size| *size >= 0).unwrap_or(-1); jsc::host_fn::from_js_host_call_generic(global, || { - Bun__ConsoleObject__forEachLimited(iterable, global, budget, ctx, callback) + Bun__ConsoleObject__forEachLimited( + iterable, + global, + budget, + size_already_read, + ctx, + callback, + ) }) } @@ -974,6 +984,7 @@ impl<'a> TablePrinter<'a> { tabular_data, global_object, UNSIZED_ITERABLE_BUDGET, + None, (&raw mut ctx).cast::(), callback::, )?; @@ -4657,6 +4668,7 @@ pub mod formatter { value, global_this, UNSIZED_ITERABLE_BUDGET, + Some(length), (&raw mut iter).cast::(), MapIteratorCtx::::for_each, )?; @@ -4680,6 +4692,7 @@ pub mod formatter { value, global_this, UNSIZED_ITERABLE_BUDGET, + Some(length), (&raw mut iter).cast::(), MapIteratorCtx::::for_each, )?; @@ -4726,6 +4739,7 @@ pub mod formatter { value, global_this, UNSIZED_ITERABLE_BUDGET, + None, (&raw mut iter).cast::(), MapIteratorCtx::::for_each, )?; @@ -4750,6 +4764,7 @@ pub mod formatter { value, global_this, UNSIZED_ITERABLE_BUDGET, + None, (&raw mut iter).cast::(), MapIteratorCtx::::for_each, )?; @@ -4819,6 +4834,7 @@ pub mod formatter { value, global_this, UNSIZED_ITERABLE_BUDGET, + Some(length), (&raw mut iter).cast::(), SetIteratorCtx::::for_each, )?; @@ -4842,6 +4858,7 @@ pub mod formatter { value, global_this, UNSIZED_ITERABLE_BUDGET, + Some(length), (&raw mut iter).cast::(), SetIteratorCtx::::for_each, )?; diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 0097bbfb56b4..3c6ed18a34e3 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -5445,6 +5445,7 @@ impl VirtualMachine { errors, global_ref, UNSIZED_ERRORS_BUDGET, + None, (&raw mut ctx).cast(), agg_iter, ) { diff --git a/src/jsc/bindings/ConsoleObject.cpp b/src/jsc/bindings/ConsoleObject.cpp index cafa174ed783..f70c134e61f6 100644 --- a/src/jsc/bindings/ConsoleObject.cpp +++ b/src/jsc/bindings/ConsoleObject.cpp @@ -131,8 +131,8 @@ void ConsoleObject::profileEnd(JSC::JSGlobalObject* globalObject, const String& } -// `JSC::forEachInIterable` with the iterator protocol bounded: by the collection's element count when it has one, else by `budget`. True when elements were left. -extern "C" bool Bun__ConsoleObject__forEachLimited(JSC::EncodedJSValue encodedIterable, JSC::JSGlobalObject* globalObject, uint32_t budget, void* ctx, void (*callback)(JSC::VM*, JSC::JSGlobalObject*, void* ctx, JSC::EncodedJSValue)) +// `JSC::forEachInIterable` with the iterator protocol bounded: by the collection's element count when it has one, else by `budget`. `sizeAlreadyRead` is a Map or Set `size` the caller read, or -1. True when elements were left. +extern "C" bool Bun__ConsoleObject__forEachLimited(JSC::EncodedJSValue encodedIterable, JSC::JSGlobalObject* globalObject, uint32_t budget, int32_t sizeAlreadyRead, void* ctx, void (*callback)(JSC::VM*, JSC::JSGlobalObject*, void* ctx, JSC::EncodedJSValue)) { auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); @@ -171,15 +171,17 @@ extern "C" bool Bun__ConsoleObject__forEachLimited(JSC::EncodedJSValue encodedIt uint64_t bound = budget; if (map || set) { // Node's rule for a replaced iterator: it gets as many steps as the collection reports entries. - JSC::JSObject* collection = map ? static_cast(map) : set; - JSC::JSValue sizeValue = collection->get(globalObject, vm.propertyNames->size); - RETURN_IF_EXCEPTION(scope, false); - double size = sizeValue.toNumber(globalObject); - RETURN_IF_EXCEPTION(scope, false); - // `size` is a user-controlled claim once it exceeds what the storage holds, so past that only the budget applies. - uint64_t trusted = std::max(map ? map->size() : set->size(), budget); - if (size >= 0) - bound = size < static_cast(trusted) ? static_cast(size) : trusted; + double size = sizeAlreadyRead; + if (sizeAlreadyRead < 0) { + JSC::JSObject* collection = map ? static_cast(map) : set; + JSC::JSValue sizeValue = collection->get(globalObject, vm.propertyNames->size); + RETURN_IF_EXCEPTION(scope, false); + size = sizeValue.toNumber(globalObject); + RETURN_IF_EXCEPTION(scope, false); + } + // A size that is not a finite count says nothing about where the walk ends, so the budget stays. + if (std::isfinite(size) && size >= 0) + bound = size < static_cast(UINT32_MAX) ? static_cast(size) : UINT32_MAX; } else if (auto* array = dynamicDowncast(iterable)) bound = array->length(); else if (auto* view = dynamicDowncast(iterable)) diff --git a/test/js/bun/util/inspect.test.js b/test/js/bun/util/inspect.test.js index 46d1d58ee18b..d5d89ae87f90 100644 --- a/test/js/bun/util/inspect.test.js +++ b/test/js/bun/util/inspect.test.js @@ -1136,6 +1136,35 @@ describe("inspect bounds a replaced Map or Set iterator", () => { ); }); + it("such a subclass prints every entry past the budget for unsized iterables", () => { + class Cache extends Map { + #items = entries(1500); + get size() { + return this.#items.length; + } + *[Symbol.iterator]() { + yield* this.#items; + } + } + expect(Bun.inspect(new Cache())).toEndWith(" 1498: 1498,\n 1499: 1499,\n}"); + expect(Bun.inspect.table(new Cache())).toEndWith("│ 1499 │ 1499 │ 1499 │\n└──────┴──────┴────────┘\n"); + }); + + it("reads `size` once", () => { + let reads = 0; + class Counted extends Map { + get size() { + reads++; + return 1; + } + *[Symbol.iterator]() { + yield ["a", 1]; + } + } + expect(Bun.inspect(new Counted())).toBe('Map(1) {\n "a": 1,\n}'); + expect(reads).toBe(1); + }); + it("an iterator that yields more than `size` is cut there and marked", () => { class Overflow extends Map { get size() { @@ -1255,20 +1284,26 @@ describe.concurrent("inspect survives an iterator that never ends", () => { expect(exitCode).toBe(0); }); - it("a Map subclass that reports more entries than it can hold", async () => { + it("a Map subclass whose size is not a finite count", async () => { const { output, runaway, exitCode } = await run( `let yielded = 0; class Lazy extends Map { - get size() { return 1e18; } + get size() { return Infinity; } *[Symbol.iterator]() { for (;;) { if (++yielded > 5000) process.exit(2); yield [yielded, yielded]; } } } - console.log(new Lazy()); + console.table(new Lazy()); console.log("yielded=" + yielded);`, "stdout", ); expect(runaway).toBe(false); - // The storage is empty, so the claim is trusted up to the budget of 1000 steps. - expect(output).toEndWith(" 1000: 1000,\n ... more items\n}\nyielded=1001\n"); + // `Infinity` says nothing about where the walk ends, so the budget of 1000 rows applies. + expect(output.split("\n").slice(-5)).toEqual([ + "│ 999 │ 1000 │ 1000 │", + "└─────┴──────┴────────┘", + "... more rows", + "yielded=1001", + "", + ]); expect(exitCode).toBe(0); }); From b2ea8fdbd13596a71cffd0e37b4615a920be97bf Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:17:16 +0000 Subject: [PATCH 9/9] ci: retrigger