diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index 2dbefc463781..edb310a92485 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -559,6 +559,42 @@ fn message_with_type_and_level_( Ok(()) } +/// Steps the formatters give an iterable that has no element count of its own, such as a generator. +const UNSIZED_ITERABLE_BUDGET: u32 = 1000; + +/// 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, + budget: u32, + size_already_read: Option, + ctx: *mut c_void, + callback: jsc::ForEachCallback, +) -> JsResult { + unsafe extern "C" { + // safe: `ctx` is an opaque round-trip pointer that C++ only forwards to `callback`. + safe fn Bun__ConsoleObject__forEachLimited( + 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, + size_already_read, + ctx, + callback, + ) + }) +} + // ─────────────────────────────────────────────────────────────────────────── // TablePrinter // ─────────────────────────────────────────────────────────────────────────── @@ -898,6 +934,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,8 +980,11 @@ impl<'a> TablePrinter<'a> { } ctx.idx += 1; } - tabular_data.for_each_with_context( + rows_truncated = for_each_limited( + tabular_data, global_object, + UNSIZED_ITERABLE_BUDGET, + None, (&raw mut ctx).cast::(), callback::, )?; @@ -1063,6 +1103,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,6 +2790,24 @@ pub mod formatter { self.estimated_line_length += 1; Ok(()) } + + /// 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, + 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 _ = writer.write_all(pfmt!("... more items", C).as_bytes()); + if !self.single_line { + let _ = writer.write_all(b"\n"); + } + } } // ─────────────────────────────────────────────────────────────────────── @@ -4601,8 +4664,11 @@ pub mod formatter { writer: writer_, count: 0, }; - value.for_each( + let truncated = for_each_limited( + value, global_this, + UNSIZED_ITERABLE_BUDGET, + Some(length), (&raw mut iter).cast::(), MapIteratorCtx::::for_each, )?; @@ -4610,7 +4676,10 @@ pub mod formatter { if iter.formatter.failed { return Ok(()); } - if count > 0 { + if truncated { + self.print_more_entries::(writer_, count > 0); + } + if count > 0 || truncated { let _ = writer_.write_all(b" "); } } else { @@ -4619,14 +4688,20 @@ pub mod formatter { writer: writer_, count: 0, }; - value.for_each( + let truncated = for_each_limited( + value, global_this, + UNSIZED_ITERABLE_BUDGET, + Some(length), (&raw mut iter).cast::(), MapIteratorCtx::::for_each, )?; if iter.formatter.failed { return Ok(()); } + if truncated { + self.print_more_entries::(writer_, true); + } } } if !self.single_line { @@ -4660,8 +4735,11 @@ pub mod formatter { writer: writer_, count: 0, }; - value.for_each( + let truncated = for_each_limited( + value, global_this, + UNSIZED_ITERABLE_BUDGET, + None, (&raw mut iter).cast::(), MapIteratorCtx::::for_each, )?; @@ -4669,6 +4747,9 @@ pub mod formatter { if iter.formatter.failed { return Ok(()); } + if truncated { + self.print_more_entries::(writer_, count > 0); + } // Only the MapIterator case writes a trailing space. if count > 0 && label == "MapIterator" { let _ = writer_.write_all(b" "); @@ -4679,8 +4760,11 @@ pub mod formatter { writer: writer_, count: 0, }; - value.for_each( + let truncated = for_each_limited( + value, global_this, + UNSIZED_ITERABLE_BUDGET, + None, (&raw mut iter).cast::(), MapIteratorCtx::::for_each, )?; @@ -4691,6 +4775,9 @@ pub mod formatter { if count > 0 { let _ = writer_.write_all(b"\n"); } + if truncated { + self.print_more_entries::(writer_, count > 0); + } } } if !self.single_line { @@ -4743,8 +4830,11 @@ pub mod formatter { writer: writer_, is_first: true, }; - value.for_each( + let truncated = for_each_limited( + value, global_this, + UNSIZED_ITERABLE_BUDGET, + Some(length), (&raw mut iter).cast::(), SetIteratorCtx::::for_each, )?; @@ -4752,7 +4842,10 @@ pub mod formatter { if iter.formatter.failed { return Ok(()); } - if !is_first { + if truncated { + self.print_more_entries::(writer_, !is_first); + } + if !is_first || truncated { let _ = writer_.write_all(b" "); } } else { @@ -4761,14 +4854,20 @@ pub mod formatter { writer: writer_, is_first: true, }; - value.for_each( + let truncated = for_each_limited( + value, global_this, + UNSIZED_ITERABLE_BUDGET, + Some(length), (&raw mut iter).cast::(), SetIteratorCtx::::for_each, )?; if iter.formatter.failed { return Ok(()); } + if truncated { + self.print_more_entries::(writer_, 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..3c6ed18a34e3 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 user-assigned, so its iterator can be endless. + const UNSIZED_ERRORS_BUDGET: u32 = 100; + match crate::console_object::for_each_limited( + errors, + global_ref, + UNSIZED_ERRORS_BUDGET, + None, + (&raw mut ctx).cast(), + agg_iter, + ) { + Ok(true) => { + 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(false) => {} + 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..f70c134e61f6 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,94 @@ 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`. `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); + JSC::JSValue iterable = JSC::JSValue::decode(encodedIterable); + JSC::JSCell* sentinel = vm.orderedHashTableSentinel(); + + auto visit = [&](JSC::VM&, JSC::JSGlobalObject*, JSC::JSValue value) { + callback(&vm, globalObject, ctx, JSC::JSValue::encode(value)); + }; + + // 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 != sentinel) { + JSC::forEachInMapStorage(vm, globalObject, storage, 0, JSC::IterationKind::Entries, visit); + RETURN_IF_EXCEPTION(scope, false); + } + return false; + } + auto* set = dynamicDowncast(iterable); + if (set && set->isIteratorProtocolFastAndNonObservable()) { + JSC::JSCell* storage = set->storageOrSentinel(vm); + if (storage != sentinel) { + JSC::forEachInSetStorage(vm, globalObject, storage, 0, visit); + RETURN_IF_EXCEPTION(scope, false); + } + 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. + 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)) + 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, false); + + bool truncated = false; + for (uint64_t visited = 0;; visited++) { + JSC::JSValue next = JSC::iteratorStep(globalObject, iterationRecord); + RETURN_IF_EXCEPTION(scope, false); + if (next.isFalse()) + return false; + + if (visited >= bound) { + truncated = true; + break; + } + + JSC::JSValue nextValue = JSC::iteratorValue(globalObject, next); + RETURN_IF_EXCEPTION(scope, false); + + callback(&vm, globalObject, ctx, JSC::JSValue::encode(nextValue)); + if (scope.exception()) [[unlikely]] + break; + } + + scope.release(); + JSC::iteratorClose(globalObject, iterationRecord.iterator); + return truncated; +} diff --git a/test/js/bun/console/console-table.test.ts b/test/js/bun/console/console-table.test.ts index 32fee910127d..4fb919d34b21 100644 --- a/test/js/bun/console/console-table.test.ts +++ b/test/js/bun/console/console-table.test.ts @@ -363,3 +363,121 @@ 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 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 }; + } + 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); + }); + + // 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 }]);`, + ); + 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] = 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 │ k │ 1 │", + "│ 1 │ k │ 1 │", + "└───┴─────┴────────┘", + "... 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 a1a05dbfa3ca..d5d89ae87f90 100644 --- a/test/js/bun/util/inspect.test.js +++ b/test/js/bun/util/inspect.test.js @@ -1082,3 +1082,267 @@ it("object property enumeration scales linearly with property count", () => { expect(l.ms / 30000 / (s.ms / 3000)).toBeLessThan(3); }); }); + +// 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); + + 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 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 { + #items = [ + ["a", 1], + ["b", 2], + ]; + get size() { + return this.#items.length; + } + *[Symbol.iterator]() { + yield* this.#items; + } + } + + 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("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() { + 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("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++, {}), + }; + } + } + expect(Bun.inspect(new Overflow())).toBe("Map(1) {\n 0: 0,\n ... more items\n}"); + expect(returned).toBe(1); + }); +}); + +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], ["b", 2]]));`, + "stdout", + ); + 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", "b"]));`, + "stdout", + ); + 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], ["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: 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", "b"]); + Object.getPrototypeOf(set.values()).next = () => ({ value: "k", done: false }); + console.log(set.values());`, + "stdout", + ); + expect({ values: count(output, '"k"'), runaway }).toEqual({ values: 2, runaway: false }); + expect(output).toEndWith(" ... more items\n}\n"); + expect(exitCode).toBe(0); + }); + + 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 Infinity; } + *[Symbol.iterator]() { for (;;) { if (++yielded > 5000) process.exit(2); yield [yielded, yielded]; } } + } + console.table(new Lazy()); + console.log("yielded=" + yielded);`, + "stdout", + ); + expect(runaway).toBe(false); + // `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); + }); + + 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 "k": "v",\n ... more items\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); + }); + + 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); + }); +});