From 8478aa6b049ef8a1d26b9e556cb524a549448d90 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:50:06 +0000 Subject: [PATCH 01/19] util.inspect, Console#table: read Map and Set iterators from storage util.inspect(map.entries()) and new Console().table(map.entries()) walked the iterated collection through the iterator protocol with no bound (Array.from in previewEntries, a for-of in table). A replaced %MapIteratorPrototype%.next or Map.prototype[Symbol.iterator] that never reports done made them spin forever. previewEntries is now native (UtilInspect.cpp), like internalBinding('util').previewEntries in Node.js. It reads what the iterator has left from the storage of the collection. It runs no user code, does not advance the iterator, and copies out at most maxArrayLength entries. As in Node.js, the output starts at the position of the iterator and Console#table shows Key and Values columns for map.entries(). Console#table still reads a Map or a Set through its iterator, so that a subclass can keep its entries anywhere, but stops after size steps, like formatMap in util.inspect. Each key and value of an iterator is now a table cell of its own, so an object cell reaches isBuffer(). Import it from Buffer, where it lives: require('node:buffer').isBuffer is undefined. --- src/js/builtins/ConsoleObject.ts | 21 +- src/js/internal/util/inspect.js | 50 +--- src/jsc/bindings/UtilInspect.cpp | 88 ++++++ src/jsc/bindings/UtilInspect.h | 3 + .../console/console-table-iterators.test.ts | 254 ++++++++++++++++-- .../internal-inspect.test.js | 190 ++++++++++++- 6 files changed, 546 insertions(+), 60 deletions(-) diff --git a/src/js/builtins/ConsoleObject.ts b/src/js/builtins/ConsoleObject.ts index 830676866b24..25237dfd2266 100644 --- a/src/js/builtins/ConsoleObject.ts +++ b/src/js/builtins/ConsoleObject.ts @@ -143,7 +143,9 @@ export function write(this: Console, input) { // TODO: probably could extract `getStringWidth`; probably make that a native function. note how it is copied from `readline.js` export function createConsoleConstructor(console: typeof globalThis.console) { const { inspect, formatWithOptions } = require("node:util"); - const { isBuffer } = require("node:buffer"); + const { + Buffer: { isBuffer }, + } = require("node:buffer"); const { isMapIterator, isSetIterator } = require("node:util/types"); const { validateObject, validateInteger, validateArray, validateOneOf } = require("internal/validators"); @@ -162,6 +164,8 @@ export function createConsoleConstructor(console: typeof globalThis.console) { const kCounts = Symbol("counts"); const internalGetStringWidth = $newCppFunction("stringWidth.cpp", "jsFunctionBunStringWidth", 1); + // [entries, isKeyValue, length] for a Map or Set iterator, without running or advancing it. + const previewEntries = $newCppFunction("UtilInspect.cpp", "jsFunctionPreviewEntries", 2); /** * Returns the number of columns required to display the given string. @@ -666,6 +670,11 @@ export function createConsoleConstructor(console: typeof globalThis.console) { const mapIter = isMapIterator(tabularData); let isKeyValue = false; let i = 0; + if (mapIter) { + const res = previewEntries(tabularData); + tabularData = res[0]; + isKeyValue = res[1]; + } if (isKeyValue || $isMap(tabularData)) { const keys = []; @@ -678,7 +687,10 @@ export function createConsoleConstructor(console: typeof globalThis.console) { length++; } } else { + // User code can replace the iterator, so it gets `size` steps, like formatMap() in util.inspect. + const size = tabularData.size; for (const { 0: k, 1: v } of tabularData) { + if (length >= size) break; ArrayPrototypePush.$call(keys, _inspect(k)); ArrayPrototypePush.$call(values, _inspect(v)); length++; @@ -688,13 +700,16 @@ export function createConsoleConstructor(console: typeof globalThis.console) { } const setIter = isSetIterator(tabularData); - // if (setIter) tabularData = previewEntries(tabularData); + if (setIter) tabularData = previewEntries(tabularData)[0]; const setlike = setIter || mapIter || $isSet(tabularData); if (setlike) { const values = []; let length = 0; - for (const v of tabularData as Set) { + // An iterator is an array of its entries by now. A Set is bounded like the Map above. + const size = setIter || mapIter ? tabularData.length : tabularData.size; + for (const v of tabularData) { + if (length >= size) break; ArrayPrototypePush.$call(values, _inspect(v)); length++; } diff --git a/src/js/internal/util/inspect.js b/src/js/internal/util/inspect.js index 5c89a5cc51e2..118f0e508307 100644 --- a/src/js/internal/util/inspect.js +++ b/src/js/internal/util/inspect.js @@ -43,9 +43,7 @@ const { TypedArrayPrototypeGetSymbolToStringTag, } = primordials; -const ArrayFrom = Array.from; const ArrayPrototypeFilter = uncurryThis(Array.prototype.filter); -const ArrayPrototypeFlat = uncurryThis(Array.prototype.flat); const ArrayPrototypeForEach = uncurryThis(Array.prototype.forEach); const ArrayPrototypeIncludes = uncurryThis(Array.prototype.includes); const ArrayPrototypeIndexOf = uncurryThis(Array.prototype.indexOf); @@ -67,8 +65,6 @@ const FunctionPrototypeBind = uncurryThis(Function.prototype.bind); const FunctionPrototypeToString = uncurryThis(Function.prototype.toString); const JSONStringify = JSON.stringify; const MapPrototypeEntries = uncurryThis(Map.prototype.entries); -const MapPrototypeValues = uncurryThis(Map.prototype.values); -const MapPrototypeKeys = uncurryThis(Map.prototype.keys); const MathFloor = Math.floor; const MathMax = Math.max; const MathRound = Math.round; @@ -98,7 +94,6 @@ const RegExpPrototypeExec = uncurryThis(RegExp.prototype.exec); const RegExpPrototypeSymbolReplace = uncurryThis(RegExp.prototype[Symbol.replace]); const RegExpPrototypeSymbolSplit = uncurryThis(RegExp.prototype[Symbol.split]); const RegExpPrototypeToString = uncurryThis(RegExp.prototype.toString); -const SetPrototypeEntries = uncurryThis(Set.prototype.entries); const SetPrototypeValues = uncurryThis(Set.prototype.values); const StringPrototypeCharCodeAt = uncurryThis(String.prototype.charCodeAt); const StringPrototypeIncludes = uncurryThis(String.prototype.includes); @@ -2256,9 +2251,10 @@ function formatMap(value, ctx, ignored, recurseTimes) { return output; } -function formatSetIterInner(ctx, recurseTimes, entries, state) { +// `length` counts every entry there is. `entries` can stop at maxArrayLength of them. +function formatSetIterInner(ctx, recurseTimes, entries, state, length = entries.length) { const maxArrayLength = MathMax(ctx.maxArrayLength, 0); - const maxLength = $min(maxArrayLength, entries.length); + const maxLength = $min(maxArrayLength, length); const output = new Array(maxLength); ctx.indentationLvl += 2; for (let i = 0; i < maxLength; i++) { @@ -2271,17 +2267,16 @@ function formatSetIterInner(ctx, recurseTimes, entries, state) { // output is not sorted anyway. ArrayPrototypeSort(output); } - const remaining = entries.length - maxLength; + const remaining = length - maxLength; if (remaining > 0) { ArrayPrototypePush.$call(output, remainingText(remaining)); } return output; } -function formatMapIterInner(ctx, recurseTimes, entries, state) { +// Entries exist as [key1, val1, key2, val2, ...]. `len` counts every pair there is. +function formatMapIterInner(ctx, recurseTimes, entries, state, len = entries.length / 2) { const maxArrayLength = MathMax(ctx.maxArrayLength, 0); - // Entries exist as [key1, val1, key2, val2, ...] - const len = entries.length / 2; const remaining = len - maxArrayLength; const maxLength = $min(maxArrayLength, len); const output = new Array(maxLength); @@ -2329,15 +2324,15 @@ function formatWeakMap(ctx, value, recurseTimes) { } function formatIterator(braces, ctx, value, recurseTimes) { - const { 0: entries, 1: isKeyValue } = previewEntries(value, true); + const { 0: entries, 1: isKeyValue, 2: length } = previewEntries(value, true, MathMax(ctx.maxArrayLength, 0)); if (isKeyValue) { // TODO(bun): JSC can also differ between the keys and values iterator, maybe we should also distinguish those in the future? // Mark entry iterators as such. braces[0] = RegExpPrototypeSymbolReplace(/ Iterator] {$/, braces[0], " Entries] {"); - return formatMapIterInner(ctx, recurseTimes, entries, kMapEntries); + return formatMapIterInner(ctx, recurseTimes, entries, kMapEntries, length); } - return formatSetIterInner(ctx, recurseTimes, entries, kIterator); + return formatSetIterInner(ctx, recurseTimes, entries, kIterator, length); } function formatPromise(ctx, value, recurseTimes) { @@ -2783,28 +2778,11 @@ function getProxyDetails(proxy, withHandler = true) { if (withHandler) return [target, handler]; else return target; } -function previewEntries(val, isIterator = false) { - if (isIterator) { - // the Map or Set instance this iterator belongs to - const iteratedObject = $getInternalField(val, 1 /*iteratorFieldIteratedObject*/); - // for Maps: 0 = keys, 1 = values, 2 = entries - // for Sets: 1 = keys|values, 2 = entries - const kind = $getInternalField(val, 3 /*iteratorFieldKind*/); - const isEntries = kind === 2; - // TODO(bun): improve performance by not using Array.from and instead using the iterator directly to only get the first - // few entries which will actually be displayed (this requires changing some logic in the call sites of this function) - if ($isMap(iteratedObject)) { - if (isEntries) return [ArrayPrototypeFlat(ArrayFrom(iteratedObject)), true]; - else if (kind === 1) return [ArrayFrom(MapPrototypeValues(iteratedObject)), false]; - else return [ArrayFrom(MapPrototypeKeys(iteratedObject)), false]; - } else if ($isSet(iteratedObject)) { - if (isEntries) return [ArrayPrototypeFlat(ArrayFrom(SetPrototypeEntries(iteratedObject))), true]; - else return [ArrayFrom(iteratedObject), false]; - } - // TODO(bun): This function is currently only called for Map and Set iterators - // perhaps we should add support for other iterators in the future? (e.g. ArrayIterator and StringIterator) - else throw new Error("previewEntries(): Invalid iterator received"); - } +// For a Map or Set iterator: [entries, isKeyValue, length]. `entries` is flat and holds the first +// `limit` (default: all) of the `length` entries the iterator has left. See UtilInspect.cpp. +const previewIteratorEntries = $newCppFunction("UtilInspect.cpp", "jsFunctionPreviewEntries", 2); +function previewEntries(val, isIterator = false, limit) { + if (isIterator) return previewIteratorEntries(val, limit); // TODO(bun): are there any JSC APIs for viewing the contents of these in JS? if (isWeakMap(val)) return []; if (isWeakSet(val)) return []; diff --git a/src/jsc/bindings/UtilInspect.cpp b/src/jsc/bindings/UtilInspect.cpp index 00936176bfa9..0e9861a3a002 100644 --- a/src/jsc/bindings/UtilInspect.cpp +++ b/src/jsc/bindings/UtilInspect.cpp @@ -10,6 +10,8 @@ #include "JavaScriptCore/PropertyNameArray.h" #include "JavaScriptCore/JSArray.h" #include "JavaScriptCore/IdentifierInlines.h" +#include "JavaScriptCore/JSMapIterator.h" +#include "JavaScriptCore/JSSetIterator.h" namespace Bun { @@ -118,4 +120,90 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionGetOwnNonIndexProperties, (JSGlobalObject * g RELEASE_AND_RETURN(scope, JSValue::encode(constructArray(globalObject, static_cast(nullptr), keys))); } +// Walks what a Map or Set iterator has left to yield, in the storage of the collection, the way +// the iterator's own next() does but without moving the iterator. Copies out at most `limit` +// entries and returns how many are left in total. +template +static uint32_t previewRemainingEntries(VM& vm, Iterator* iterator, uint32_t limit, MarkedArgumentBuffer& entries) +{ + using Helper = typename Collection::Helper; + + JSCell* storageCell = iterator->tryGetStorage(); + // An iterator made while the collection had no storage yet picks it up on its first next(). + if (!storageCell) + storageCell = iterator->iteratedObject()->storage(); + if (!storageCell || storageCell == vm.orderedHashTableSentinel()) + return 0; + + IterationKind kind = iterator->kind(); + auto* storage = uncheckedDowncast(storageCell); + typename Helper::Entry entry = iterator->entry(); + uint32_t remaining = 0; + while (true) { + // Follows the tables a rehash or a clear() left behind, then skips deleted entries. + auto next = Helper::transitAndNext(vm, *storage, entry); + if (!next.storage) + break; + storage = next.storage; + entry = next.entry + 1; + if (remaining++ >= limit) + continue; + // A Set stores keys only. Its entries() pairs each key with itself. + JSValue value = std::is_same_v ? next.key : next.value; + if (kind != IterationKind::Values) + entries.append(next.key); + if (kind != IterationKind::Keys) + entries.append(value); + } + return remaining; +} + +// Port of V8's `internalBinding('util').previewEntries(iterator, true)`, which util.inspect and +// console.Console#table use to show a Map or Set iterator. No user code runs: a replaced +// %MapIteratorPrototype%.next or Map.prototype[Symbol.iterator] is never called. +// Returns [entries, isKeyValue, length]. `entries` is flat ([key, value, key, value, ...] when +// isKeyValue) and holds the first `limit` (default: all) of the `length` entries that are left. +JSC_DEFINE_HOST_FUNCTION(jsFunctionPreviewEntries, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSValue iteratorValue = callFrame->argument(0); + JSValue limitValue = callFrame->argument(1); + uint32_t limit = std::numeric_limits::max(); + if (limitValue.isNumber()) { + double number = limitValue.asNumber(); + // NaN is not > 0. + if (!(number > 0)) + limit = 0; + else if (number < limit) + limit = static_cast(number); + } + + MarkedArgumentBuffer entries; + uint32_t length = 0; + bool isKeyValue = false; + if (auto* mapIterator = dynamicDowncast(iteratorValue)) { + isKeyValue = mapIterator->kind() == IterationKind::Entries; + length = previewRemainingEntries(vm, mapIterator, limit, entries); + } else if (auto* setIterator = dynamicDowncast(iteratorValue)) { + isKeyValue = setIterator->kind() == IterationKind::Entries; + length = previewRemainingEntries(vm, setIterator, limit, entries); + } else + return throwVMTypeError(globalObject, scope, "previewEntries() expects a Map or Set iterator"_s); + + if (entries.hasOverflowed()) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return {}; + } + JSArray* entriesArray = constructArray(globalObject, static_cast(nullptr), entries); + RETURN_IF_EXCEPTION(scope, {}); + + MarkedArgumentBuffer result; + result.append(entriesArray); + result.append(jsBoolean(isKeyValue)); + result.append(jsNumber(length)); + RELEASE_AND_RETURN(scope, JSValue::encode(constructArray(globalObject, static_cast(nullptr), result))); +} + } diff --git a/src/jsc/bindings/UtilInspect.h b/src/jsc/bindings/UtilInspect.h index ea2d39848a40..dbe7a30e635b 100644 --- a/src/jsc/bindings/UtilInspect.h +++ b/src/jsc/bindings/UtilInspect.h @@ -9,4 +9,7 @@ JSC::Structure* createUtilInspectOptionsStructure(JSC::VM& vm, JSC::JSGlobalObje // internalBinding('util').getOwnNonIndexProperties(object, filter) JSC_DECLARE_HOST_FUNCTION(jsFunctionGetOwnNonIndexProperties); +// internalBinding('util').previewEntries(iterator, true), with a limit on how many entries it copies out +JSC_DECLARE_HOST_FUNCTION(jsFunctionPreviewEntries); + } diff --git a/test/js/node/console/console-table-iterators.test.ts b/test/js/node/console/console-table-iterators.test.ts index c9eb214a0e8c..d9a17feb2451 100644 --- a/test/js/node/console/console-table-iterators.test.ts +++ b/test/js/node/console/console-table-iterators.test.ts @@ -1,41 +1,255 @@ import { expect, test } from "bun:test"; import { bunEnv, bunExe } from "harness"; -// A broken Console builtin aborts the whole process the moment `node:console` -// is loaded, so this has to be a spawned fixture: an in-process test in -// console.test.ts would take the test runner down with it. -test("console.Console#table renders Map and Set iterators", async () => { +const prelude = /* js */ ` + const { Console } = require("node:console"); + const c = new Console({ stdout: process.stdout, stderr: process.stderr, colorMode: false }); +`; + +async function run(script: string) { await using proc = Bun.spawn({ - cmd: [ - bunExe(), - "-e", - `const { Console } = require("node:console"); - const c = new Console({ stdout: process.stdout, stderr: process.stderr, colorMode: false }); - c.table(new Map([["a", 1], ["b", 2]]).entries()); - c.table(new Set([7, 8]).values());`, - ], + cmd: [bunExe(), "-e", prelude + script], env: bunEnv, stderr: "pipe", }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - if (exitCode !== 0) { expect(stderr).toBe(""); } + return { stdout, exitCode }; +} + +// A broken Console builtin aborts the whole process the moment `node:console` +// is loaded, so this has to be a spawned fixture: an in-process test in +// console.test.ts would take the test runner down with it. +test("console.Console#table renders Map and Set iterators", async () => { + const { stdout, exitCode } = await run(/* js */ ` + c.table(new Map([["a", 1], ["b", 2]]).entries()); + c.table(new Set([7, 8]).values()); + `); + expect(stdout).toMatchInlineSnapshot(` + "┌───────────────────┬─────┬────────┐ + │ (iteration index) │ Key │ Values │ + ├───────────────────┼─────┼────────┤ + │ 0 │ 'a' │ 1 │ + │ 1 │ 'b' │ 2 │ + └───────────────────┴─────┴────────┘ + ┌───────────────────┬────────┐ + │ (iteration index) │ Values │ + ├───────────────────┼────────┤ + │ 0 │ 7 │ + │ 1 │ 8 │ + └───────────────────┴────────┘ + " + `); + expect(exitCode).toBe(0); +}); + +// Like Node.js: the rows are what the iterator has left, and the iterator does not move. +test("console.Console#table shows what a Map or Set iterator has left and does not advance it", async () => { + const { stdout, exitCode } = await run(/* js */ ` + const entries = new Map([["a", 1], ["b", 2], ["c", 3]]).entries(); + entries.next(); + c.table(entries); + console.log(JSON.stringify(entries.next())); + + const values = new Set([7, 8, 9]).values(); + values.next(); + c.table(values); + console.log(JSON.stringify(values.next())); + + c.table(new Map([["a", 1]]).keys()); + c.table(new Set([7]).entries()); + `); + expect(stdout).toMatchInlineSnapshot(` + "┌───────────────────┬─────┬────────┐ + │ (iteration index) │ Key │ Values │ + ├───────────────────┼─────┼────────┤ + │ 0 │ 'b' │ 2 │ + │ 1 │ 'c' │ 3 │ + └───────────────────┴─────┴────────┘ + {"value":["b",2],"done":false} + ┌───────────────────┬────────┐ + │ (iteration index) │ Values │ + ├───────────────────┼────────┤ + │ 0 │ 8 │ + │ 1 │ 9 │ + └───────────────────┴────────┘ + {"value":8,"done":false} + ┌───────────────────┬────────┐ + │ (iteration index) │ Values │ + ├───────────────────┼────────┤ + │ 0 │ 'a' │ + └───────────────────┴────────┘ + ┌───────────────────┬────────┐ + │ (iteration index) │ Values │ + ├───────────────────┼────────┤ + │ 0 │ 7 │ + │ 1 │ 7 │ + └───────────────────┴────────┘ + " + `); + expect(exitCode).toBe(0); +}); + +// Every key and value of an iterator is a cell of its own, so an object that is +// not an array reaches the isBuffer() check of the cell formatter. +test("console.Console#table formats object keys and values of a Map or Set iterator", async () => { + const { stdout, exitCode } = await run(/* js */ ` + c.table(new Map([["a", { x: 1 }], [{ k: 1 }, [1, 2]]]).entries()); + c.table(new Map([["a", { x: 1 }]]).values()); + c.table(new Set([{ x: 1 }]).entries()); + `); + expect(stdout).toMatchInlineSnapshot(` + "┌───────────────────┬──────────┬──────────┐ + │ (iteration index) │ Key │ Values │ + ├───────────────────┼──────────┼──────────┤ + │ 0 │ 'a' │ { x: 1 } │ + │ 1 │ { k: 1 } │ [ 1, 2 ] │ + └───────────────────┴──────────┴──────────┘ + ┌───────────────────┬──────────┐ + │ (iteration index) │ Values │ + ├───────────────────┼──────────┤ + │ 0 │ { x: 1 } │ + └───────────────────┴──────────┘ + ┌───────────────────┬──────────┐ + │ (iteration index) │ Values │ + ├───────────────────┼──────────┤ + │ 0 │ { x: 1 } │ + │ 1 │ { x: 1 } │ + └───────────────────┴──────────┘ + " + `); + expect(exitCode).toBe(0); +}); + +// The replaced functions never report done. On a build that lets them run +// without a bound, the guard ends the child process and the output shows how +// far it got. +const guard = /* js */ ` + let calls = 0; + function guard() { + if (++calls > 5000) { + console.log("RUNAWAY calls=" + calls); + process.exit(2); + } + } +`; + +test("console.Console#table does not run a replaced next() of a Map or Set iterator", async () => { + const { stdout, exitCode } = await run( + guard + + /* js */ ` + const entries = new Map([["a", 1], ["b", 2]]).entries(); + const values = new Set([7, 8]).values(); + Object.getPrototypeOf(entries).next = () => (guard(), { value: ["k", "v"], done: false }); + Object.getPrototypeOf(values).next = () => (guard(), { value: "v", done: false }); + c.table(entries); + c.table(values); + console.log("calls=" + calls); + `, + ); expect(stdout).toMatchInlineSnapshot(` - "┌───────────────────┬────────────┐ - │ (iteration index) │ Values │ - ├───────────────────┼────────────┤ - │ 0 │ [ 'a', 1 ] │ - │ 1 │ [ 'b', 2 ] │ - └───────────────────┴────────────┘ + "┌───────────────────┬─────┬────────┐ + │ (iteration index) │ Key │ Values │ + ├───────────────────┼─────┼────────┤ + │ 0 │ 'a' │ 1 │ + │ 1 │ 'b' │ 2 │ + └───────────────────┴─────┴────────┘ ┌───────────────────┬────────┐ │ (iteration index) │ Values │ ├───────────────────┼────────┤ │ 0 │ 7 │ │ 1 │ 8 │ └───────────────────┴────────┘ + calls=0 + " + `); + expect(exitCode).toBe(0); +}); + +// A Map or a Set is read through its iterator, so that a subclass can keep its +// entries anywhere. The iterator gets as many steps as the collection has entries. +test("console.Console#table stops a replaced Symbol.iterator of a Map or Set at its size", async () => { + const { stdout, exitCode } = await run( + guard + + /* js */ ` + Map.prototype[Symbol.iterator] = function* () { + for (;;) { + guard(); + yield ["k", "v"]; + } + }; + Set.prototype[Symbol.iterator] = function* () { + for (;;) { + guard(); + yield "v"; + } + }; + c.table(new Map([["a", 1], ["b", 2]])); + c.table(new Set([7, 8])); + c.table(new Map()); + `, + ); + expect(stdout).toMatchInlineSnapshot(` + "┌───────────────────┬─────┬────────┐ + │ (iteration index) │ Key │ Values │ + ├───────────────────┼─────┼────────┤ + │ 0 │ 'k' │ 'v' │ + │ 1 │ 'k' │ 'v' │ + └───────────────────┴─────┴────────┘ + ┌───────────────────┬────────┐ + │ (iteration index) │ Values │ + ├───────────────────┼────────┤ + │ 0 │ 'v' │ + │ 1 │ 'v' │ + └───────────────────┴────────┘ + ┌───────────────────┬─────┬────────┐ + │ (iteration index) │ Key │ Values │ + ├───────────────────┼─────┼────────┤ + └───────────────────┴─────┴────────┘ + " + `); + expect(exitCode).toBe(0); +}); + +// This is why a Map is not read from its storage: a subclass like quick-lru +// never calls super.set(), so the inherited storage is empty. A size that is +// not a number (here a method that shadows the getter) sets no bound. +test("console.Console#table reads a Map subclass through its own iterator", async () => { + const { stdout, exitCode } = await run(/* js */ ` + class Lru extends Map { + #rows = [["x", 1], ["y", 2], ["z", 3]]; + get size() { + return this.#rows.length; + } + *[Symbol.iterator]() { + yield* this.#rows; + } + } + c.table(new Lru()); + + class JavaStyle extends Map { + size() { + return 1; + } + } + c.table(new JavaStyle([["a", 1], ["b", 2]])); + `); + expect(stdout).toMatchInlineSnapshot(` + "┌───────────────────┬─────┬────────┐ + │ (iteration index) │ Key │ Values │ + ├───────────────────┼─────┼────────┤ + │ 0 │ 'x' │ 1 │ + │ 1 │ 'y' │ 2 │ + │ 2 │ 'z' │ 3 │ + └───────────────────┴─────┴────────┘ + ┌───────────────────┬─────┬────────┐ + │ (iteration index) │ Key │ Values │ + ├───────────────────┼─────┼────────┤ + │ 0 │ 'a' │ 1 │ + │ 1 │ 'b' │ 2 │ + └───────────────────┴─────┴────────┘ " `); expect(exitCode).toBe(0); diff --git a/test/js/node/util/node-inspect-tests/internal-inspect.test.js b/test/js/node/util/node-inspect-tests/internal-inspect.test.js index c6f5e4c570d2..50214335b169 100644 --- a/test/js/node/util/node-inspect-tests/internal-inspect.test.js +++ b/test/js/node/util/node-inspect-tests/internal-inspect.test.js @@ -1,7 +1,8 @@ import assert from "assert"; import { expect, test } from "bun:test"; -import { withoutAggressiveGC } from "harness"; +import { bunEnv, bunExe, withoutAggressiveGC } from "harness"; import util from "util"; +import vm from "vm"; test("no assertion failures", () => { // Errors in accessors are not triggered @@ -157,6 +158,193 @@ test.each([ }); }); +// Map and Set iterators are shown through the native previewEntries +// (UtilInspect.cpp). It reads what the iterator has left from the storage of +// the collection and never calls next(). So the iterator stays where it is, and +// user code that replaces next() or Symbol.iterator does not run. The expected +// strings are what Node.js prints. +test("util.inspect shows what a Map or Set iterator has left", () => { + const map = new Map([ + ["a", 1], + ["b", 2], + ["c", 3], + ]); + const set = new Set([1, 2, 3]); + + const entries = map.entries(); + entries.next(); + expect(util.inspect(entries)).toBe("[Map Entries] { [ 'b', 2 ], [ 'c', 3 ] }"); + expect(util.inspect(entries)).toBe("[Map Entries] { [ 'b', 2 ], [ 'c', 3 ] }"); + expect(entries.next()).toEqual({ value: ["b", 2], done: false }); + + const keys = map.keys(); + keys.next(); + keys.next(); + expect(util.inspect(keys)).toBe("[Map Iterator] { 'c' }"); + + const values = map.values(); + values.next(); + expect(util.inspect(values)).toBe("[Map Iterator] { 2, 3 }"); + + const setValues = set.values(); + setValues.next(); + expect(util.inspect(setValues)).toBe("[Set Iterator] { 2, 3 }"); + + const setEntries = set.entries(); + setEntries.next(); + expect(util.inspect(setEntries)).toBe("[Set Entries] { [ 2, 2 ], [ 3, 3 ] }"); + + // An iterator that reported done stays empty, also when the collection grows. + for (const _ of entries); + for (const _ of setValues); + map.set("d", 4); + set.add(4); + expect(util.inspect(entries)).toBe("[Map Entries] { }"); + expect(util.inspect(setValues)).toBe("[Set Iterator] { }"); + + expect(util.inspect(new Map().entries())).toBe("[Map Entries] { }"); + expect(util.inspect(new Set().values())).toBe("[Set Iterator] { }"); + + const fromOtherRealm = vm.runInNewContext("const i = new Map([[1, 2], [3, 4]]).entries(); i.next(); i"); + expect(util.inspect(fromOtherRealm)).toBe("[Map Entries] { [ 3, 4 ] }"); +}); + +test("util.inspect of a Map or Set iterator follows delete(), clear() and growth of the collection", () => { + const map = new Map([ + ["a", 1], + ["b", 2], + ["c", 3], + ["d", 4], + ]); + let iterator = map.entries(); + iterator.next(); + map.delete("b"); + map.set("e", 5); + expect(util.inspect(iterator)).toBe("[Map Entries] { [ 'c', 3 ], [ 'd', 4 ], [ 'e', 5 ] }"); + + const cleared = new Map([ + ["a", 1], + ["b", 2], + ]); + iterator = cleared.entries(); + iterator.next(); + cleared.clear(); + cleared.set("z", 26); + expect(util.inspect(iterator)).toBe("[Map Entries] { [ 'z', 26 ] }"); + + // This iterator exists before the Map has any storage to point at. + const late = new Map(); + iterator = late.entries(); + late.set("x", 1); + expect(util.inspect(iterator)).toBe("[Map Entries] { [ 'x', 1 ] }"); + + // Growth and deletes rehash the storage. The iterator still points at the first table. + const grown = new Map([ + [0, 0], + [1, 1], + ]); + iterator = grown.keys(); + iterator.next(); + for (let i = 2; i < 40; i++) grown.set(i, i); + for (let i = 2; i < 36; i++) grown.delete(i); + expect(util.inspect(iterator)).toBe("[Map Iterator] { 1, 36, 37, 38, 39 }"); + expect([...iterator]).toEqual([1, 36, 37, 38, 39]); + + const set = new Set([1, 2, 3, 4, 5, 6]); + iterator = set.values(); + iterator.next(); + iterator.next(); + set.delete(1); + set.delete(4); + set.add(7); + expect(util.inspect(iterator)).toBe("[Set Iterator] { 3, 5, 6, 7 }"); + expect([...iterator]).toEqual([3, 5, 6, 7]); +}); + +test("util.inspect of a Map or Set iterator counts the items it leaves out from where the iterator is", () => { + const map = new Map(); + for (let i = 0; i < 150; i++) map.set(i, i); + + let iterator = map.keys(); + for (let i = 0; i < 20; i++) iterator.next(); + expect(util.inspect(iterator, { maxArrayLength: 2 })).toBe("[Map Iterator] { 20, 21, ... 128 more items }"); + expect(util.inspect(iterator, { breakLength: Infinity })).toEndWith(", 118, 119, ... 30 more items }"); + + iterator = map.entries(); + for (let i = 0; i < 148; i++) iterator.next(); + expect(util.inspect(iterator, { maxArrayLength: 1 })).toBe("[Map Entries] { [ 148, 148 ], ... 1 more item }"); + expect(util.inspect(iterator, { maxArrayLength: 0 })).toBe("[Map Entries] { ... 2 more items }"); + expect(util.inspect(iterator, { maxArrayLength: -1 })).toBe("[Map Entries] { ... 2 more items }"); + for (const maxArrayLength of [2, 3, Infinity, null]) { + expect(util.inspect(iterator, { maxArrayLength })).toBe("[Map Entries] { [ 148, 148 ], [ 149, 149 ] }"); + } + + iterator = new Set(map.keys()).entries(); + for (let i = 0; i < 147; i++) iterator.next(); + expect(util.inspect(iterator, { maxArrayLength: 1 })).toBe("[Set Entries] { [ 147, 147 ], ... 2 more items }"); +}); + +test("util.inspect of a Map or Set iterator does not run a replaced next() or Symbol.iterator", async () => { + // The replaced functions never report done. On a build that calls them, the + // guard ends the child process and the output shows how far it got. + const fixture = /* js */ ` + const util = require("node:util"); + let calls = 0; + function guard() { + if (++calls > 5000) { + console.log("RUNAWAY calls=" + calls); + process.exit(2); + } + } + function* endless() { + for (;;) { + guard(); + yield ["k", "v"]; + } + } + const map = new Map([["a", 1], ["b", 2]]); + const set = new Set([1, 2]); + const iterators = { + "map.entries()": map.entries(), + "map.keys()": map.keys(), + "map.values()": map.values(), + "map[Symbol.iterator]()": map[Symbol.iterator](), + "set.entries()": set.entries(), + "set.values()": set.values(), + "set[Symbol.iterator]()": set[Symbol.iterator](), + }; + Object.getPrototypeOf(map.entries()).next = () => (guard(), { value: ["k", "v"], done: false }); + Object.getPrototypeOf(set.values()).next = () => (guard(), { value: "v", done: false }); + for (const method of ["entries", "keys", "values", Symbol.iterator]) { + Map.prototype[method] = endless; + Set.prototype[method] = endless; + } + for (const name in iterators) console.log(name, util.inspect(iterators[name])); + console.log("calls=" + calls); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + if (exitCode !== 0) expect(stderr).toBe(""); + expect(stdout).toBe( + [ + "map.entries() [Map Entries] { [ 'a', 1 ], [ 'b', 2 ] }", + "map.keys() [Map Iterator] { 'a', 'b' }", + "map.values() [Map Iterator] { 1, 2 }", + "map[Symbol.iterator]() [Map Entries] { [ 'a', 1 ], [ 'b', 2 ] }", + "set.entries() [Set Entries] { [ 1, 1 ], [ 2, 2 ] }", + "set.values() [Set Iterator] { 1, 2 }", + "set[Symbol.iterator]() [Set Iterator] { 1, 2 }", + "calls=0", + "", + ].join("\n"), + ); + expect(exitCode).toBe(0); +}); + //! non-standard property, should this be kept? test.skip("util.stylizeWithHTML", () => { assert.strictEqual( From 204b5946ebe30f90a5b9b9e542a768124fa1ca1b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 12 Sep 2026 08:19:59 +0000 Subject: [PATCH 02/19] Console#table: do not bound a Map or Set by its size A subclass can yield more entries than size reports. quick-lru caps size at maxSize while its iterator also yields the older generation, so table(lru) printed 3 of 5 rows. Node.js and bun 1.4.2 print all 5. The loops over a Map or a Set are Node's again. The iterator path keeps the native previewEntries. A test pins the two-generation shape. --- src/js/builtins/ConsoleObject.ts | 8 +- .../console/console-table-iterators.test.ts | 86 ++++--------------- 2 files changed, 17 insertions(+), 77 deletions(-) diff --git a/src/js/builtins/ConsoleObject.ts b/src/js/builtins/ConsoleObject.ts index 25237dfd2266..354a337f5ec3 100644 --- a/src/js/builtins/ConsoleObject.ts +++ b/src/js/builtins/ConsoleObject.ts @@ -687,10 +687,7 @@ export function createConsoleConstructor(console: typeof globalThis.console) { length++; } } else { - // User code can replace the iterator, so it gets `size` steps, like formatMap() in util.inspect. - const size = tabularData.size; for (const { 0: k, 1: v } of tabularData) { - if (length >= size) break; ArrayPrototypePush.$call(keys, _inspect(k)); ArrayPrototypePush.$call(values, _inspect(v)); length++; @@ -706,10 +703,7 @@ export function createConsoleConstructor(console: typeof globalThis.console) { if (setlike) { const values = []; let length = 0; - // An iterator is an array of its entries by now. A Set is bounded like the Map above. - const size = setIter || mapIter ? tabularData.length : tabularData.size; - for (const v of tabularData) { - if (length >= size) break; + for (const v of tabularData as Set) { ArrayPrototypePush.$call(values, _inspect(v)); length++; } diff --git a/test/js/node/console/console-table-iterators.test.ts b/test/js/node/console/console-table-iterators.test.ts index d9a17feb2451..b11076bff569 100644 --- a/test/js/node/console/console-table-iterators.test.ts +++ b/test/js/node/console/console-table-iterators.test.ts @@ -168,87 +168,33 @@ test("console.Console#table does not run a replaced next() of a Map or Set itera expect(exitCode).toBe(0); }); -// A Map or a Set is read through its iterator, so that a subclass can keep its -// entries anywhere. The iterator gets as many steps as the collection has entries. -test("console.Console#table stops a replaced Symbol.iterator of a Map or Set at its size", async () => { - const { stdout, exitCode } = await run( - guard + - /* js */ ` - Map.prototype[Symbol.iterator] = function* () { - for (;;) { - guard(); - yield ["k", "v"]; - } - }; - Set.prototype[Symbol.iterator] = function* () { - for (;;) { - guard(); - yield "v"; - } - }; - c.table(new Map([["a", 1], ["b", 2]])); - c.table(new Set([7, 8])); - c.table(new Map()); - `, - ); - expect(stdout).toMatchInlineSnapshot(` - "┌───────────────────┬─────┬────────┐ - │ (iteration index) │ Key │ Values │ - ├───────────────────┼─────┼────────┤ - │ 0 │ 'k' │ 'v' │ - │ 1 │ 'k' │ 'v' │ - └───────────────────┴─────┴────────┘ - ┌───────────────────┬────────┐ - │ (iteration index) │ Values │ - ├───────────────────┼────────┤ - │ 0 │ 'v' │ - │ 1 │ 'v' │ - └───────────────────┴────────┘ - ┌───────────────────┬─────┬────────┐ - │ (iteration index) │ Key │ Values │ - ├───────────────────┼─────┼────────┤ - └───────────────────┴─────┴────────┘ - " - `); - expect(exitCode).toBe(0); -}); - -// This is why a Map is not read from its storage: a subclass like quick-lru -// never calls super.set(), so the inherited storage is empty. A size that is -// not a number (here a method that shadows the getter) sets no bound. -test("console.Console#table reads a Map subclass through its own iterator", async () => { +// A Map or a Set itself is read through its own iterator, and every entry the +// iterator yields is a row, as in Node.js. `size` does not bound it: quick-lru +// caps `size` at maxSize, and its iterator also yields the older generation. +test("console.Console#table prints every entry that a Map subclass yields, also past its size", async () => { const { stdout, exitCode } = await run(/* js */ ` - class Lru extends Map { - #rows = [["x", 1], ["y", 2], ["z", 3]]; + class TwoGenerations extends Map { + #recent = [["d", 4], ["e", 5]]; + #old = [["a", 1], ["b", 2], ["c", 3]]; get size() { - return this.#rows.length; + return 3; } *[Symbol.iterator]() { - yield* this.#rows; + yield* this.#recent; + yield* this.#old; } } - c.table(new Lru()); - - class JavaStyle extends Map { - size() { - return 1; - } - } - c.table(new JavaStyle([["a", 1], ["b", 2]])); + c.table(new TwoGenerations()); `); expect(stdout).toMatchInlineSnapshot(` "┌───────────────────┬─────┬────────┐ │ (iteration index) │ Key │ Values │ ├───────────────────┼─────┼────────┤ - │ 0 │ 'x' │ 1 │ - │ 1 │ 'y' │ 2 │ - │ 2 │ 'z' │ 3 │ - └───────────────────┴─────┴────────┘ - ┌───────────────────┬─────┬────────┐ - │ (iteration index) │ Key │ Values │ - ├───────────────────┼─────┼────────┤ - │ 0 │ 'a' │ 1 │ - │ 1 │ 'b' │ 2 │ + │ 0 │ 'd' │ 4 │ + │ 1 │ 'e' │ 5 │ + │ 2 │ 'a' │ 1 │ + │ 3 │ 'b' │ 2 │ + │ 4 │ 'c' │ 3 │ └───────────────────┴─────┴────────┘ " `); From 2c8f81ee269b834dbe759b0e6dfcfb1416573363 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 12 Sep 2026 08:23:47 +0000 Subject: [PATCH 03/19] Shorten the comments on previewEntries --- src/js/internal/util/inspect.js | 3 +-- src/jsc/bindings/UtilInspect.cpp | 10 ++-------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/src/js/internal/util/inspect.js b/src/js/internal/util/inspect.js index 118f0e508307..1b77d8a3ece0 100644 --- a/src/js/internal/util/inspect.js +++ b/src/js/internal/util/inspect.js @@ -2778,8 +2778,7 @@ function getProxyDetails(proxy, withHandler = true) { if (withHandler) return [target, handler]; else return target; } -// For a Map or Set iterator: [entries, isKeyValue, length]. `entries` is flat and holds the first -// `limit` (default: all) of the `length` entries the iterator has left. See UtilInspect.cpp. +// (iterator, limit) => [flat entries (at most `limit`), isKeyValue, entries left] const previewIteratorEntries = $newCppFunction("UtilInspect.cpp", "jsFunctionPreviewEntries", 2); function previewEntries(val, isIterator = false, limit) { if (isIterator) return previewIteratorEntries(val, limit); diff --git a/src/jsc/bindings/UtilInspect.cpp b/src/jsc/bindings/UtilInspect.cpp index 0e9861a3a002..beb111e70843 100644 --- a/src/jsc/bindings/UtilInspect.cpp +++ b/src/jsc/bindings/UtilInspect.cpp @@ -120,9 +120,7 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionGetOwnNonIndexProperties, (JSGlobalObject * g RELEASE_AND_RETURN(scope, JSValue::encode(constructArray(globalObject, static_cast(nullptr), keys))); } -// Walks what a Map or Set iterator has left to yield, in the storage of the collection, the way -// the iterator's own next() does but without moving the iterator. Copies out at most `limit` -// entries and returns how many are left in total. +// JSMapIterator::nextWithAdvance in a loop, minus its writes to the iterator. Returns how many entries are left. template static uint32_t previewRemainingEntries(VM& vm, Iterator* iterator, uint32_t limit, MarkedArgumentBuffer& entries) { @@ -158,11 +156,7 @@ static uint32_t previewRemainingEntries(VM& vm, Iterator* iterator, uint32_t lim return remaining; } -// Port of V8's `internalBinding('util').previewEntries(iterator, true)`, which util.inspect and -// console.Console#table use to show a Map or Set iterator. No user code runs: a replaced -// %MapIteratorPrototype%.next or Map.prototype[Symbol.iterator] is never called. -// Returns [entries, isKeyValue, length]. `entries` is flat ([key, value, key, value, ...] when -// isKeyValue) and holds the first `limit` (default: all) of the `length` entries that are left. +// internalBinding('util').previewEntries(iterator, true) plus a limit: [flat entries (at most `limit`), isKeyValue, entries left]. JSC_DEFINE_HOST_FUNCTION(jsFunctionPreviewEntries, (JSGlobalObject * globalObject, CallFrame* callFrame)) { auto& vm = JSC::getVM(globalObject); From 29be5f2e4de16aa09b9bb803a4cb497950cc1fd0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 12 Sep 2026 08:37:51 +0000 Subject: [PATCH 04/19] ci: retrigger From 495e5be0e4806044b9b5162567bc342beca1f2a1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 12 Sep 2026 09:03:12 +0000 Subject: [PATCH 05/19] test: run the Console#table iterator tests concurrently --- test/js/node/console/console-table-iterators.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/js/node/console/console-table-iterators.test.ts b/test/js/node/console/console-table-iterators.test.ts index b11076bff569..40786bcf3301 100644 --- a/test/js/node/console/console-table-iterators.test.ts +++ b/test/js/node/console/console-table-iterators.test.ts @@ -22,7 +22,7 @@ async function run(script: string) { // A broken Console builtin aborts the whole process the moment `node:console` // is loaded, so this has to be a spawned fixture: an in-process test in // console.test.ts would take the test runner down with it. -test("console.Console#table renders Map and Set iterators", async () => { +test.concurrent("console.Console#table renders Map and Set iterators", async () => { const { stdout, exitCode } = await run(/* js */ ` c.table(new Map([["a", 1], ["b", 2]]).entries()); c.table(new Set([7, 8]).values()); @@ -46,7 +46,7 @@ test("console.Console#table renders Map and Set iterators", async () => { }); // Like Node.js: the rows are what the iterator has left, and the iterator does not move. -test("console.Console#table shows what a Map or Set iterator has left and does not advance it", async () => { +test.concurrent("console.Console#table shows what a Map or Set iterator has left and does not advance it", async () => { const { stdout, exitCode } = await run(/* js */ ` const entries = new Map([["a", 1], ["b", 2], ["c", 3]]).entries(); entries.next(); @@ -94,7 +94,7 @@ test("console.Console#table shows what a Map or Set iterator has left and does n // Every key and value of an iterator is a cell of its own, so an object that is // not an array reaches the isBuffer() check of the cell formatter. -test("console.Console#table formats object keys and values of a Map or Set iterator", async () => { +test.concurrent("console.Console#table formats object keys and values of a Map or Set iterator", async () => { const { stdout, exitCode } = await run(/* js */ ` c.table(new Map([["a", { x: 1 }], [{ k: 1 }, [1, 2]]]).entries()); c.table(new Map([["a", { x: 1 }]]).values()); @@ -136,7 +136,7 @@ const guard = /* js */ ` } `; -test("console.Console#table does not run a replaced next() of a Map or Set iterator", async () => { +test.concurrent("console.Console#table does not run a replaced next() of a Map or Set iterator", async () => { const { stdout, exitCode } = await run( guard + /* js */ ` @@ -171,7 +171,7 @@ test("console.Console#table does not run a replaced next() of a Map or Set itera // A Map or a Set itself is read through its own iterator, and every entry the // iterator yields is a row, as in Node.js. `size` does not bound it: quick-lru // caps `size` at maxSize, and its iterator also yields the older generation. -test("console.Console#table prints every entry that a Map subclass yields, also past its size", async () => { +test.concurrent("console.Console#table prints every entry that a Map subclass yields, also past its size", async () => { const { stdout, exitCode } = await run(/* js */ ` class TwoGenerations extends Map { #recent = [["d", 4], ["e", 5]]; From 6531d5c6c010a4e1deaedbecf236653bf3ef384f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:56:46 +0000 Subject: [PATCH 06/19] previewEntries: step a private copy of the iterator with the engine's own next() The reader walked the storage of the collection itself. That walk is a copy of JSMapIterator::nextWithAdvance, and it went stale: a process that runs AOT-compiled code gives a Map or a Set compact storage, and the copy cast it to the ordinary table. util.inspect(map.entries()) in an executable built with --compile --aot aborted at the cast. The reader now copies the four internal fields of the iterator into a private iterator and steps that one with nextWithAdvance. No Bun code names the storage any more, so the engine decides how it is read. The buffer for the entries is sized once from the size of the collection. --- src/jsc/bindings/UtilInspect.cpp | 81 ++++++++++++++++++++------------ 1 file changed, 51 insertions(+), 30 deletions(-) diff --git a/src/jsc/bindings/UtilInspect.cpp b/src/jsc/bindings/UtilInspect.cpp index 19993ea580cf..8e19bbae1855 100644 --- a/src/jsc/bindings/UtilInspect.cpp +++ b/src/jsc/bindings/UtilInspect.cpp @@ -125,49 +125,76 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionGetOwnNonIndexProperties, (JSGlobalObject * g RELEASE_AND_RETURN(scope, JSValue::encode(constructArray(globalObject, static_cast(nullptr), keys))); } -// JSMapIterator::nextWithAdvance in a loop, minus its writes to the iterator. Returns how many entries are left. -template -static uint32_t previewRemainingEntries(VM& vm, Iterator* iterator, uint32_t limit, MarkedArgumentBuffer& entries) +static ALWAYS_INLINE bool nextEntry(VM& vm, JSMapIterator* iterator, JSValue& key, JSValue& value) { - using Helper = typename Collection::Helper; + auto next = iterator->nextWithAdvance(vm); + key = next.key; + value = next.value; + return !!key; +} - JSCell* storageCell = iterator->tryGetStorage(); - // An iterator made while the collection had no storage yet picks it up on its first next(). - if (!storageCell) - storageCell = iterator->iteratedObject()->storage(); - if (!storageCell || storageCell == vm.orderedHashTableSentinel()) +static ALWAYS_INLINE bool nextEntry(VM& vm, JSSetIterator* iterator, JSValue& key, JSValue& value) +{ + // A Set stores keys only. Its entries() pairs each key with itself. + key = value = iterator->nextWithAdvance(vm); + return !!key; +} + +// Steps a private copy of `iterator` with the engine's own next(), so the caller's iterator does not move. Returns how many entries are left. +template +static uint32_t remainingEntries(VM& vm, Structure* structure, Iterator* iterator, uint32_t limit, MarkedArgumentBuffer& entries) +{ + Iterator* copy = Iterator::createWithInitialValues(vm, structure); + for (auto field : { Iterator::Field::Entry, Iterator::Field::IteratedObject, Iterator::Field::Storage, Iterator::Field::Kind }) { + // Storage is empty until the collection has one. + if (JSValue value = iterator->internalField(field).get()) + copy->internalField(field).set(vm, copy, value); + } + + JSValue key; + JSValue value; + if (!nextEntry(vm, copy, key, value)) return 0; + // The collection holds at least what the iterator has left, so one allocation is enough. IterationKind kind = iterator->kind(); - auto* storage = uncheckedDowncast(storageCell); - typename Helper::Entry entry = iterator->entry(); + size_t copied = std::min(limit, iterator->iteratedObject()->size()); + entries.ensureCapacity(kind == IterationKind::Entries ? copied * 2 : copied); uint32_t remaining = 0; - while (true) { - // Follows the tables a rehash or a clear() left behind, then skips deleted entries. - auto next = Helper::transitAndNext(vm, *storage, entry); - if (!next.storage) - break; - storage = next.storage; - entry = next.entry + 1; + do { if (remaining++ >= limit) continue; - // A Set stores keys only. Its entries() pairs each key with itself. - JSValue value = std::is_same_v ? next.key : next.value; if (kind != IterationKind::Values) - entries.append(next.key); + entries.append(key); if (kind != IterationKind::Keys) entries.append(value); - } + } while (nextEntry(vm, copy, key, value)); return remaining; } +// False if `value` is not a Map or Set iterator. `entries` is flat: [key, value, key, value, ...] when `isKeyValue`. +static bool remainingEntriesOf(JSGlobalObject* globalObject, JSValue value, uint32_t limit, MarkedArgumentBuffer& entries, bool& isKeyValue, uint32_t& length) +{ + auto& vm = JSC::getVM(globalObject); + if (auto* mapIterator = dynamicDowncast(value)) { + isKeyValue = mapIterator->kind() == IterationKind::Entries; + length = remainingEntries(vm, globalObject->mapIteratorStructure(), mapIterator, limit, entries); + return true; + } + if (auto* setIterator = dynamicDowncast(value)) { + isKeyValue = setIterator->kind() == IterationKind::Entries; + length = remainingEntries(vm, globalObject->setIteratorStructure(), setIterator, limit, entries); + return true; + } + return false; +} + // internalBinding('util').previewEntries(iterator, true) plus a limit: [flat entries (at most `limit`), isKeyValue, entries left]. JSC_DEFINE_HOST_FUNCTION(jsFunctionPreviewEntries, (JSGlobalObject * globalObject, CallFrame* callFrame)) { auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); - JSValue iteratorValue = callFrame->argument(0); JSValue limitValue = callFrame->argument(1); uint32_t limit = std::numeric_limits::max(); if (limitValue.isNumber()) { @@ -182,13 +209,7 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionPreviewEntries, (JSGlobalObject * globalObjec MarkedArgumentBuffer entries; uint32_t length = 0; bool isKeyValue = false; - if (auto* mapIterator = dynamicDowncast(iteratorValue)) { - isKeyValue = mapIterator->kind() == IterationKind::Entries; - length = previewRemainingEntries(vm, mapIterator, limit, entries); - } else if (auto* setIterator = dynamicDowncast(iteratorValue)) { - isKeyValue = setIterator->kind() == IterationKind::Entries; - length = previewRemainingEntries(vm, setIterator, limit, entries); - } else + if (!remainingEntriesOf(globalObject, callFrame->argument(0), limit, entries, isKeyValue, length)) return throwVMTypeError(globalObject, scope, "previewEntries() expects a Map or Set iterator"_s); if (entries.hasOverflowed()) [[unlikely]] { From ce44fdd422933aa5afc3c43b387312c3bf5b5979 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:25:28 +0000 Subject: [PATCH 07/19] console.log, Bun.inspect: print a Map or Set iterator without running it print_map_iterator_like handed the iterator to JSValue::for_each, which runs the iterator protocol on it. The print used up the caller's iterator, and it read an own next property, so a getter ran. It now calls Bun__inspect__forEachRemainingEntry, a second arm over the reader of previewEntries in UtilInspect.cpp. The arm copies what the iterator has left before the first entry is formatted, and gives an entries() iterator's pair as the [key, value] array that next() makes, so the text does not change. A print that does not use the iterator up can meet it again inside its own entries, so Map and Set iterators join the cycle detection. --- src/jsc/ConsoleObject.rs | 29 +++++++++++++++++++++++++++-- src/jsc/bindings/UtilInspect.cpp | 31 +++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index 0445d3afb34a..c25a89bd309f 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -1890,6 +1890,8 @@ pub mod formatter { | Tag::Object | Tag::Map | Tag::Set + | Tag::MapIterator + | Tag::SetIterator | Tag::Error | Tag::Class | Tag::Event @@ -3194,6 +3196,27 @@ pub mod formatter { max_depth: u32, colors: bool, ) -> JSValue; + + /// C++ helper (`UtilInspect.cpp`): `callback` gets what a Map or Set + /// iterator has left, as `next()` would yield it. The iterator is not + /// run and does not move. + safe fn Bun__inspect__forEachRemainingEntry( + iterator: JSValue, + global: &JSGlobalObject, + ctx: *mut c_void, + callback: jsc::ForEachCallback, + ); + } + + pub(crate) fn for_each_remaining_entry( + iterator: JSValue, + global: &JSGlobalObject, + ctx: *mut c_void, + callback: jsc::ForEachCallback, + ) -> JsResult<()> { + jsc::host_fn::from_js_host_call_generic(global, || { + Bun__inspect__forEachRemainingEntry(iterator, global, ctx, callback) + }) } // ─────────────────────────────────────────────────────────────────────── @@ -4673,7 +4696,8 @@ pub mod formatter { writer: writer_, count: 0, }; - value.for_each( + for_each_remaining_entry( + value, global_this, (&raw mut iter).cast::(), MapIteratorCtx::::for_each, @@ -4692,7 +4716,8 @@ pub mod formatter { writer: writer_, count: 0, }; - value.for_each( + for_each_remaining_entry( + value, global_this, (&raw mut iter).cast::(), MapIteratorCtx::::for_each, diff --git a/src/jsc/bindings/UtilInspect.cpp b/src/jsc/bindings/UtilInspect.cpp index 8e19bbae1855..44dae095957d 100644 --- a/src/jsc/bindings/UtilInspect.cpp +++ b/src/jsc/bindings/UtilInspect.cpp @@ -226,4 +226,35 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionPreviewEntries, (JSGlobalObject * globalObjec RELEASE_AND_RETURN(scope, JSValue::encode(constructArray(globalObject, static_cast(nullptr), result))); } +// JSC__JSValue__forEach for a printer that was given a Map or Set iterator: `callback` gets what the iterator has left, and the iterator is not run. +// Every entry is read before the first call, because the callback can run user code that changes the collection. +extern "C" void Bun__inspect__forEachRemainingEntry(JSC::EncodedJSValue encodedIterator, JSC::JSGlobalObject* globalObject, void* ctx, void (*callback)(JSC::VM*, JSC::JSGlobalObject*, void*, JSC::EncodedJSValue)) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSValue iterator = JSValue::decode(encodedIterator); + MarkedArgumentBuffer entries; + uint32_t length = 0; + bool isKeyValue = false; + if (!remainingEntriesOf(globalObject, iterator, std::numeric_limits::max(), entries, isKeyValue, length)) + return; + if (entries.hasOverflowed()) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return; + } + + // next() makes the pair of an entries() iterator in the realm of the iterator. + JSGlobalObject* realm = asObject(iterator)->globalObject(); + for (size_t i = 0; i < entries.size(); i += isKeyValue ? 2 : 1) { + JSValue entry = entries.at(i); + if (isKeyValue) { + entry = constructArrayPair(realm, entry, entries.at(i + 1)); + RETURN_IF_EXCEPTION(scope, void()); + } + callback(&vm, globalObject, ctx, JSValue::encode(entry)); + RETURN_IF_EXCEPTION(scope, void()); + } +} + } From bd3ca420e5dfbca2cee497929e7e33b837a10f64 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:26:37 +0000 Subject: [PATCH 08/19] console.table, the error printer: read a Map or Set iterator they are given TablePrinter asked the table data for Symbol.iterator and then iterated it, and the AggregateError printer iterated errors. Both ran the protocol on a Map or Set iterator and used it up. Both now go through for_each_listed: a Map or Set iterator is read with for_each_remaining_entry, every other value is iterated as before. The rows and members are what next() would yield, so the text does not change. JSValue::for_each_with_context had one caller and goes away. --- src/jsc/ConsoleObject.rs | 28 +++++++++++++++++++++++++--- src/jsc/JSValue.rs | 13 +------------ src/jsc/VirtualMachine.rs | 10 +++++++--- 3 files changed, 33 insertions(+), 18 deletions(-) diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index c25a89bd309f..c820d7c54729 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -677,12 +677,15 @@ impl<'a> TablePrinter<'a> { ) -> JsResult { bun_removable_features::removable_definition!(ConsoleTable, Self::init); let _ = level; + let jstype = tabular_data.js_type(); Ok(TablePrinter { global_object, tabular_data, properties, - is_iterable: tabular_data.is_iterable(global_object)?, - jstype: tabular_data.js_type(), + // No `Symbol.iterator` lookup on a Map or Set iterator: it is not run. + is_iterable: formatter::is_map_or_set_iterator(jstype) + || tabular_data.is_iterable(global_object)?, + jstype, value_formatter: { // `Formatter` has a `Drop` impl, so struct-update // from a temporary is rejected (E0509). @@ -975,7 +978,8 @@ impl<'a> TablePrinter<'a> { } ctx.idx += 1; } - tabular_data.for_each_with_context( + formatter::for_each_listed( + tabular_data, global_object, (&raw mut ctx).cast::(), callback::, @@ -3219,6 +3223,24 @@ pub mod formatter { }) } + pub(crate) fn is_map_or_set_iterator(js_type: jsc::JSType) -> bool { + matches!(js_type, jsc::JSType::MapIterator | jsc::JSType::SetIterator) + } + + /// `JSValue::for_each` for a printer: printing a Map or Set iterator must + /// not use it up, so that one is read and every other value is iterated. + pub(crate) fn for_each_listed( + value: JSValue, + global: &JSGlobalObject, + ctx: *mut c_void, + callback: jsc::ForEachCallback, + ) -> JsResult<()> { + if is_map_or_set_iterator(value.js_type()) { + return for_each_remaining_entry(value, global, ctx, callback); + } + value.for_each(global, ctx, callback) + } + // ─────────────────────────────────────────────────────────────────────── // print_as — the big tag-dispatched printer // ─────────────────────────────────────────────────────────────────────── diff --git a/src/jsc/JSValue.rs b/src/jsc/JSValue.rs index bd1c55e3b31a..42306168ed16 100644 --- a/src/jsc/JSValue.rs +++ b/src/jsc/JSValue.rs @@ -2158,7 +2158,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); @@ -2379,17 +2379,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 7ea86d9ec243..bdac967eb74c 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -6405,9 +6405,13 @@ impl VirtualMachine { allow_side_effects, printed_member: false, }; - if errors - .for_each(global_ref, (&raw mut ctx).cast(), agg_iter) - .is_err() + if crate::console_object::formatter::for_each_listed( + errors, + global_ref, + (&raw mut ctx).cast(), + agg_iter, + ) + .is_err() { global_ref.clear_exception(); } From 2b74f8ddd5ad37ffa459f0f2f0feb08b5709b8de Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:32:09 +0000 Subject: [PATCH 09/19] test: a print does not run or use up a Map or Set iterator Every console and Bun.inspect sink, table data, a table cell and AggregateError.errors: no call to next, Symbol.iterator or return, and the iterator has every entry left. The text of an iterator that user code did not touch is pinned as it was. The program under test/aot prints the same iterators in an executable built with --compile --aot, where a Map or a Set has compact storage. --- ...-iterators-printed-without-running-them.js | 101 ++++++ test/js/bun/console/bun-inspect-table.test.ts | 14 + test/js/bun/console/console-table.test.ts | 42 +++ test/js/bun/util/inspect-error.test.js | 11 + test/js/bun/util/inspect.test.js | 289 ++++++++++++++++++ test/js/web/console/console-log.expected.txt | 36 +++ test/js/web/console/console-log.js | 8 + 7 files changed, 501 insertions(+) create mode 100644 test/aot/parallel/local-containers/map-and-set-iterators-printed-without-running-them.js diff --git a/test/aot/parallel/local-containers/map-and-set-iterators-printed-without-running-them.js b/test/aot/parallel/local-containers/map-and-set-iterators-printed-without-running-them.js new file mode 100644 index 000000000000..905f8ca25afa --- /dev/null +++ b/test/aot/parallel/local-containers/map-and-set-iterators-printed-without-running-them.js @@ -0,0 +1,101 @@ +// console.log, Bun.inspect, console.table, util.inspect and Console#table read a Map or Set iterator from its collection and do not run it. In an executable the collection has the compiled kind of storage: what each prints, and what the iterator yields afterwards, is what plain Bun prints. +const util = require("node:util"); +const { Console } = require("node:console"); +const own = new Console({ stdout: process.stdout, stderr: process.stderr, colorMode: false }); + +function printed(label, iterator) { + console.log("## " + label); + console.log(iterator); + console.log("%o", iterator); + console.log(Bun.inspect(iterator, { compact: true }), Bun.inspect({ nested: [iterator] }, { compact: true })); + console.log(util.inspect(iterator), util.inspect(iterator, { maxArrayLength: 1 })); + console.table(iterator); + own.table(iterator); + const left = []; + for (const entry of iterator) left.push(entry); + console.log(left.length, JSON.stringify(left.slice(0, 3)), JSON.stringify(left.slice(-2))); +} + +const map = () => + new Map([ + ["a", 1], + ["b", 2], + ["c", 3], + ]); +const set = () => new Set(["x", "y", "z"]); +const kinds = { + "map.keys()": () => map().keys(), + "map.values()": () => map().values(), + "map.entries()": () => map().entries(), + "map[Symbol.iterator]()": () => map()[Symbol.iterator](), + "set.keys()": () => set().keys(), + "set.values()": () => set().values(), + "set.entries()": () => set().entries(), + "set[Symbol.iterator]()": () => set()[Symbol.iterator](), +}; +for (const kind in kinds) { + printed(kind, kinds[kind]()); + const advanced = kinds[kind](); + advanced.next(); + printed(kind + " after next()", advanced); + const done = kinds[kind](); + for (const _ of done); + printed(kind + " done", done); +} + +const late = new Map(); +const lateEntries = late.entries(); +late.set("x", 1); +printed("made before the first entry", lateEntries); + +const changed = new Map([ + ["a", 1], + ["b", 2], + ["c", 3], + ["d", 4], +]); +const changedKeys = changed.keys(); +changedKeys.next(); +changedKeys.next(); +changed.delete("a"); +changed.delete("c"); +changed.set("e", 5); +printed("after delete before and behind the position", changedKeys); + +const cleared = new Set([1, 2]); +const clearedValues = cleared.values(); +clearedValues.next(); +cleared.clear(); +cleared.add(3); +printed("after clear() and add()", clearedValues); + +const grown = new Set([0, 1]); +const grownValues = grown.values(); +grownValues.next(); +for (let i = 2; i < 40; i++) grown.add(i); +for (let i = 2; i < 36; i++) grown.delete(i); +printed("after growth and deletes", grownValues); + +// Past 256 and past 65536 entries the table has wider slots. +for (const size of [300, 70000]) { + const large = new Map(); + for (let i = 0; i < size; i++) large.set("k" + i, i); + const entries = large.entries(); + for (let i = 0; i < size - 4; i++) entries.next(); + printed("the last entries of " + size, entries); + const members = new Set(large.keys()).values(); + for (let i = 0; i < size - 2; i++) members.next(); + printed("the last members of " + size, members); +} + +const atEnd = new Set([1]); +const atEndValues = atEnd.values(); +atEndValues.next(); +console.log(Bun.inspect(atEndValues), util.inspect(atEndValues)); +atEnd.add(2); +printed("at its end, then the Set grows", atEndValues); + +const holder = new Map(); +const selfValues = holder.values(); +holder.set("self", selfValues); +console.log(Bun.inspect(selfValues, { compact: true }), Bun.inspect(holder, { compact: true })); diff --git a/test/js/bun/console/bun-inspect-table.test.ts b/test/js/bun/console/bun-inspect-table.test.ts index 29a5d16c98e0..af96b821c6cf 100644 --- a/test/js/bun/console/bun-inspect-table.test.ts +++ b/test/js/bun/console/bun-inspect-table.test.ts @@ -47,6 +47,20 @@ describe("inspect.table", () => { expect(inspect.table(function () {})).not.toBeEmpty(); }); + it("leaves a Map or Set iterator that is the data where it is", () => { + const entries = new Map([ + ["a", 1], + ["b", 2], + ]).entries(); + const table = inspect.table(entries, { colors: false }); + expect(table).toBe("┌───┬───┬───┐\n│ │ 0 │ 1 │\n├───┼───┼───┤\n│ 0 │ a │ 1 │\n│ 1 │ b │ 2 │\n└───┴───┴───┘\n"); + expect(inspect.table(entries, { colors: false })).toBe(table); + expect([...entries]).toEqual([ + ["a", 1], + ["b", 2], + ]); + }); + it("takes a subclass of Array, and Array.prototype, as the list of properties", () => { const rows = [{ a: 1, b: 2 }]; class Properties extends Array {} diff --git a/test/js/bun/console/console-table.test.ts b/test/js/bun/console/console-table.test.ts index 416c9c27d6d8..e5b3785aa95a 100644 --- a/test/js/bun/console/console-table.test.ts +++ b/test/js/bun/console/console-table.test.ts @@ -363,3 +363,45 @@ console.log("calls=" + calls);`, expect({ stdout, stderr, exitCode }).toEqual({ stdout: box("1") + "calls=1\n", stderr: "", exitCode: 0 }); }); }); + +// A Map or Set iterator as the data is read from its collection. The table does +// not call next(), so the caller's iterator keeps its entries. +describe("console.table of a Map or Set iterator", () => { + test("the rows are what the iterator has left, and the iterator keeps them", () => { + const entries = new Map([ + ["a", 1], + ["b", 2], + ["c", 3], + ]).entries(); + entries.next(); + const rows = `┌───┬───┬───┐\n│ │ 0 │ 1 │\n├───┼───┼───┤\n│ 0 │ b │ 2 │\n│ 1 │ c │ 3 │\n└───┴───┴───┘\n`; + expect([renderTable(entries), renderTable(entries)]).toEqual([rows, rows]); + expect(entries.next()).toEqual({ value: ["b", 2], done: false }); + + const values = new Set(["x", "y"]).values(); + expect(renderTable(values)).toBe( + `┌───┬────────┐\n│ │ Values │\n├───┼────────┤\n│ 0 │ x │\n│ 1 │ y │\n└───┴────────┘\n`, + ); + expect([...values]).toEqual(["x", "y"]); + }); + + test("Symbol.iterator and next on the iterator itself are not read", () => { + const iterator = new Set([1, 2]).values(); + const reads: string[] = []; + Object.defineProperty(iterator, Symbol.iterator, { get: () => void reads.push("Symbol.iterator") }); + Object.defineProperty(iterator, "next", { get: () => void reads.push("next") }); + expect({ table: renderTable(iterator), reads }).toEqual({ + table: `┌───┬────────┐\n│ │ Values │\n├───┼────────┤\n│ 0 │ 1 │\n│ 1 │ 2 │\n└───┴────────┘\n`, + reads: [], + }); + }); + + test("a generator as the data is still run", () => { + function* rows() { + yield { a: 1 }; + } + const generator = rows(); + renderTable(generator); + expect(generator.next()).toEqual({ value: undefined, done: true }); + }); +}); diff --git a/test/js/bun/util/inspect-error.test.js b/test/js/bun/util/inspect-error.test.js index 7efcaf49c928..71039f2b2993 100644 --- a/test/js/bun/util/inspect-error.test.js +++ b/test/js/bun/util/inspect-error.test.js @@ -591,6 +591,17 @@ describe.concurrent("AggregateError whose errors cannot be walked", () => { }); }); +// The members are read from the collection of the iterator. The printer does +// not call next(), so the iterator in `errors` keeps them. +test("AggregateError whose errors is a Set iterator: the members are printed and the iterator keeps them", () => { + const members = new Set([new Error("first member"), new TypeError("second member")]).values(); + const error = Object.assign(new AggregateError([], "outer message"), { errors: members }); + const text = Bun.inspect(error); + expect(text).toContain("error: first member"); + expect(text).toContain("TypeError: second member"); + expect(Array.from(members, member => member.message)).toEqual(["first member", "second member"]); +}); + describe("an error with a circular reference to itself", () => { // Drops the source preview, stack frames, blank lines and the version line. function rendered(text) { diff --git a/test/js/bun/util/inspect.test.js b/test/js/bun/util/inspect.test.js index b3c80d53db96..eab82c3f9aba 100644 --- a/test/js/bun/util/inspect.test.js +++ b/test/js/bun/util/inspect.test.js @@ -11,6 +11,7 @@ import { } from "harness"; import { join } from "path"; import util from "util"; +import { runInNewContext } from "vm"; it("prototype", () => { const prototypes = [ Request.prototype, @@ -1536,3 +1537,291 @@ it.skipIf(!isASAN)("prints a substring-rope jsx type after a prop's inspect.cust expect(stdout).toEndWith("\n"); expect(exitCode).toBe(0); }); + +// The formatter reads what a Map or Set iterator has left from the collection. +// It does not call next(), so a print does not use up the caller's iterator and +// runs no code that a script put on the iterator. +describe("printing a Map or Set iterator does not run it", () => { + const map = () => + new Map([ + ["a", 1], + ["b", 2], + ]); + const set = () => new Set(["a", "b"]); + const kinds = { + "map.keys()": () => map().keys(), + "map.values()": () => map().values(), + "map.entries()": () => map().entries(), + "map[Symbol.iterator]()": () => map()[Symbol.iterator](), + "set.keys()": () => set().keys(), + "set.values()": () => set().values(), + "set.entries()": () => set().entries(), + "set[Symbol.iterator]()": () => set()[Symbol.iterator](), + }; + + it.each(Object.keys(kinds))("%s has every entry left after it is printed", kind => { + const iterator = kinds[kind](); + const text = Bun.inspect(iterator); + Bun.inspect({ nested: [iterator] }); + Bun.inspect.table([{ cell: iterator }]); + Bun.inspect.table(iterator); + expect({ text: Bun.inspect(iterator), left: [...iterator] }).toEqual({ text, left: [...kinds[kind]()] }); + }); + + it("the text is the same as before", () => { + expect(Bun.inspect(map().entries())).toBe('MapIterator { \n [ "a", 1 ],\n [ "b", 2 ],\n}'); + expect(Bun.inspect(map().entries(), { compact: true })).toBe('MapIterator { [ "a", 1 ], [ "b", 2 ] }'); + expect(Bun.inspect(map().keys(), { compact: true })).toBe('MapIterator { "a", "b" }'); + expect(Bun.inspect(set().values())).toBe('SetIterator { \n "a",\n "b",\n}'); + expect(Bun.inspect(set().entries(), { compact: true })).toBe('SetIterator { [ "a", "a" ], [ "b", "b" ]}'); + expect(Bun.inspect(new Map().entries())).toBe("MapIterator { }"); + expect(Bun.inspect(new Set().values())).toBe("SetIterator { }"); + }); + + it("an iterator that advanced prints what it has left", () => { + const entries = new Map([ + ["a", 1], + ["b", 2], + ["c", 3], + ]).entries(); + entries.next(); + expect(Bun.inspect(entries, { compact: true })).toBe('MapIterator { [ "b", 2 ], [ "c", 3 ] }'); + expect(entries.next()).toEqual({ value: ["b", 2], done: false }); + + const done = map().keys(); + for (const _ of done); + expect(Bun.inspect(done)).toBe("MapIterator { }"); + expect(done.next()).toEqual({ value: undefined, done: true }); + }); + + it("the print shows what next() yields after the collection changed", () => { + const late = new Map(); + let iterator = late.entries(); + late.set("x", 1); + expect(Bun.inspect(iterator, { compact: true })).toBe('MapIterator { [ "x", 1 ] }'); + + const changed = new Map([ + ["a", 1], + ["b", 2], + ["c", 3], + ["d", 4], + ]); + iterator = changed.keys(); + iterator.next(); + iterator.next(); + changed.delete("a"); + changed.delete("c"); + changed.set("e", 5); + expect(Bun.inspect(iterator, { compact: true })).toBe('MapIterator { "d", "e" }'); + + const cleared = new Set([1, 2]); + iterator = cleared.values(); + iterator.next(); + cleared.clear(); + cleared.add(3); + expect(Bun.inspect(iterator, { compact: true })).toBe("SetIterator { 3}"); + + // Growth and deletes replace the table the iterator points at. + const grown = new Set([0, 1]); + iterator = grown.values(); + iterator.next(); + for (let i = 2; i < 40; i++) grown.add(i); + for (let i = 2; i < 36; i++) grown.delete(i); + expect(Bun.inspect(iterator, { compact: true })).toBe("SetIterator { 1, 36, 37, 38, 39}"); + expect([...iterator]).toEqual([1, 36, 37, 38, 39]); + }); + + it("the same iterator prints the same entries each time", () => { + const iterator = map().entries(); + const once = 'MapIterator { [ "a", 1 ], [ "b", 2 ] }'; + expect(Bun.inspect(iterator, { compact: true })).toBe(once); + expect(Bun.inspect(iterator, { compact: true })).toBe(once); + expect(Bun.inspect([iterator, iterator], { compact: true })).toBe(`[ ${once}, ${once} ]`); + + const values = new Set([1, 2, 3]).values(); + const visited = []; + for (const value of values) { + Bun.inspect(values); + visited.push(value); + } + expect(visited).toEqual([1, 2, 3]); + }); + + it("an iterator with no prototype, a frozen one and one from another realm print", () => { + const bare = Object.setPrototypeOf(map().keys(), null); + expect(Bun.inspect(bare, { compact: true })).toBe('MapIterator { "a", "b" }'); + + const frozen = Object.freeze(set().values()); + expect(Bun.inspect(frozen, { compact: true })).toBe('SetIterator { "a", "b"}'); + expect(frozen.next()).toEqual({ value: "a", done: false }); + + const foreign = runInNewContext("const i = new Map([[1, 2], [3, 4]]).entries(); i.next(); i"); + expect(Bun.inspect(foreign, { compact: true })).toBe("MapIterator { [ 3, 4 ] }"); + }); + + it("an iterator that its own collection holds prints [Circular]", () => { + const holder = new Map(); + const values = holder.values(); + holder.set("self", values); + expect(Bun.inspect(values, { compact: true })).toBe("MapIterator { [Circular] }"); + expect(Bun.inspect(holder, { compact: true })).toBe('Map(1) { "self": MapIterator { [Circular] } }'); + expect(values.next()).toEqual({ value: values, done: false }); + }); + + it("the entries are read before the first one is formatted", () => { + const changing = new Map(); + const iterator = changing.entries(); + changing.set("first", { + [Bun.inspect.custom]() { + changing.set("added", 1); + iterator.next(); + return "custom"; + }, + }); + changing.set("second", 2); + expect(Bun.inspect(iterator, { compact: true })).toBe('MapIterator { [ "first", custom ], [ "second", 2 ] }'); + expect([...iterator].map(entry => entry[0])).toEqual(["second", "added"]); + }); + + it("an entry that throws when it is formatted leaves the iterator where it is", () => { + const holder = new Map([["ok", 1]]); + const iterator = holder.values(); + let returns = 0; + iterator.return = () => { + returns++; + return {}; + }; + holder.set("bad", { + [Bun.inspect.custom]() { + throw new Error("boom"); + }, + }); + expect(() => Bun.inspect(iterator)).toThrow("boom"); + expect({ returns, next: iterator.next() }).toEqual({ returns: 0, next: { value: 1, done: false } }); + }); + + it("a next on the iterator itself is not read", () => { + const iterator = new Set([1]).values(); + let reads = 0; + Object.defineProperty(iterator, "next", { + get() { + reads++; + }, + enumerable: true, + configurable: true, + }); + expect({ text: Bun.inspect(iterator, { compact: true }), reads }).toEqual({ text: "SetIterator { 1}", reads: 0 }); + }); + + // Node.js closes an iterator like this when it prints it. Here a print changes nothing. + it("an iterator at its end that has not reported done stays open", () => { + const growing = new Set([1]); + const iterator = growing.values(); + iterator.next(); + expect(Bun.inspect(iterator)).toBe("SetIterator { }"); + growing.add(2); + expect(Bun.inspect(iterator, { compact: true })).toBe("SetIterator { 2}"); + expect(iterator.next()).toEqual({ value: 2, done: false }); + }); + + it("what has to iterate still iterates", () => { + function* rows() { + yield [1, 2]; + } + const generator = rows(); + Bun.inspect.table(generator); + const arrayValues = [[1, 2]].values(); + Bun.inspect.table(arrayValues); + const matched = new Set([1, 2]).values(); + expect(matched).toContain(2); + + let runs = 0; + class Counted extends Map { + *[Symbol.iterator]() { + runs++; + yield ["own", 1]; + } + } + const subclass = Bun.inspect(new Counted([["stored", 0]]), { compact: true }); + + expect({ + generator: generator.next().done, + arrayValues: arrayValues.next().done, + matched: matched.next().done, + subclass, + runs, + }).toEqual({ generator: true, arrayValues: true, matched: true, subclass: 'Map(1) { "own": 1 }', runs: 1 }); + }); + + it("no console or Bun.inspect sink calls next, Symbol.iterator or return", async () => { + // In a child process: the wrappers go on prototypes that every Map and Set iterator shares. + const fixture = /* js */ ` + const calls = { next: 0, "Symbol.iterator": 0, return: 0 }; + const mapIteratorPrototype = Object.getPrototypeOf(new Map().entries()); + const setIteratorPrototype = Object.getPrototypeOf(new Set().values()); + const iteratorPrototype = Object.getPrototypeOf(mapIteratorPrototype); + const nextOf = new Map(); + for (const prototype of [mapIteratorPrototype, setIteratorPrototype]) { + const next = prototype.next; + nextOf.set(prototype, next); + prototype.next = function () { + calls.next++; + return next.call(this); + }; + } + const symbolIterator = iteratorPrototype[Symbol.iterator]; + iteratorPrototype[Symbol.iterator] = function () { + calls["Symbol.iterator"]++; + return symbolIterator.call(this); + }; + const kinds = { + "map.keys()": () => new Map([[1, 2], [3, 4], [5, 6]]).keys(), + "map.values()": () => new Map([[1, 2], [3, 4], [5, 6]]).values(), + "map.entries()": () => new Map([[1, 2], [3, 4], [5, 6]]).entries(), + "set.values()": () => new Set([1, 3, 5]).values(), + "set.entries()": () => new Set([1, 3, 5]).entries(), + }; + const sinks = { + "console.log": it => console.log(it), + "console.log nested": it => console.log({ a: [it] }), + "console.log %o": it => console.log("%o", it), + "console.dir": it => console.dir(it), + "console.error": it => console.error(it), + "console.table": it => console.table(it), + "console.table cell": it => console.table([{ cell: it }]), + "Bun.inspect": it => Bun.inspect(it), + "Bun.inspect.table": it => Bun.inspect.table(it), + "AggregateError.errors": it => Bun.inspect(Object.assign(new AggregateError([]), { errors: it })), + }; + const usedUp = []; + for (const sink in sinks) { + for (const kind in kinds) { + const it = kinds[kind](); + it.return = () => { + calls.return++; + return {}; + }; + sinks[sink](it); + const next = nextOf.get(Object.getPrototypeOf(it)); + let left = 0; + while (!next.call(it).done) left++; + if (left !== 3) usedUp.push(sink + " of " + kind); + } + } + process.stdout.write("\\nRESULT " + JSON.stringify({ calls, usedUp }) + "\\n"); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const result = stdout.split("\n").findLast(line => line.startsWith("RESULT ")); + expect(JSON.parse(result?.slice("RESULT ".length) ?? "null")).toEqual({ + calls: { next: 0, "Symbol.iterator": 0, return: 0 }, + usedUp: [], + }); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/js/web/console/console-log.expected.txt b/test/js/web/console/console-log.expected.txt index 6b1d807fbec7..d4b7d1cedb6f 100644 --- a/test/js/web/console/console-log.expected.txt +++ b/test/js/web/console/console-log.expected.txt @@ -132,6 +132,42 @@ MapIterator { }, } MapIterator { } MapIterator { } +MapIterator { + [ "key", { + a: [], + str: "123123132", + nr: 3453, + } ], + [ "key_2", { + b: "test", + } ], +} +SetIterator { + [ 1, 1 ], + [ "123", "123" ], + [ + { + a: [], + str: "123123132", + nr: 3453, + }, { + a: [], + str: "123123132", + nr: 3453, + } + ], +} +MapIterator { + [ "key_2", { + b: "test", + } ], +} +MapIterator { + [ "key_2", { + b: "test", + } ], +} +false NestedClass { a: 1, b: 2, diff --git a/test/js/web/console/console-log.js b/test/js/web/console/console-log.js index 91c3bddd0278..35f83c2b2896 100644 --- a/test/js/web/console/console-log.js +++ b/test/js/web/console/console-log.js @@ -106,6 +106,14 @@ const m = new Map([ console.log(m.keys()); console.log(m.values()); console.log(new Map().keys(), new Map().values()); +console.log(m.entries()); +console.log(set.entries()); +// Printing an iterator leaves it where it is. +const advanced = m.entries(); +advanced.next(); +console.log(advanced); +console.log(advanced); +console.log(advanced.next().done); class NestedClass { a = 1; b = 2; From 5aee966fea0cb5415dc0372d920a7be6b42e5c59 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:27:50 +0000 Subject: [PATCH 10/19] Shorten the comments on the native arm --- src/jsc/ConsoleObject.rs | 7 ++----- src/jsc/bindings/UtilInspect.cpp | 3 +-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index c820d7c54729..b1e62128254d 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -3201,9 +3201,7 @@ pub mod formatter { colors: bool, ) -> JSValue; - /// C++ helper (`UtilInspect.cpp`): `callback` gets what a Map or Set - /// iterator has left, as `next()` would yield it. The iterator is not - /// run and does not move. + /// C++ helper (`UtilInspect.cpp`): `callback` gets what a Map or Set iterator has left. The iterator is not run. safe fn Bun__inspect__forEachRemainingEntry( iterator: JSValue, global: &JSGlobalObject, @@ -3227,8 +3225,7 @@ pub mod formatter { matches!(js_type, jsc::JSType::MapIterator | jsc::JSType::SetIterator) } - /// `JSValue::for_each` for a printer: printing a Map or Set iterator must - /// not use it up, so that one is read and every other value is iterated. + /// `JSValue::for_each` for a printer: a Map or Set iterator is read, not run, so the print does not use it up. pub(crate) fn for_each_listed( value: JSValue, global: &JSGlobalObject, diff --git a/src/jsc/bindings/UtilInspect.cpp b/src/jsc/bindings/UtilInspect.cpp index 44dae095957d..b1ecec0ffa59 100644 --- a/src/jsc/bindings/UtilInspect.cpp +++ b/src/jsc/bindings/UtilInspect.cpp @@ -226,8 +226,7 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionPreviewEntries, (JSGlobalObject * globalObjec RELEASE_AND_RETURN(scope, JSValue::encode(constructArray(globalObject, static_cast(nullptr), result))); } -// JSC__JSValue__forEach for a printer that was given a Map or Set iterator: `callback` gets what the iterator has left, and the iterator is not run. -// Every entry is read before the first call, because the callback can run user code that changes the collection. +// JSC__JSValue__forEach for a printer given a Map or Set iterator: `callback` gets what it has left, all read before the first call because formatting can run user code. extern "C" void Bun__inspect__forEachRemainingEntry(JSC::EncodedJSValue encodedIterator, JSC::JSGlobalObject* globalObject, void* ctx, void (*callback)(JSC::VM*, JSC::JSGlobalObject*, void*, JSC::EncodedJSValue)) { auto& vm = JSC::getVM(globalObject); From 7f3e98911d5e63d998790e928204f731bbea43f5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:40:42 +0000 Subject: [PATCH 11/19] Declare jsFunctionPreviewEntries in the table of built-ins The ahead-of-time compiler takes a native function without a declaration for code that can call, keep and change what it gets. The entry says what this function does. For a Map or Set iterator it runs no program code, changes no operand and answers a new [array, boolean, number]. For anything else it throws a TypeError. The function belongs to the feature NodeUtil, as jsFunctionGetOwnNonIndexProperties does. --- .../builtins/host/features-node.builtins.ts | 1 + .../host/native-functions.builtins.ts | 21 +++++++++++++++++++ .../built-ins-alone/fresh-results-calls.ts | 2 ++ 3 files changed, 24 insertions(+) diff --git a/src/codegen/builtins/host/features-node.builtins.ts b/src/codegen/builtins/host/features-node.builtins.ts index 8d904c94b223..e36e212c6c6e 100644 --- a/src/codegen/builtins/host/features-node.builtins.ts +++ b/src/codegen/builtins/host/features-node.builtins.ts @@ -218,6 +218,7 @@ export default b.declarations( ...nativeBindings("NewCppFunction", "JSEventTargetNode.cpp", "jsFunctionNodeEventsGetEventListeners"), ...nativeBindings("NewCppFunction", "NodeUtilTypesModule.cpp", "jsFunctionIsError"), ...nativeBindings("NewCppFunction", "UtilInspect.cpp", "jsFunctionGetOwnNonIndexProperties"), + ...nativeBindings("NewCppFunction", "UtilInspect.cpp", "jsFunctionPreviewEntries"), ), extendAll(["NodeSmallModules"], "node:util/types", "node:constants"), diff --git a/src/codegen/builtins/host/native-functions.builtins.ts b/src/codegen/builtins/host/native-functions.builtins.ts index 80d2dd1ab422..6f3cc8f2d9fb 100644 --- a/src/codegen/builtins/host/native-functions.builtins.ts +++ b/src/codegen/builtins/host/native-functions.builtins.ts @@ -189,6 +189,27 @@ export default b.declarations( uses: { object: [b.runs("OwnKeys"), b.readsContents()], filter: converted }, implementation: b.hostCall, }), + b.tuple("IteratorPreview", [newArrayOf(b.any).dense, b.bool, b.u32]), + // It steps a copy of the Map or Set iterator, so the iterator stays where it is and no code of the program runs. It answers what the collection has left, whether that comes in pairs, and how many entries are left. Anything else is refused with a TypeError. + b.static(cpp("UtilInspect.cpp", "jsFunctionPreviewEntries"), [ + { + args: { iterator: b.union(b.instance("MapIterator"), b.instance("SetIterator")), limit: b.any.optional }, + returns: b.layoutOf("IteratorPreview").fresh, + capability: "heap", + throws: true, + uses: { iterator: [b.readsContents(), b.reachableValuesStoredIn(b.result)], limit: b.inspected }, + reads: ["CollectionStorage", "CollectionEntries"], + implementation: b.hostCall, + }, + { + args: { iterator: b.any.optional, limit: b.any.optional }, + returns: b.never, + capability: "heap", + throws: true, + uses: { iterator: b.inspected, limit: b.inspected }, + implementation: b.hostCall, + }, + ]), // It lists the own keys of the holder and asks first `except` and then the holder for the property under each: no getter of either runs. Of each function that the holder has for a value it reads `length`, and those without parameters it calls on the receiver. Of an object that such a call answers it asks whether it has @@iterator. b.static(cpp("BunCompiledModules.cpp", "jsFunctionCallOwnFunctionsWithoutParameters"), { args: { holder: b.object, except: b.object, receiver: b.any.optional }, diff --git a/test/aot/built-ins-alone/fresh-results-calls.ts b/test/aot/built-ins-alone/fresh-results-calls.ts index a86c5df6c21b..41eb7ed4dd56 100644 --- a/test/aot/built-ins-alone/fresh-results-calls.ts +++ b/test/aot/built-ins-alone/fresh-results-calls.ts @@ -469,6 +469,8 @@ export const notCalled: Record = { ), "native:NewCppFunction:NodeTLS.cpp:parseCACertificates": ofOneModule("node:tls"), "native:NewCppFunction:UtilInspect.cpp:jsFunctionGetOwnNonIndexProperties": ofOneModule("internal/util/inspect"), + "native:NewCppFunction:UtilInspect.cpp:jsFunctionPreviewEntries": + "only internal/util/inspect and the function that makes console.Console hold it, and hand it to nobody", "%NpmJscNpmManifestTestHelpers%.parseManifest": "it reads a manifest that `bun install` has stored", "node:module.findSourceMap": "it answers undefined for every file that was tried", "Bun.secrets.delete": "it writes to the store of secrets of the operating system", From a7ff98048b66eb24d939e159d3aea1409780b615 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:21:23 +0000 Subject: [PATCH 12/19] Close a Set iterator that is printed on one line with a space Bun.inspect(set.values(), { compact: true }) and a table cell printed "SetIterator { 3}". A Map iterator on one line already ended with a space before the brace. Both print it now: "SetIterator { 3 }". --- src/jsc/ConsoleObject.rs | 3 +-- test/js/bun/util/inspect.test.js | 15 ++++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index 618f0b38d0f8..452f3f9b09cb 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -4718,8 +4718,7 @@ pub mod formatter { if iter.formatter.failed { return Ok(()); } - // Only the MapIterator case writes a trailing space. - if count > 0 && label == "MapIterator" { + if count > 0 { let _ = writer_.write_all(b" "); } } else { diff --git a/test/js/bun/util/inspect.test.js b/test/js/bun/util/inspect.test.js index eab82c3f9aba..8691f0f28947 100644 --- a/test/js/bun/util/inspect.test.js +++ b/test/js/bun/util/inspect.test.js @@ -1568,12 +1568,13 @@ describe("printing a Map or Set iterator does not run it", () => { expect({ text: Bun.inspect(iterator), left: [...iterator] }).toEqual({ text, left: [...kinds[kind]()] }); }); - it("the text is the same as before", () => { + it("prints each form of the text", () => { expect(Bun.inspect(map().entries())).toBe('MapIterator { \n [ "a", 1 ],\n [ "b", 2 ],\n}'); expect(Bun.inspect(map().entries(), { compact: true })).toBe('MapIterator { [ "a", 1 ], [ "b", 2 ] }'); expect(Bun.inspect(map().keys(), { compact: true })).toBe('MapIterator { "a", "b" }'); expect(Bun.inspect(set().values())).toBe('SetIterator { \n "a",\n "b",\n}'); - expect(Bun.inspect(set().entries(), { compact: true })).toBe('SetIterator { [ "a", "a" ], [ "b", "b" ]}'); + expect(Bun.inspect(set().values(), { compact: true })).toBe('SetIterator { "a", "b" }'); + expect(Bun.inspect(set().entries(), { compact: true })).toBe('SetIterator { [ "a", "a" ], [ "b", "b" ] }'); expect(Bun.inspect(new Map().entries())).toBe("MapIterator { }"); expect(Bun.inspect(new Set().values())).toBe("SetIterator { }"); }); @@ -1619,7 +1620,7 @@ describe("printing a Map or Set iterator does not run it", () => { iterator.next(); cleared.clear(); cleared.add(3); - expect(Bun.inspect(iterator, { compact: true })).toBe("SetIterator { 3}"); + expect(Bun.inspect(iterator, { compact: true })).toBe("SetIterator { 3 }"); // Growth and deletes replace the table the iterator points at. const grown = new Set([0, 1]); @@ -1627,7 +1628,7 @@ describe("printing a Map or Set iterator does not run it", () => { iterator.next(); for (let i = 2; i < 40; i++) grown.add(i); for (let i = 2; i < 36; i++) grown.delete(i); - expect(Bun.inspect(iterator, { compact: true })).toBe("SetIterator { 1, 36, 37, 38, 39}"); + expect(Bun.inspect(iterator, { compact: true })).toBe("SetIterator { 1, 36, 37, 38, 39 }"); expect([...iterator]).toEqual([1, 36, 37, 38, 39]); }); @@ -1652,7 +1653,7 @@ describe("printing a Map or Set iterator does not run it", () => { expect(Bun.inspect(bare, { compact: true })).toBe('MapIterator { "a", "b" }'); const frozen = Object.freeze(set().values()); - expect(Bun.inspect(frozen, { compact: true })).toBe('SetIterator { "a", "b"}'); + expect(Bun.inspect(frozen, { compact: true })).toBe('SetIterator { "a", "b" }'); expect(frozen.next()).toEqual({ value: "a", done: false }); const foreign = runInNewContext("const i = new Map([[1, 2], [3, 4]]).entries(); i.next(); i"); @@ -1710,7 +1711,7 @@ describe("printing a Map or Set iterator does not run it", () => { enumerable: true, configurable: true, }); - expect({ text: Bun.inspect(iterator, { compact: true }), reads }).toEqual({ text: "SetIterator { 1}", reads: 0 }); + expect({ text: Bun.inspect(iterator, { compact: true }), reads }).toEqual({ text: "SetIterator { 1 }", reads: 0 }); }); // Node.js closes an iterator like this when it prints it. Here a print changes nothing. @@ -1720,7 +1721,7 @@ describe("printing a Map or Set iterator does not run it", () => { iterator.next(); expect(Bun.inspect(iterator)).toBe("SetIterator { }"); growing.add(2); - expect(Bun.inspect(iterator, { compact: true })).toBe("SetIterator { 2}"); + expect(Bun.inspect(iterator, { compact: true })).toBe("SetIterator { 2 }"); expect(iterator.next()).toEqual({ value: 2, done: false }); }); From 16482dfcc5b3e43019a43f1e77c08170f4b4f52d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:49:53 +0000 Subject: [PATCH 13/19] Console#table: take isMapIterator and isSetIterator from native code createConsoleConstructor took the two tests from the writable node:util/types. What they approve goes to the native reader, which throws on anything but a Map or Set iterator. So a script that replaced a test made table() throw, or ran in a place where Node.js runs no user code. The constructor now makes its own two functions with $newCppFunction, as internal/util/inspect does. --- src/js/builtins/ConsoleObject.ts | 12 ++++- .../console/console-table-iterators.test.ts | 49 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/js/builtins/ConsoleObject.ts b/src/js/builtins/ConsoleObject.ts index 7966f1963ed8..94cef3d199e5 100644 --- a/src/js/builtins/ConsoleObject.ts +++ b/src/js/builtins/ConsoleObject.ts @@ -146,7 +146,6 @@ export function write(this: Console & { $writer: $Node.FileSink | undefined }, i export function createConsoleConstructor(console: Console) { const { inspect, formatWithOptions } = require("node:util"); require("node:buffer"); - const { isMapIterator, isSetIterator } = require("node:util/types"); const { validateObject, validateInteger, validateArray, validateOneOf } = require("internal/validators"); const kMaxGroupIndentation = 1000; @@ -173,6 +172,17 @@ export function createConsoleConstructor(console: Console) { // For a Map or Set iterator, without running or advancing it. const previewEntries: (iterator: object) => [entries: unknown[], isKeyValue: boolean, length: number] = $newCppFunction("UtilInspect.cpp", "jsFunctionPreviewEntries", 2); + // previewEntries() throws on anything but what these two accept, so a script must not be able to replace them. + const isMapIterator: (value: unknown) => boolean = $newCppFunction( + "NodeUtilTypesModule.cpp", + "jsFunctionIsMapIterator", + 1, + ); + const isSetIterator: (value: unknown) => boolean = $newCppFunction( + "NodeUtilTypesModule.cpp", + "jsFunctionIsSetIterator", + 1, + ); /** * Returns the number of columns required to display the given string. diff --git a/test/js/node/console/console-table-iterators.test.ts b/test/js/node/console/console-table-iterators.test.ts index 35ce06621959..3be799d4e174 100644 --- a/test/js/node/console/console-table-iterators.test.ts +++ b/test/js/node/console/console-table-iterators.test.ts @@ -167,6 +167,55 @@ test.concurrent("console.Console#table does not run a replaced next() of a Map o expect(exitCode).toBe(0); }); +// The two tests decide what the native reader gets, and it throws on anything else. +test.concurrent("console.Console#table does not call a replaced isMapIterator or isSetIterator", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + /* js */ ` + const types = require("node:util/types"); + const calls = { isMapIterator: 0, isSetIterator: 0 }; + for (const name of Object.keys(calls)) types[name] = () => (calls[name]++, true); + // Made after the replacement. + const { Console } = require("node:console"); + const c = new Console({ stdout: process.stdout, stderr: process.stderr, colorMode: false }); + c.table([7, 8]); + c.table(new Map([["a", 1]]).entries()); + c.table(new Set([9]).values()); + console.log(JSON.stringify(calls)); + `, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + if (exitCode !== 0) { + expect(stderr).toBe(""); + } + expect(stdout).toMatchInlineSnapshot(` + "┌─────────┬────────┐ + │ (index) │ Values │ + ├─────────┼────────┤ + │ 0 │ 7 │ + │ 1 │ 8 │ + └─────────┴────────┘ + ┌───────────────────┬─────┬────────┐ + │ (iteration index) │ Key │ Values │ + ├───────────────────┼─────┼────────┤ + │ 0 │ 'a' │ 1 │ + └───────────────────┴─────┴────────┘ + ┌───────────────────┬────────┐ + │ (iteration index) │ Values │ + ├───────────────────┼────────┤ + │ 0 │ 9 │ + └───────────────────┴────────┘ + {"isMapIterator":0,"isSetIterator":0} + " + `); + expect(exitCode).toBe(0); +}); + // A Map or a Set itself is read through its own iterator, and every entry the // iterator yields is a row, as in Node.js. `size` does not bound it: quick-lru // caps `size` at maxSize, and its iterator also yields the older generation. From 6fd89500695031ae16ef746a5e04f7a93de39d8d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:58:58 +0000 Subject: [PATCH 14/19] test: run the replaced-guard case of Console#table through the shared helper run() takes a second script that goes ahead of the prelude, so the case that replaces node:util/types before node:console is loaded no longer has its own copy of the spawn and drain. --- .../console/console-table-iterators.test.ts | 42 +++++++------------ 1 file changed, 16 insertions(+), 26 deletions(-) diff --git a/test/js/node/console/console-table-iterators.test.ts b/test/js/node/console/console-table-iterators.test.ts index 3be799d4e174..1c33d9a6c6de 100644 --- a/test/js/node/console/console-table-iterators.test.ts +++ b/test/js/node/console/console-table-iterators.test.ts @@ -6,9 +6,10 @@ const prelude = /* js */ ` const c = new Console({ stdout: process.stdout, stderr: process.stderr, colorMode: false }); `; -async function run(script: string) { +// `before` runs ahead of the prelude, so ahead of the first load of `node:console`. +async function run(script: string, before = "") { await using proc = Bun.spawn({ - cmd: [bunExe(), "-e", prelude + script], + cmd: [bunExe(), "-e", before + prelude + script], env: bunEnv, stderr: "pipe", }); @@ -169,30 +170,19 @@ test.concurrent("console.Console#table does not run a replaced next() of a Map o // The two tests decide what the native reader gets, and it throws on anything else. test.concurrent("console.Console#table does not call a replaced isMapIterator or isSetIterator", async () => { - await using proc = Bun.spawn({ - cmd: [ - bunExe(), - "-e", - /* js */ ` - const types = require("node:util/types"); - const calls = { isMapIterator: 0, isSetIterator: 0 }; - for (const name of Object.keys(calls)) types[name] = () => (calls[name]++, true); - // Made after the replacement. - const { Console } = require("node:console"); - const c = new Console({ stdout: process.stdout, stderr: process.stderr, colorMode: false }); - c.table([7, 8]); - c.table(new Map([["a", 1]]).entries()); - c.table(new Set([9]).values()); - console.log(JSON.stringify(calls)); - `, - ], - env: bunEnv, - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - if (exitCode !== 0) { - expect(stderr).toBe(""); - } + const { stdout, exitCode } = await run( + /* js */ ` + c.table([7, 8]); + c.table(new Map([["a", 1]]).entries()); + c.table(new Set([9]).values()); + console.log(JSON.stringify(calls)); + `, + /* js */ ` + const types = require("node:util/types"); + const calls = { isMapIterator: 0, isSetIterator: 0 }; + for (const name of Object.keys(calls)) types[name] = () => (calls[name]++, true); + `, + ); expect(stdout).toMatchInlineSnapshot(` "┌─────────┬────────┐ │ (index) │ Values │ From 304b3811b63a6f789b046c10166b0238b1ecb091 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:42:31 +0000 Subject: [PATCH 15/19] previewEntries: reserve for what the iterator has left, not for the collection The reader reserved its buffer for the size of the whole collection. For an iterator with 3 of 1e7 entries left that is 153 MiB, and when the reservation failed the print threw RangeError: Out of memory although the three entries fit. The reader now walks one private copy to count what is left, reserves that, and fills the buffer from a second private copy. A static_assert ties the field-by-field copy to the number of internal fields of a Map or Set iterator. --- src/jsc/bindings/UtilInspect.cpp | 34 +++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/src/jsc/bindings/UtilInspect.cpp b/src/jsc/bindings/UtilInspect.cpp index b1ecec0ffa59..3546b1ebe7a7 100644 --- a/src/jsc/bindings/UtilInspect.cpp +++ b/src/jsc/bindings/UtilInspect.cpp @@ -140,35 +140,45 @@ static ALWAYS_INLINE bool nextEntry(VM& vm, JSSetIterator* iterator, JSValue& ke return !!key; } -// Steps a private copy of `iterator` with the engine's own next(), so the caller's iterator does not move. Returns how many entries are left. +// A private iterator at the position of `iterator`. Stepping it does not move `iterator`. template -static uint32_t remainingEntries(VM& vm, Structure* structure, Iterator* iterator, uint32_t limit, MarkedArgumentBuffer& entries) +static Iterator* copyOf(VM& vm, Structure* structure, Iterator* iterator) { + static_assert(Iterator::numberOfInternalFields == 4, "copyOf copies a Map or Set iterator field by field"); Iterator* copy = Iterator::createWithInitialValues(vm, structure); for (auto field : { Iterator::Field::Entry, Iterator::Field::IteratedObject, Iterator::Field::Storage, Iterator::Field::Kind }) { // Storage is empty until the collection has one. if (JSValue value = iterator->internalField(field).get()) copy->internalField(field).set(vm, copy, value); } + return copy; +} +// Steps private copies of `iterator` with the engine's own next(), so the caller's iterator does not move. Returns how many entries are left. +template +static uint32_t remainingEntries(VM& vm, Structure* structure, Iterator* iterator, uint32_t limit, MarkedArgumentBuffer& entries) +{ JSValue key; JSValue value; - if (!nextEntry(vm, copy, key, value)) - return 0; + uint32_t remaining = 0; + for (Iterator* counted = copyOf(vm, structure, iterator); nextEntry(vm, counted, key, value);) + remaining++; - // The collection holds at least what the iterator has left, so one allocation is enough. + // The first walk counted what is left, so the second fills one allocation of that size. + uint32_t copied = std::min(limit, remaining); + if (!copied) + return remaining; IterationKind kind = iterator->kind(); - size_t copied = std::min(limit, iterator->iteratedObject()->size()); - entries.ensureCapacity(kind == IterationKind::Entries ? copied * 2 : copied); - uint32_t remaining = 0; - do { - if (remaining++ >= limit) - continue; + entries.ensureCapacity(kind == IterationKind::Entries ? static_cast(copied) * 2 : copied); + if (entries.hasOverflowed()) [[unlikely]] + return remaining; + Iterator* copy = copyOf(vm, structure, iterator); + for (uint32_t index = 0; index < copied && nextEntry(vm, copy, key, value); index++) { if (kind != IterationKind::Values) entries.append(key); if (kind != IterationKind::Keys) entries.append(value); - } while (nextEntry(vm, copy, key, value)); + } return remaining; } From 15d410f52c0fd0494956c349ece83aa19df6c461 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:42:31 +0000 Subject: [PATCH 16/19] Lint: a printer walks a value through formatter::for_each_listed JSValue::for_each runs the iteration protocol and uses an iterator up. The lint allows it in ConsoleObject.rs and VirtualMachine.rs only inside for_each_listed and the two walks of a Map or a Set itself. --- src/jsc/JSValue.rs | 1 + .../printer-for-each-door.test.ts | 43 +++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 test/internal/source-lints/printer-for-each-door.test.ts diff --git a/src/jsc/JSValue.rs b/src/jsc/JSValue.rs index 42306168ed16..6f41b22e5156 100644 --- a/src/jsc/JSValue.rs +++ b/src/jsc/JSValue.rs @@ -2369,6 +2369,7 @@ impl JSValue { host_fn::from_js_host_call_generic(global, || JSC__JSValue__isIterable(self, global)) } /// `JSValue.forEach` — invoke `callback` for each iterable element. + /// It runs the iteration protocol, so it uses an iterator up. A printer calls `console_object::formatter::for_each_listed`. pub fn for_each( self, global: &JSGlobalObject, diff --git a/test/internal/source-lints/printer-for-each-door.test.ts b/test/internal/source-lints/printer-for-each-door.test.ts new file mode 100644 index 000000000000..e662dbdbd0cb --- /dev/null +++ b/test/internal/source-lints/printer-for-each-door.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "fs"; +import path from "path"; + +// `JSValue::for_each` runs the iteration protocol: `Symbol.iterator`, `next` +// and `return`. On a Map or Set iterator that the program holds, it uses the +// iterator up and runs what a script put on it. A printer must not do that: +// `console.log(it)` left `it` done. +// +// `formatter::for_each_listed` is the door for a printer. It reads a Map or +// Set iterator from its collection and hands every other value to +// `JSValue::for_each`. +// +// value.for_each(global, ctx, callback) → formatter::for_each_listed(value, global, ctx, callback) + +const root = path.resolve(import.meta.dir, "..", "..", ".."); + +const PRINTERS = ["src/jsc/ConsoleObject.rs", "src/jsc/VirtualMachine.rs"]; + +// The door, and the two walks of a Map or a Set itself: each of those makes an +// iterator of its own, which nobody else holds. +const ALLOWED = new Set(["for_each_listed", "print_map_like", "print_set"]); + +// A call with a closure is `Iterator::for_each`, not `JSValue::for_each`. +const jsValueForEach = /\.for_each\((?!\s*(?:\||move\b))|JSValue::for_each\(/; +const functionStart = /^\s*(?:pub(?:\([a-z]+\))?\s+)?(?:unsafe\s+)?(?:extern\s+"C"\s+)?fn\s+(\w+)/; + +test("a printer walks a value through formatter::for_each_listed", () => { + const found: string[] = []; + for (const file of PRINTERS) { + let enclosing = ""; + readFileSync(path.join(root, file), "utf8") + .split("\n") + .forEach((line, index) => { + enclosing = functionStart.exec(line)?.[1] ?? enclosing; + if (line.trimStart().startsWith("//")) return; + if (jsValueForEach.test(line) && !ALLOWED.has(enclosing)) { + found.push(`${file}:${index + 1}: ${enclosing} calls JSValue::for_each: use formatter::for_each_listed`); + } + }); + } + expect(found).toEqual([]); +}); From ec4b93a8850fe9013d5429fa959747cc79090a0b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:42:31 +0000 Subject: [PATCH 17/19] test: more places where native code formats a Map or Set iterator - a Set iterator that its own Set holds prints [Circular], also without a depth limit - entries that the collection drops during the print survive a collection - a failing matcher, a rejected argument and an error that holds the iterator leave its entries - the echo of the REPL and a test.each title leave its entries --- test/js/bun/repl/repl.test.ts | 16 ++++++ test/js/bun/test/jest-each.test.ts | 11 ++++ test/js/bun/util/inspect.test.js | 83 ++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+) diff --git a/test/js/bun/repl/repl.test.ts b/test/js/bun/repl/repl.test.ts index 6f3ecb50cd83..906b41d8d4fc 100644 --- a/test/js/bun/repl/repl.test.ts +++ b/test/js/bun/repl/repl.test.ts @@ -789,6 +789,22 @@ describe.concurrent("Bun REPL", () => { }); }); + describe("iterators", () => { + // The REPL prints what a line evaluated to, and that print must not use an iterator up. + test("the echo of a Map or Set iterator leaves its entries", async () => { + const { outputs, stderr, exitCode } = await runRepl([ + "const entries = new Map([[1, 2]]).entries()", + "entries.next().value", + "const values = new Set(['a']).values()", + "values.next().value", + ".exit", + ]); + expect(outputs).toEqual(["MapIterator { \n [ 1, 2 ],\n}", "[ 1, 2 ]", 'SetIterator { \n "a",\n}', '"a"']); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); + }); + describe("multiline input", () => { test("keeps reading lines until the input is complete", async () => { const { outputs, stderr, exitCode } = await runRepl([ diff --git a/test/js/bun/test/jest-each.test.ts b/test/js/bun/test/jest-each.test.ts index 0bd0239ac72a..331beeaf0942 100644 --- a/test/js/bun/test/jest-each.test.ts +++ b/test/js/bun/test/jest-each.test.ts @@ -45,6 +45,17 @@ describe("jest-each", () => { it.each([undefined, null, NaN, Infinity])("stringify %#: %j", (arg, cb) => { cb(); }); + + // The title is formatted before the body runs, and that must not use a row's iterator up. + it.each([ + [new Set([1, 2, 3]).values(), 3], + [new Map([["a", 1]]).entries(), 1], + ])("a Map or Set iterator in the title keeps its entries: %p, %i", (iterator, count) => { + expect([...iterator]).toHaveLength(count); + }); + it.each([{ held: new Set([1, 2]).keys() }])("a Set iterator under $held keeps its entries", ({ held }) => { + expect([...held]).toEqual([1, 2]); + }); }); describe.each(["some", "cool", "strings"])("works with describe: %s", s => { diff --git a/test/js/bun/util/inspect.test.js b/test/js/bun/util/inspect.test.js index 8691f0f28947..a72fd71afaf4 100644 --- a/test/js/bun/util/inspect.test.js +++ b/test/js/bun/util/inspect.test.js @@ -1667,6 +1667,30 @@ describe("printing a Map or Set iterator does not run it", () => { expect(Bun.inspect(values, { compact: true })).toBe("MapIterator { [Circular] }"); expect(Bun.inspect(holder, { compact: true })).toBe('Map(1) { "self": MapIterator { [Circular] } }'); expect(values.next()).toEqual({ value: values, done: false }); + + const set = new Set(); + const members = set.values(); + set.add(members); + expect(Bun.inspect(members, { compact: true })).toBe("SetIterator { [Circular] }"); + expect(Bun.inspect(set, { compact: true })).toBe("Set(1) { SetIterator { [Circular] } }"); + expect(members.next()).toEqual({ value: members, done: false }); + + const pairs = new Set(); + const entries = pairs.entries(); + pairs.add(entries); + expect(Bun.inspect(entries, { compact: true })).toBe("SetIterator { [ [Circular], [Circular] ] }"); + }); + + it("without a depth limit the print of such an iterator still ends", () => { + const holder = new Map(); + const values = holder.values(); + holder.set("self", values); + expect(Bun.inspect(values, { compact: true, depth: Infinity })).toBe("MapIterator { [Circular] }"); + + const set = new Set(); + const members = set.values(); + set.add(members); + expect(Bun.inspect(members, { compact: true, depth: Infinity })).toBe("SetIterator { [Circular] }"); }); it("the entries are read before the first one is formatted", () => { @@ -1684,6 +1708,42 @@ describe("printing a Map or Set iterator does not run it", () => { expect([...iterator].map(entry => entry[0])).toEqual(["second", "added"]); }); + // The collection drops the later entries while the first one is formatted, so only the copy holds them. + it("entries that the collection dropped during the print survive a collection", () => { + const sinks = { + "Bun.inspect": iterator => Bun.inspect(iterator), + "Bun.inspect.table": iterator => Bun.inspect.table(iterator), + "AggregateError.errors": iterator => Bun.inspect(Object.assign(new AggregateError([]), { errors: iterator })), + }; + // More than a MarkedArgumentBuffer holds inline. + const count = 40; + const formatted = {}; + for (const sink in sinks) { + for (const drop of ["clear", "delete"]) { + const dropping = new Map(); + // Made before the first set(), so the iterator does not keep a table of its own alive. + const iterator = dropping.entries(); + const seen = []; + for (let i = 0; i < count; i++) { + dropping.set(i, { + [Bun.inspect.custom]() { + if (i === 0) { + if (drop === "clear") dropping.clear(); + else for (let j = 1; j < count; j++) dropping.delete(j); + Bun.gc(true); + } + seen.push(i); + return "entry" + i; + }, + }); + } + sinks[sink](iterator); + formatted[`${sink}, ${drop}`] = seen.length; + } + } + expect(formatted).toEqual(Object.fromEntries(Object.keys(formatted).map(key => [key, count]))); + }); + it("an entry that throws when it is formatted leaves the iterator where it is", () => { const holder = new Map([["ok", 1]]); const iterator = holder.values(); @@ -1725,6 +1785,17 @@ describe("printing a Map or Set iterator does not run it", () => { expect(iterator.next()).toEqual({ value: 2, done: false }); }); + it("a matcher that fails leaves the iterator its entries", () => { + const left = {}; + for (const matcher of ["toBeNull", "toBeUndefined", "toBeFalsy", "toBeInstanceOf"]) { + const iterator = new Map([[1, 2]]).entries(); + expect(() => expect(iterator)[matcher](Array)).toThrow(); + left[matcher] = [...iterator]; + } + const kept = [[1, 2]]; + expect(left).toEqual({ toBeNull: kept, toBeUndefined: kept, toBeFalsy: kept, toBeInstanceOf: kept }); + }); + it("what has to iterate still iterates", () => { function* rows() { yield [1, 2]; @@ -1793,6 +1864,18 @@ describe("printing a Map or Set iterator does not run it", () => { "Bun.inspect": it => Bun.inspect(it), "Bun.inspect.table": it => Bun.inspect.table(it), "AggregateError.errors": it => Bun.inspect(Object.assign(new AggregateError([]), { errors: it })), + "console.error of an error that holds it": it => console.error(Object.assign(new Error("holder"), { held: it })), + // No print in the program: native code formats the value for the message of an error. + "a rejected argument": it => { + try { + require("node:fs").openSync("/", it); + } catch {} + }, + "a rejected encoding": it => { + try { + require("node:crypto").createHash("sha1").setDefaultEncoding(it); + } catch {} + }, }; const usedUp = []; for (const sink in sinks) { From 0334d090efdf421d15ea9b29e8bdf9a7013bab01 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:59:07 +0000 Subject: [PATCH 18/19] Lint: tell JSValue::for_each from Iterator::for_each by its arguments The pattern took every call without a closure for JSValue::for_each, so items.iter().for_each(record_item) in a printer failed the lint. It now matches the three arguments of JSValue::for_each: a comma after the first one, or a line that ends at the parenthesis. --- test/internal/source-lints/printer-for-each-door.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/internal/source-lints/printer-for-each-door.test.ts b/test/internal/source-lints/printer-for-each-door.test.ts index e662dbdbd0cb..9766b89c8ad8 100644 --- a/test/internal/source-lints/printer-for-each-door.test.ts +++ b/test/internal/source-lints/printer-for-each-door.test.ts @@ -21,8 +21,10 @@ const PRINTERS = ["src/jsc/ConsoleObject.rs", "src/jsc/VirtualMachine.rs"]; // iterator of its own, which nobody else holds. const ALLOWED = new Set(["for_each_listed", "print_map_like", "print_set"]); -// A call with a closure is `Iterator::for_each`, not `JSValue::for_each`. -const jsValueForEach = /\.for_each\((?!\s*(?:\||move\b))|JSValue::for_each\(/; +// `JSValue::for_each` takes `(global, ctx, callback)`: a comma follows its first +// argument, or rustfmt ends the line at the `(`. `Iterator::for_each` takes one +// closure or one function, and does not match. +const jsValueForEach = /\.for_each\(\s*(?:$|[^|]+?,)|JSValue::for_each\(/; const functionStart = /^\s*(?:pub(?:\([a-z]+\))?\s+)?(?:unsafe\s+)?(?:extern\s+"C"\s+)?fn\s+(\w+)/; test("a printer walks a value through formatter::for_each_listed", () => { From 8548f2c413d6b365d9eeafde0ea58e82c6b774b9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:59:07 +0000 Subject: [PATCH 19/19] test: assert the failure text, the entry order and the iterator position - The matcher case asserts the text of the failure. A bare toThrow() also passed for a matcher that does not exist. - The two sinks that print nothing report the error that each call gets. The case fails if a call does not throw. - The collection case compares the order of the formatted entries, not only how many there are. - The circular cases check that the iterator still yields its entry after the print. --- test/js/bun/util/inspect.test.js | 47 ++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/test/js/bun/util/inspect.test.js b/test/js/bun/util/inspect.test.js index a72fd71afaf4..ce7ee346efbb 100644 --- a/test/js/bun/util/inspect.test.js +++ b/test/js/bun/util/inspect.test.js @@ -1679,6 +1679,7 @@ describe("printing a Map or Set iterator does not run it", () => { const entries = pairs.entries(); pairs.add(entries); expect(Bun.inspect(entries, { compact: true })).toBe("SetIterator { [ [Circular], [Circular] ] }"); + expect(entries.next()).toEqual({ value: [entries, entries], done: false }); }); it("without a depth limit the print of such an iterator still ends", () => { @@ -1686,11 +1687,13 @@ describe("printing a Map or Set iterator does not run it", () => { const values = holder.values(); holder.set("self", values); expect(Bun.inspect(values, { compact: true, depth: Infinity })).toBe("MapIterator { [Circular] }"); + expect(values.next()).toEqual({ value: values, done: false }); const set = new Set(); const members = set.values(); set.add(members); expect(Bun.inspect(members, { compact: true, depth: Infinity })).toBe("SetIterator { [Circular] }"); + expect(members.next()).toEqual({ value: members, done: false }); }); it("the entries are read before the first one is formatted", () => { @@ -1738,10 +1741,11 @@ describe("printing a Map or Set iterator does not run it", () => { }); } sinks[sink](iterator); - formatted[`${sink}, ${drop}`] = seen.length; + formatted[`${sink}, ${drop}`] = seen; } } - expect(formatted).toEqual(Object.fromEntries(Object.keys(formatted).map(key => [key, count]))); + const each = Array.from({ length: count }, (_, index) => index); + expect(formatted).toEqual(Object.fromEntries(Object.keys(formatted).map(key => [key, each]))); }); it("an entry that throws when it is formatted leaves the iterator where it is", () => { @@ -1789,7 +1793,8 @@ describe("printing a Map or Set iterator does not run it", () => { const left = {}; for (const matcher of ["toBeNull", "toBeUndefined", "toBeFalsy", "toBeInstanceOf"]) { const iterator = new Map([[1, 2]]).entries(); - expect(() => expect(iterator)[matcher](Array)).toThrow(); + // The message of the failure shows the value that the matcher received. + expect(() => expect(iterator)[matcher](Array)).toThrow("MapIterator { \n [ 1, 2 ],\n}"); left[matcher] = [...iterator]; } const kept = [[1, 2]]; @@ -1853,6 +1858,16 @@ describe("printing a Map or Set iterator does not run it", () => { "set.values()": () => new Set([1, 3, 5]).values(), "set.entries()": () => new Set([1, 3, 5]).entries(), }; + // No print in the program: native code formats the value for the message of an error. + const messages = []; + const rejected = call => { + try { + call(); + messages.push("no error"); + } catch (error) { + messages.push(error.code + ": " + error.message); + } + }; const sinks = { "console.log": it => console.log(it), "console.log nested": it => console.log({ a: [it] }), @@ -1865,17 +1880,8 @@ describe("printing a Map or Set iterator does not run it", () => { "Bun.inspect.table": it => Bun.inspect.table(it), "AggregateError.errors": it => Bun.inspect(Object.assign(new AggregateError([]), { errors: it })), "console.error of an error that holds it": it => console.error(Object.assign(new Error("holder"), { held: it })), - // No print in the program: native code formats the value for the message of an error. - "a rejected argument": it => { - try { - require("node:fs").openSync("/", it); - } catch {} - }, - "a rejected encoding": it => { - try { - require("node:crypto").createHash("sha1").setDefaultEncoding(it); - } catch {} - }, + "a rejected argument": it => rejected(() => require("node:fs").openSync("/", it)), + "a rejected encoding": it => rejected(() => require("node:crypto").createHash("sha1").setDefaultEncoding(it)), }; const usedUp = []; for (const sink in sinks) { @@ -1892,7 +1898,7 @@ describe("printing a Map or Set iterator does not run it", () => { if (left !== 3) usedUp.push(sink + " of " + kind); } } - process.stdout.write("\\nRESULT " + JSON.stringify({ calls, usedUp }) + "\\n"); + process.stdout.write("\\nRESULT " + JSON.stringify({ calls, usedUp, messages }) + "\\n"); `; await using proc = Bun.spawn({ cmd: [bunExe(), "-e", fixture], @@ -1902,9 +1908,20 @@ describe("printing a Map or Set iterator does not run it", () => { }); const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); const result = stdout.split("\n").findLast(line => line.startsWith("RESULT ")); + const shown = [ + "MapIterator { 1, 3, 5 }", + "MapIterator { 2, 4, 6 }", + "MapIterator { [ 1, 2 ], [ 3, 4 ], [ 5, 6 ] }", + "SetIterator { 1, 3, 5 }", + "SetIterator { [ 1, 1 ], [ 3, 3 ], [ 5, 5 ] }", + ]; expect(JSON.parse(result?.slice("RESULT ".length) ?? "null")).toEqual({ calls: { next: 0, "Symbol.iterator": 0, return: 0 }, usedUp: [], + messages: [ + ...shown.map(iterator => `ERR_INVALID_ARG_VALUE: The argument 'flags' is invalid. Received ${iterator}`), + ...shown.map(iterator => `ERR_UNKNOWN_ENCODING: Unknown encoding: ${iterator}`), + ], }); expect(exitCode).toBe(0); });