Conversation
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.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Essentials Run ID: 📒 Files selected for processing (6)
Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review. WalkthroughMap and Set iterator inspection now uses a native, non-advancing preview helper. ChangesIterator previewing and formatting
Suggested reviewers: Priority: ⚪ Not assessed Merge Risk: ⚪ Minimal · up to The iterator preview integration and its consumers are covered by focused tests, with no actionable merge-blocking issue identified. 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Status Reproduced on bun 1.4.2 and on main with this script. The guard ends the process so that it does not hang. const util = require("node:util");
const m = new Map([["a", 1]]);
let nexts = 0;
Object.getPrototypeOf(m.entries()).next = () => {
if (++nexts > 5000) { console.log("RUNAWAY nexts=" + nexts); process.exit(2); }
return { value: ["k", "v"], done: false };
};
console.log(util.inspect(m.entries()));
console.log("nexts=" + nexts);
The same guard with Without any tampering, Tests: bun 1.4.2 fails 8 of the 9 new tests in CI The diff is green. Build #114384 on the first head passed on every lane. The two tests of this PR pass on every lane of every build. Builds #114632 and #114654 are red because of one test that this PR does not touch: The review threads are all resolved. The PR is ready for a maintainer. PR: #42359 |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it adds a new native path that walks JSC's ordered-hash-table storage directly (Helper::transitAndNext, tryGetStorage, sentinel handling), a human familiar with those JSC internals should also take a look.
What was reviewed:
jsFunctionPreviewEntries: throw-scope discipline,MarkedArgumentBuffer+hasOverflowed()OOM,RETURN_IF_EXCEPTION/RELEASE_AND_RETURNplacement,dynamicDowncast, and NaN/±Infinity/negative handling on the limit — all correct.previewRemainingEntries: no allocation or user-JS entry during the walk, so no GC hazard between readingnext.key/next.valueand appending to the rooted buffer; theuncheckedDowncastis guarded by the sentinel check.inspect.js:maxArrayLength: nullis normalized toInfinityat line 548 beforeformatIteratorruns, so the newMathMax(ctx.maxArrayLength, 0)limit is never accidentally 0; deleted primordials are all now unused.- Tests: runaway guard exits 2 (fails for the right reason instead of hanging), stdout asserted before exitCode,
await using+ concurrent drain, snapshots match Node's shape.
Extended reasoning...
Overview
This PR replaces the JS-level previewEntries in util.inspect (which materialized an entire Map/Set via Array.from, ran user-overridable iterator protocol, and ignored iterator position) with a native C++ implementation in src/jsc/bindings/UtilInspect.cpp that reads the iterator's remaining entries directly from JSC's ordered-hash-table storage via Helper::transitAndNext. It wires the same helper into console.Console#table, fixes a latent isBuffer destructuring bug, bounds the for...of over real Maps/Sets by .size, and threads a separate length through formatSetIterInner/formatMapIterInner so the "... N more items" count is accurate for advanced iterators under maxArrayLength. Roughly 90 lines of new C++, ~40 lines of JS delta, and ~400 lines of new tests across two files.
Security risks
None identified. The native function is only reachable from built-in JS ($newCppFunction), takes a Map/Set iterator (validated with dynamicDowncast, else throws a TypeError), and a numeric limit that is defensively clamped (NaN/negative → 0, ≥ UINT32_MAX → UINT32_MAX, non-number → default). It calls no user code, performs no allocation inside the storage walk, and roots every collected JSValue in a MarkedArgumentBuffer with an overflow → OOM check. No untrusted parsing, no filesystem or network surface.
Level of scrutiny
Medium-high. The JS and test changes are straightforward and follow repo conventions closely (primordial-safe calls, $newCppFunction, dead-primordial cleanup, await using + concurrent drain, stdout-before-exitCode, runaway guard so a regression fails rather than hangs). The C++ is small and hits every REVIEW.md checkbox for JSC bindings. What lifts scrutiny above "approve" is that previewRemainingEntries depends on JSC-internal invariants: that tryGetStorage() is null only for the never-stepped case, that after ruling out orderedHashTableSentinel() the cell is safe to uncheckedDowncast to Collection::Storage, and that transitAndNext from a stale entry() correctly chains through rehash/clear tables without touching freed memory. These are the same invariants the iterator's own next() relies on and the tests exercise them (delete/clear/rehash/growth, cross-realm, exhausted iterator), but a reviewer who knows the JSC ordered-hash-table lifecycle should confirm the contract.
Other factors
The PR description documents byte-identical output against Node v26.3.0 across ~50 shapes, a pass under BUN_JSC_validateExceptionChecks=1, and an ASAN stress loop with Bun.gc(true) between inspects. The bug hunt ran to dry_streak with no findings and no ruled-out candidates. I spot-checked the maxArrayLength: null path (normalized to Infinity at inspect.js:548 before formatIterator, so the new limit argument is Infinity, not 0), the default-parameter back-compat for formatWeakSet/formatWeakMap callers, and that every deleted primordial (ArrayFrom, ArrayPrototypeFlat, MapPrototypeValues, MapPrototypeKeys, SetPrototypeEntries) is genuinely unused after the change. No CODEOWNERS entry covers these paths. The one open design point — .size is read via a user-overridable getter, and a subclass reporting size = Infinity with an infinite iterator still hangs — is called out in the PR as intentionally Node-compatible and matches formatMap in util.inspect.
|
For the reviewer who checks the JSC side:
The helper uses these through the JSC headers of the pinned WebKit build. If a WebKit upgrade changes them, The failed |
|
Updated 2:42 AM PT - Sep 12th, 2026
❌ @robobun, your commit 495e5be has 1 failures in 🧪 To try this PR locally: bunx bun-pr 42359That installs a local version of the PR into your bun-42359 --bun |
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.
|
Two pushes after a self-review. The description above is rewritten to match. 204b594 removes the const lru = new QuickLRU({ maxSize: 3 });
for (let i = 0; i < 5; i++) lru.set("k" + i, i);
lru.size; // 3
[...lru].length; // 5
new Console(process.stdout).table(lru);
// node 26.3 and bun 1.4.2: 5 rows. first revision of this PR: 3 rowsThe two loops are Node's again, and a test pins the two-generation shape. So 2c8f81e cuts the three comment blocks that comment-cop flagged to one line each. Not taken from the review: split the |
Problem
util.inspectof an advanced Map or Set iterator also prints the entries it already yielded.new Console(stream).table(map.entries())prints pairs in oneValuescolumn and leaves the iterator done. Node.js prints what is left, inKeyandValuescolumns, and does not move the iterator.%MapIteratorPrototype%.nextis replaced by a function that never reportsdone. No user reported this. A code read found it (console: do not hang on an iterator that never ends #42264).previewEntries(src/js/internal/util/inspect.js:2786): it copies the collection withArray.from.Console#table(src/js/builtins/ConsoleObject.ts:697) ran afor-ofover the iterator.Fix
previewEntriesis now native (src/jsc/bindings/UtilInspect.cpp). It reads what the iterator has left from the storage of the collection. It calls no user code, does not move the iterator, copies at mostmaxArrayLengthentries and counts the rest.Console#tablecalls it for iterators, with Node's five lines. Each key and value is now a cell, so this PR carries theisBufferimport line of node:console: fix Console#table throwing on object cells, Maps and Sets #33452. Without it an object cell throws.util.inspectandnew Console(). The native formatter (console.log,Bun.inspect) still advances an iterator it prints.internal-inspect.test.js,console-table-iterators.test.ts. bun 1.4.2 fails 8 of 9 new tests (1 control). Self-reviewed: 14 concerns raised, 6 addressed, the rest in Notes.Background
clear()links the old table to the new one.JSMap::Helper::transitAndNextis the read-only step thatnext()uses: follow those links, skip deleted entries, return the next entry. The helper loops over it.Consoleclass as its global console.Notes
Repro of the hang
Without tampering
Comparison with Node.js v26.3.0
A script of about 50 shapes prints byte-identical
util.inspectoutput on this branch and on Node: all four Map and all four Set iterator kinds, advanced and exhausted iterators,delete(),clear()and a rehash while the iterator is open, an iterator made before the Map had any entry, an iterator from anode:vmcontext,maxArrayLengthof 0, 1, 2, -1,Infinityandnull,showHidden,sorted,compact: false, nested values and extra own keys.Console#tablematches Node on the same shapes except for cell alignment, which #32619 covers. Node's owntest-console-table.jsexpects theKeyandValuescolumns formap.entries().isBufferConsole#tableformats an object cell withisArray(v) = $isJSArray(v) || $isTypedArrayView(v) || isBuffer(v), andisBuffercame fromrequire("node:buffer").isBuffer, which isundefined. So an object cell that is not an array throwsTypeError: isBuffer is not a function. #33452 fixes that import. Before this PRtable(map.entries())did not reach it, because each row was the[key, value]pair, an array. With Node's code path each key and value is a cell of its own, so this PR needs the same line (const { Buffer: { isBuffer } } = require("node:buffer")). The line is identical to #33452, so the PR that lands second rebases without a conflict.The self-review asked for #33452 to land first, by itself, because the bug is worse than its title says. A Worker uses this JS
Consoleas its global console.console.table([{ createdAt: new Date(0) }])inside anode:worker_threadsWorker throwsundefined is not a functionon 1.4.2 and the worker exits with code 1. I agree with that order. I kept the line here because this PR is not correct without it, and I do not set the merge order.Console#table(map)is unchangedThe first revision of this PR also stopped the
for-ofover a Map or a Set aftersizesteps. The self-review falsified that with quick-lru 7.3.0. It capssizeatmaxSize, and its iterator also yields the older generation:sizeis 3 and the iterator yields 5. Node.js and bun 1.4.2 print 5 rows. The bound printed 3. The bound is gone, and those two loops are Node's again. The testprints every entry that a Map subclass yields, also past its sizepins the two-generation shape.So
new Console().table(map)with an endlessMap.prototype[Symbol.iterator]still never returns, on bun and on Node alike. If a bound is wanted there, one rule has to cover this code andforEachLimitedin #42264.Same class, not fixed here
util.inspect(Object.setPrototypeOf(new Map([[1, 2]]), null))with a replaced%MapIteratorPrototype%.nextnever returns on bun and on Node. For a null-prototype MapformatRawhandsformatMapthe result ofMap.prototype.entries(), whosesizeisundefined, so there is no bound.console.log(it),console.table(it)andBun.inspect(it)go through the native formatter, which advancesit. Node does not. The helper in this PR could serve the native formatter too. That is a follow-up.util.inspect(weakMap, { showHidden: true })still prints no entries (node:utildoes not implementWeakSetcorrectly #28044).Overlap with open PRs
previewEntriesintoConsole#tablewith the same five lines, changes the same snapshot inconsole-table-iterators.test.ts, and carries theisBufferline too.Console#table. The snapshots here use the alignment on main.Self-review
14 concerns survived the review. Removed the
sizebound and its test (2 concerns). Rewrote this description around the differences that need no tampering, the missing user report and the scope (4 concerns). Not taken: move theisBufferline out and stack this PR on #33452 and on the alignment PR (4 concerns), for the reason above. 4 concerns say to keep the design: the native read that does not advance is Node's own, and theConsole#tablelines are the ones #35391 has. 1 concern (WeakMap and WeakSet contents) is a follow-up.Other checks
BUN_JSC_validateExceptionChecks=1.clear(),Bun.gc(true)between twoutil.inspectcalls of the same iterator) gives stable output under the ASAN debug build, and whatutil.inspectshows equals what the iterator then yields.test/js/node/util/,test/js/node/console/,test/js/bun/console/andtest/js/bun/util/inspect.test.json the debug build.util-inspect-long-runningand theparseArgsstress test hit the 5 s timeout under ASAN when the host is loaded. Neither reaches this code.test-util-inspect-*.js,test-util.js,test-util-types.js,test-console-instance.js,test-console-methods.js,test-console-group.js,test-console-with-frozen-intrinsics.jsandtest-repl-inspect-defaults.jsfromtest/js/node/test/parallel/.util.inspectof an iterator over a large Map no longer copies the Map. It copiesmaxArrayLengthentries and counts the rest in native code.[human-review] gate passed · iteration 0 · 6 files touched
fails on main (without fix)
passes on PR (with fix)
diff hotspot
gate history · 3 passed · 0 rejected · iteration 0
evidence per changed file