console.table: invoke each cell getter once - #32924
Conversation
TablePrinter made two full passes over the rows (one to size the columns, one to render), so every cell property was read twice and the rendered value was the second read's. The second pass also re-ran the iteration protocol, so a one-shot iterable produced an empty table. Read and format each cell once, caching the rendered bytes and their visible width, and render from the cache.
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (1)
Walkthrough
ChangesTablePrinter single-pass row caching
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Updated 8:42 PM PT - Jun 28th, 2026
✅ @robobun, your commit 0a36c5e5f9a2d30fa135cc0753280be1ebd24619 passed in 🧪 To try this PR locally: bunx bun-pr 32924That installs a local version of the PR into your bun-32924 --bun |
|
Found 1 issue this PR may fix:
🤖 Generated with Claude Code |
There was a problem hiding this comment.
I didn't find any bugs, but this is a ~200-line refactor of the native TablePrinter (restructuring the two-pass loop into cached FormattedRows, dropping the explicit visited-map pool release, and buffering all cell bytes in memory), so it's worth a human look — especially with build-rust CI still red on the last reported commit.
Extended reasoning...
Overview
This PR refactors TablePrinter in src/jsc/ConsoleObject.rs to collapse the two-pass (width-measure → render) loop into a single pass that formats each cell once, caches the rendered bytes + visible width in new FormattedCell/FormattedRow structs, and then renders from the cache. It removes VisibleCharacterCounter, get_width_for_value, and the entire second iteration block, and rewrites print_row to consume pre-formatted cells instead of re-reading JSValues. Five new tests cover array rows, explicit properties, plain-object tabular data, generators, and a spawned console.table repro.
Security risks
None identified. This is output-formatting logic for console.table/Bun.inspect.table; no auth, crypto, filesystem, or network surface is touched. The unsafe block touched is the existing callback_ctx pattern, unchanged in semantics.
Level of scrutiny
Medium-high. While the user-facing behavior change is well-motivated and well-tested, the implementation is a non-trivial restructuring of native Rust that interfaces with JSC: it changes how the per-cell Formatter clone's map_node pool entry is released (now relying purely on Drop rather than the explicit release block), changes column lookup from &mut Column to index-based with a resize_with slot fill, and trades streamed rendering for buffering every cell's bytes in a Vec<FormattedRow>. These are the kinds of resource-management and memory-tradeoff details a maintainer should sign off on.
Other factors
- CI (robobun) reports
build-rustfailures across all platforms for commit d7f0d91; an autofix commit (edf83d1) followed, but no green build is visible in the timeline yet. - The removed explicit pool-release block carried a comment explaining capacity-based deinit vs clear; the new code relies on
Formatter::Dropdoing the equivalent — worth confirming that's still the intended lifecycle. - Buffering all formatted cells changes peak memory for very large tables; probably fine for
console.table, but it's a deliberate tradeoff. - Existing snapshot tests (21 console-table + 35 bun-inspect-table) reportedly pass unchanged, which gives reasonable confidence in output parity.
|
Thanks for the careful read. Taking the concerns in turn. build-rust red on d7f0d91: those were not compile failures. Each of those job logs ends with visited-map pool release: the deleted explicit block was already documented in place as redundant:
error propagation: related to the above, const bad = { [Symbol.for('nodejs.util.inspect.custom')]() { throw new Error('boom'); } };
console.table([{ a: bad }]); // throws before and after
console.table([{ a: bad, b: 1 }]); // throws before and aftermemory: agreed it is a deliberate tradeoff. One behavior delta I found while auditing that is not in the PR description: when a row object has a property the iterator yields but a string-named Re-ran both console table suites against the debug build: 61 pass, 0 fail, 45 snapshots unchanged. |
There was a problem hiding this comment.
I didn't find any bugs and the single-pass refactor looks sound, but this is a ~200-line restructure of the native TablePrinter (JSC iteration, formatter pool/Drop semantics, error-swallowing in format_cell) — worth a human pass before merging.
Extended reasoning...
Overview
This PR refactors TablePrinter in src/jsc/ConsoleObject.rs to collapse the two-pass column-sizing/render loop into a single pass that caches each cell's formatted bytes and visible width in new FormattedCell/FormattedRow structs. It removes VisibleCharacterCounter and get_width_for_value, renames update_columns_for_row → collect_row, rewrites print_row to consume cached bytes instead of re-reading JS properties, and deletes the entire second iteration block in print_table (including its duplicate extern "C" callback and JSPropertyIterator walk). Five new tests cover getters on array rows, the explicit properties arg, plain-object tabular data, generators, and a spawned console.table repro; the 56 existing snapshot tests are reported unchanged.
Security risks
None identified. console.table is pure output formatting with no auth, filesystem, network, or privilege implications. The one unsafe block touched (the bun_ptr::callback_ctx cast in the iterable callback) is structurally identical to before — and the PR actually deletes the second copy of it, reducing unsafe surface.
Level of scrutiny
Medium. This is not a config tweak or mechanical rename — it's a substantive control-flow restructure of native Rust that interacts with JSC (getOwn, get_index, JSPropertyIterator, for_each_with_context). Several non-obvious correctness questions were already raised and answered in-thread: the removed explicit visited-map pool release now relies on Formatter::Drop (which the old get_width_for_value already did per cell), format_cell discards the format() JsResult with let _ = and relies on the pending VM exception surfacing later, and the per-cell Vec<u8> buffering is a deliberate memory tradeoff matching Node. These answers look right to me, but they're exactly the kind of subtle invariants a maintainer familiar with the formatter pool and JSC exception scopes should sign off on.
Other factors
The diff is net-negative (~170 deleted vs ~110 added) and removes duplicated iteration logic, which is a structural win. Test coverage is solid — new tests assert both the rendered output and the getter call count, and the generator test covers the one-shot-iterable fix. The author's follow-up comment also notes a minor behavior delta for symbol-keyed properties (previously blank, now rendered) that has no snapshot coverage. Given the scope and the reliance on Drop/exception-scope invariants, I'm deferring rather than auto-approving.
format_cell dropped the JsResult from Formatter::format, but the render loop that used to propagate a throwing custom inspect no longer reads JS values, so the exception was left pending across the next host call. Propagate it with ? and cover the previously untested primitive Values column getter path.
|
The error-swallowing in Also added a test for a getter on a primitive routed to the trailing Now seven tests in the new describe block: six fail on the unfixed build, the exception one guards the new line. |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/jsc/ConsoleObject.rs`:
- Around line 937-940: The comment in ConsoleObject::format-like row rendering
logic is too long and must be reduced to 3 lines or fewer while preserving the
key invariant. Shorten the existing note near the single-pass row formatting and
width computation so it still states that each cell is read and formatted once,
widths are computed during that pass, and cached rendered cells prevent
re-reading getters or exhausting one-shot iterables. Keep it concise and place
it on the same code path where the row rendering loop and column-width
computation are described.
In `@test/js/bun/console/console-table.test.ts`:
- Around line 308-309: The spawned regression check currently only asserts
stdout and exitCode, so unexpected stderr output can slip through; update the
test around proc.stdout.text, proc.stderr.text, and proc.exited to assert the
combined { stdout, stderr, exitCode } object instead. Keep the existing repro
behavior, but include stderr in the expected result so console-path regressions
that emit diagnostics are caught.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: ffbb22fd-2089-4211-a9fd-c526f4a2aba6
📒 Files selected for processing (2)
src/jsc/ConsoleObject.rstest/js/bun/console/console-table.test.ts
There was a problem hiding this comment.
I didn't find any bugs, but this is a non-trivial restructuring of the native TablePrinter (JSC interop, formatter Drop/pool semantics, cell caching) — worth a human pass before merging.
Extended reasoning...
Overview
This PR collapses console.table's two-pass implementation (width computation, then rendering) into a single pass that formats each cell once and caches the rendered bytes plus visible width in new FormattedCell/FormattedRow structs. It touches src/jsc/ConsoleObject.rs (~250 line diff: removes VisibleCharacterCounter, get_width_for_value, the second iteration block, and the explicit visited-map pool release in print_row; rewrites collect_row and print_row) and adds 7 tests to test/js/bun/console/console-table.test.ts.
Security risks
None identified. This is output-formatting code; no auth, crypto, permissions, or untrusted input parsing beyond what console.table already did. The unsafe blocks touched are the pre-existing callback_ctx casts for the extern "C" iteration callback, with the same shape and safety invariant as before.
Level of scrutiny
Medium. The fix is conceptually straightforward (read once, cache, render) and matches Node's approach, but the implementation is a substantive refactor of Rust code at the JSC FFI boundary: it changes how errors propagate from format() (now ? in format_cell with a regression test added in 07b50f0), removes an explicit pool-release block in favor of Formatter::Drop, restructures column-index bookkeeping, and changes the lifetime of formatted data (now buffered per-cell in Vec<u8> before the final write). These are all reasoned through in the PR thread, but they're the kind of changes a maintainer familiar with the formatter's pooling and exception-scope conventions should sign off on.
Other factors
The bug hunting system found nothing. Test coverage is good — new tests exercise array rows, explicit properties, plain-object tabular data, generators (one-shot iterables), the Values column, throwing custom inspect, and a spawned console.table end-to-end check; the existing 21 snapshot tests and 35 bun-inspect-table tests reportedly pass unchanged. The author's follow-up comment addresses build status, pool release, error propagation, and memory tradeoffs in detail. Still, this isn't a mechanical change, so I'm deferring rather than auto-approving.
|
This roughly doubles peak memory for the call: it holds every rendered cell's bytes ( Isolated
Instead of buffering the rendered text, put each cell's |
…ered text Buffering every cell's rendered bytes for the whole table roughly doubled peak memory for the call (the cached text plus the final output buffer). Keep the two-pass shape instead: the width pass reads each property exactly once, appends the cell JSValue to a MarkedArgumentBuffer scoped across the whole print, and records its visible width. The render pass formats those rooted values. Per cell this costs a 16-byte CellRef inline in the row (no per-cell heap allocation) plus one buffer slot; JSC cells do not move, so the inline copies stay valid while the buffer roots them. Also includes stderr in the spawned test's combined assertion.
|
Reworked as described in 10fdae5. The width pass now reads each property exactly once, appends the cell's I have not re-run your RSS benchmark on a release build, so treat the memory claim as structural rather than measured. All 45 existing snapshots across the two table suites are byte-identical, and the getter tests still pass (six fail on the unfixed build). |
|
CI note on 10fdae5 (build 66229): the two red jobs are both so neither ran a single test. That lane needs a manual retry from the Buildkite UI (it already used its automatic one); there is nothing to change in the PR for it. |
…the value Under JSC's RecordOverflow policy, append() is a no-op when the spill-to-heap allocation fails, leaving the value unrooted while the Rust caller (console.table's CellRef cache) keeps using it. No Rust caller can recover from an unrooted value, so route allocation failure to a loud OOM crash via appendWithCrashOnOverflow.
|
Pushed 11cf6d9, which closes a gap in the MarkedArgumentBuffer rework rather than changing its shape. JSC's MarkedArgumentBuffer uses the RecordOverflow policy: if the spill-to-heap allocation fails, append() is a silent no-op and the value is never rooted. The cell's JSValue then sits in the CellRef Vec on the Rust heap, which the GC does not scan, so an allocation failure during the width pass would turn into a use-after-free in the render pass instead of an OOM. The fix is one line in the C++ shim: appendWithCrashOnOverflow instead of append. That is JSC's own API for callers that cannot tolerate a silent drop, and it matches the project's rule that allocation failure must be loud. It upgrades every Rust caller of the binding at once, and for all of them an un-rooted value was already unrecoverable. All 81 console tests pass locally against the new build, all 45 snapshots byte-identical, and six of the seven getter tests still fail on the unfixed released build. On build 66229 (the run before this push): every red lane was infrastructure, none touched this diff.
The lane that actually exercises the new tests under ASAN, debian-13-x64-asan-test-bun, was green. This push starts a fresh build, which also re-rolls those lanes. |
Also shorten the appendWithCrashOnOverflow comment.
|
Addressed both review comments in b035424.
No behavior change (one is comment text, the other is a byte-identical helper swap), |
|
CI note on b035424 (build 66422): 280 jobs, 1 real failure. The one red lane is The two Re-rolled CI with an empty commit. All five review threads on this PR are resolved. |
|
CI status on 0cdfd2f (build 66440): the diff is green on every lane that ran. The build is only red because the two macOS 14 test lanes never got an agent. What ran and passed:
What is red:
This branch has already been retriggered twice and each rebuild hit a different piece of infrastructure (an artifact download timeout, a MySQL health check, now darwin-14 agent availability), so I am not pushing another empty commit. The change and its tests are ready; this needs a maintainer to either merge over the expired lanes or re-run the build once darwin-14 agents are back. |
The width pass caches each cell's JSValue in a Rust Vec, which JSC's conservative stack scan never visits, so the MarkedArgumentBuffer rooting them is the only thing keeping them alive across the user code that runs between the two passes. With roots.append removed, all 64 cells in the new GC test are collected before they are rendered; nothing else in the suite detects that. 64 rows also spills the buffer past its 8-slot inline storage into the heap-registered regime. Also pins the positional cell-slot mapping for a row whose key order differs from the discovered column order, and the Proxy case: each cell is read once via [[Get]] (matching Node), where the old render pass re-read through [[GetOwnProperty]] and drew the raw target value into a column sized for the trap value.
|
Pushed 31564bd: three regression tests, no source change. A coverage review of the diff found three things the suite could not detect. The The new test uses 64 rows whose getters each return a fresh object and then force a full collection. 64 is deliberate: it spills the Out-of-order row keys. Cells are now stored positionally by column index ( Proxy rows. Reading each cell once also means the column is sized from the same value that gets rendered: the With this branch it renders Full file: 31 pass / 0 fail on this branch, 24 pass / 7 fail on unmodified bun 1.4.0. |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/jsc/ConsoleObject.rs`:
- Around line 998-1035: The iterable collection path in
ConsoleObject::collect_tabular_data is collapsing every collect_row failure into
a generic JsError::Thrown, unlike the non-iterable path that preserves the typed
error. Update the Ctx/callback flow to carry the actual JsError returned by
collect_row through the for_each_with_context callback, store that error in the
context instead of a bool flag, and return that preserved error after iteration
rather than always throwing a generic error.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: fa97e4b8-ca84-4f3f-8030-889fc16a0d9f
📒 Files selected for processing (4)
src/jsc/ConsoleObject.rssrc/jsc/MarkedArgumentBuffer.rssrc/jsc/bindings/MarkedArgumentBufferBinding.cpptest/js/bun/console/console-table.test.ts
The callback context carried a bool, so every collect_row failure on the iterable path was reported as JsError::Thrown, discarding OutOfMemory and Terminated. The non-iterable path already preserved the variant via ?. Carry the JsError itself and stop reading further cells once one has failed, so no more user code runs with an exception pending.
|
CI status on df3d47d (build 66485). All review threads are now resolved and the only red is infrastructure. Review: the last open thread, on the Verification section of the description, is addressed. I re-ran the new describe block against stock bun 1.4.0 rather than restating numbers: 7 of the 10 tests fail on the unfixed build, and 3 pass on both by design because they guard the new mechanism (exception propagation, the CI, and why I am not pushing a retrigger: Zero tests ran on that lane; the runner never obtained the binary. The only two annotated test failures on the build are pre-existing flaky install tests on unrelated lanes ( A |
Jarred-Sumner
left a comment
There was a problem hiding this comment.
Use ZigStringSlice since that becomes UTF8 and usually avoids the extra clone
|
Done in 3729432.
The rendered bytes are identical either way. All 45 table snapshots are unchanged and the full console suite still passes (83/83). |
|
CI triage on 3729432 (build 66504): three failed jobs, zero failed tests.
None of the three ran a failing test: grepping each log for Re-rolled with f9f81eb. The diff has not changed since 3729432, which addressed the ZigStringSlice review. All seven review threads are resolved. |
|
CI triage on f9f81eb (build 66517), the re-roll of build 66504. One failed job, Every other lane is green, including I am not going to keep pushing empty retrigger commits for a recurring agent-side docker timeout. The diff is ready as of 3729432; this is a maintainer call from here. |
Jarred-Sumner
left a comment
There was a problem hiding this comment.
not correct. we shouldn't be formatting the JSValue twice. We format the string-ified JSValue only.
A table is built in two passes: column sizing, then rendering. Reading a property in both passes invoked its getter twice and rendered the second call's value; formatting a value in both passes ran its custom inspect hook twice, with the same symptom one level down. Buffering every cell's rendered bytes in its own Vec fixed the reads but roughly doubled peak memory; rooting the JSValues in a MarkedArgumentBuffer instead fixed the memory but kept the double format. Format each cell exactly once during the width pass, appending the rendered bytes to one shared scratch buffer, and record its byte range plus visible width. The render pass replays those ranges. Each property is read once, each value is string-ified once, and the only per-cell cost is the offset/len/width record. MarkedArgumentBuffer is back to main since nothing here uses it anymore.
|
Done in 1b2dbeb. You are right that this was not just redundant work. String-ifying runs user code, so the double format had the same observable bug as the double read, one level down: let calls = 0;
Bun.inspect.table([{ x: { [Bun.inspect.custom]() { return "call " + ++calls; } } }]);
On memory, relative to the first revision you measured: the per-cell heap Since nothing uses |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/jsc/ConsoleObject.rs`:
- Around line 680-687: The `CellRef::text` path and the related cell-width
handling are narrowing a user-controlled byte length into `u32`, which can panic
on large `console.table` output. Update `CellRef` and the surrounding formatting
logic so byte lengths stay as `usize` end-to-end, and remove any `expect("int
cast")`/fallible narrowing in the code that builds `CellRef` or computes text
slices. Make the `CellRef::text` indexing use the existing `usize`
offsets/lengths directly, and apply the same fix anywhere else in the cell
formatting flow that currently converts `text.len()` or similar external sizes
into smaller integer types.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 1fad7a99-70d3-48e4-9be9-9eb03914392d
📒 Files selected for processing (2)
src/jsc/ConsoleObject.rstest/js/bun/console/console-table.test.ts
The formatted byte length of a cell is user-controlled (a single JS string plus escape expansion can exceed u32::MAX bytes as UTF-8), so narrowing it through u32::try_from(...).expect() could panic the process on large input. CellRef only uses the length to slice cell_text, so keep it as usize end to end.
|
Measured peak RSS and time for Time for the
Process max RSS
Isolating RSS growth attributable to the call itself (
So the correctness fix comes with a consistent ~1.5× speedup from ~1k rows up, but peak memory for the call is ~1.66× main, converging to about +49% total process RSS at 1M rows. The PR description says the scratch buffer holds the cell bytes once as "a subset of the output string the call is already building", which predicts roughly the cost of one extra copy of the cell text — at 1M rows that's well under the ~420 MB observed. The measured growth is closer to the "roughly doubled peak RSS" the description attributes to the earlier per-cell- Harness
const count = parseInt(process.argv[2], 10);
const rows = new Array(count);
for (let i = 0; i < count; i++) {
rows[i] = {
id: i,
name: `user-${i}`,
email: `user-${i}@example.com`,
age: 18 + (i % 60),
active: i % 2 === 0,
score: i * 1.5,
};
}
const baseline = process.memoryUsage.rss();
const t0 = Bun.nanoseconds();
const str = Bun.inspect.table(rows);
const t1 = Bun.nanoseconds();
const after = process.memoryUsage.rss();
console.log(JSON.stringify({ count, len: str.length, baseline, after, ms: (t1 - t0) / 1e6 }));Driver (macOS): for bin in bun ./bun-pr; do
for n in 1 100 1000 10000 100000 500000 1000000; do
for i in 1 2 3; do
/usr/bin/time -l "$bin" table-rss.mjs "$n"
done
done
done |
What does this PR do?
Makes
console.tableandBun.inspect.tableread and format each cell exactly once, matching Node.Previously
console.tableinvoked an enumerable getter on a row object twice per cell, and the rendered table showed the value of the second call. Node calls it once.Bun renders
2in the cell and printsgetter invocations = 2. Node renders1and prints1. The output both doubles the side effect and shows a value the object never observably returned on first read.The same defect exists one level down. String-ifying a cell runs user code too, and the formatter ran three times per cell (two width computations and a render):
renders
call 3.Cause.
TablePrinter::print_tableinsrc/jsc/ConsoleObject.rsmade two full passes over the rows: a first pass that read every cell to compute column widths, then a second pass that read every cell again to render it. Each read goes throughgetOwn/JSPropertyIterator, both of which invoke getters, so every getter fired twice and the second call won. The second pass then formatted the value it read twice more, once to re-measure its width and once to write it, so a custom inspect hook ran three times.For iterable inputs the two passes also ran the JS iteration protocol twice, so a one-shot iterable was exhausted by the width pass and
console.table(someGenerator())rendered a header-only table with no rows.Fix. Read and format each cell exactly once, in the width pass.
format_cellappends the rendered bytes to a single scratchVec<u8>shared by the whole table and records{ offset, len, width }on the row. The render pass writes those byte ranges back out and makes no JS calls at all.Per cell this costs the offset/len/width record plus the cell's rendered bytes, held once in one contiguous buffer that is a subset of the output string the call is already building (output = cell text + borders + padding). There is no per-cell heap allocation (an earlier revision of this PR cached each cell's bytes in its own
Vec, which roughly doubled peak RSS for the call), and no value is formatted twice (the revision after that rooted each cell'sJSValuein aMarkedArgumentBufferand re-formatted it in the render pass). It is also the fastest of the three, since the old code formatted every cell three times. TheMarkedArgumentBufferfiles are back tomain; nothing here uses it anymore.The iteration protocol now runs once, so one-shot iterables are no longer exhausted by the width pass. Formatter errors (a throwing
[Bun.inspect.custom]) propagate from the width pass with?rather than being swallowed byget_width_for_valueand leaking a pending exception into the next host call.How did you verify your code works?
Eleven tests in
test/js/bun/console/console-table.test.tsunderconsole.table reads each cell once:propertieslisttabularDatagetter)Valuescolumn[[Get]]value the width pass sawconsole.tablerepro aboveEight of the eleven fail on the unfixed build: the four getter-count tests (the getter fires twice and the cell renders
2), the custom inspect test (the hook fires three times and the cell renders the third result), the Proxy test, the generator test, and the spawned repro. The remaining three guard the new mechanism rather than the original bug: exception propagation out offormat_cell, a getter that triggers a full GC in the middle of the width pass, and the positional cell-slot mapping.All 45 existing snapshots across
console-table.test.tsandbun-inspect-table.test.tspass with byte-identical output, with and without ANSI colors.