Skip to content

Find console.table columns by the address of the property-name atom - #38472

Open
robobun wants to merge 3 commits into
mainfrom
farm/b5bcdf88/console-table-column-index
Open

robobun wants to merge 3 commits into
mainfrom
farm/b5bcdf88/console-table-column-index

Conversation

@robobun

@robobun robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • console.table and Bun.inspect.table cost cells times columns: console.table([new Uint8Array(65536)]) takes 7.0 to 8.4 s of CPU (Node: 0.2 s).
  • No report exists: found by measurement. Tables under about 200 columns are not slow.
  • TablePrinter::collect_row (src/jsc/ConsoleObject.rs) compares each cell's property name with every column so far: columns[1..].iter().position(|col| col.name.eql(..)).

Fix

  • A cell finds its column by the address of its property-name atom, among the columns of earlier rows only. Up to 8 are compared inline. Past that, the first lookup builds a HashMap<usize, usize>.
  • Correct because JSC keeps one StringImpl for each distinct property-name text, and each column holds a ref on its name.
  • Verified: test/js/bun/console/console-table.test.ts (three ratio tests fail on main: 56 to 104, limit 4), test/js/bun/console/, test/js/web/console/. 20,000 random tables are byte-identical to main.
  • Self-reviewed: 15 concerns raised, 13 addressed. Kept: the ratio case for names that share one hash. Not built: an ArrayHashMap variant.

Background

Downsides

  • A one-cell table executes 28 more instructions (7,612 to 7,640), a table with no name lookup 7 to 11 more, a 9-key row followed by 200 one-key rows 0.55% more.
  • A row behind more than 8 columns builds an index over every column: 2 allocations or more, 31 to 62 bytes per column.
  • Release .text grows by 2,560 bytes. bun_core::String gains a public accessor.
Notes

All numbers are from linux x64. "main" is 179664e. "this PR" is c5ef958. Release builds are of one tree and differ only in this PR's src/ changes. "previous head" is df6bc17, the design this PR had before (an index keyed on a wyhash of the name's text).

Reach. No issue reports a slow console.table or Bun.inspect.table. The scan dates from #7866. The cost shows from about 200 columns in a grid, or a few thousand keys in one row. CPU time of one call:

input main this PR Node 26
[new Uint8Array(65536)] 7,026 to 8,362 ms 47 to 52 ms 184 to 214 ms
1 row x 40,000 keys 2,435 to 2,710 ms 14 to 16 ms 105 to 116 ms
2 rows x 20,000 keys 1,465 to 1,520 ms 16 to 19 ms
20,000 rows x 8 keys 24.3 to 25.8 ms 21.3 to 21.9 ms
20,000 rows x 8 keys, names alternate Latin-1 and UTF-16 32.6 to 36.4 ms 20.0 to 21.6 ms
8,000 rows, each with its own key 2,057 to 2,832 ms 1,965 to 1,978 ms out of memory

The last line is not a lookup cost. The output is rows times columns (503 MB there). This PR does not make console.table cheap for input of any shape.

Lookup instructions for one Bun.inspect.table call. Guest instructions inside collect_row, String::eql, indexed_column and the HashMap<usize, usize> functions, counted with qemu-x86_64 -one-insn-per-tb -d exec,nochain -dfilter, JIT off, for R rows x C string-keyed columns.

rows x cols main previous head this PR
1x1 231 277 251
1x8 2,831 1,180 972
3x3 2,223 2,175 1,407
10x8 32,180 30,979 11,871
100x8 325,670 328,969 120,861
1000x2 508,888 530,873 359,992
1x9 3,483 1,321 1,087
2x9 7,449 5,506 3,711
100x9 396,117 259,228 179,327
100x16 1,030,652 447,004 304,661
100x64 14,311,720 1,764,568 1,173,228
2x512 17,329,512 298,889 186,811

String::eql calls in the lookup: 0 on this PR. On main: 28 for 1x8, 3,592 for 100x8, 40,000 for 2x200.

All instructions of one print_table call (gdb single-step from entry to return, JIT off), main to this PR:

table main this PR
[] 2,618 2,625
1 row, properties of 1 name 7,483 7,494
Map of 1 entry 11,824 11,832
1x1 7,612 7,640
1x3 14,326 14,111
6x3 46,677 44,487
a 9-key row, then 60 rows with its first key 278,633 278,608
a 9-key row, then 200 rows with its first key 835,670 840,265
a 16-key row, then 200 rows with its first key 1,041,538 1,041,565

The last three lines are the shape where the index costs more than main's scan: main finds the first column with one compare. It needs 9 to about 16 columns and more than about 60 narrow rows.

Allocator calls for one table (mimalloc entry points, qemu exec filter). No change when every row starts behind 8 columns or fewer (1x1, 1x8, 100x8, 1x512, an 8-key row then 200 rows). 2 more allocations and frees when a row starts behind more columns (2x9, 2x200, a 9-key row then one cell). 2 more at each doubling of the index (2 rows of 10 keys with no name in common: 4, 3 such rows: 6).

The index. A slot is 24 bytes and 1 metadata byte. The capacity is 1.25 to 2.5 times the number of columns: 31 to 62 bytes for a column, and up to 94 while it doubles. The first lookup of a row behind more than 8 columns builds it over every column, however few lookups follow. It is dropped when the rows are read, before the table is printed. On real atom addresses a lookup inspects 1.8 to 2.1 slots at a load of 61% (20,000 and 40,000 names of 1, 16, 64 and 1,000 characters, and array rows).

Why not another key or container.

  • A hash of the name's text. The previous head did that with bun_wyhash::hash. Names chosen to share one hash kept the n times n cost and ran slower than on main: for 20,000 such names, a 9-name row then a row of new names took 1,762 to 1,908 ms (main: 750 to 773 ms, this PR: 13 to 15 ms), and two rows with the same names took 1,723 to 1,888 ms (main: 1,520 to 1,579 ms, this PR: 14 to 17 ms). A seed does not help, and a keyed hash costs 143 to 192 more instructions for each indexed cell.
  • bun_collections::ArrayHashMap<usize, ()>. It also scans up to 8 entries and then builds an index. But it hashes and stores every cell of a first row, and it allocates its key and hash vectors for every table with an object row. Here a first row does no lookup (one row of 40,000 keys builds no index), and a table of 8 columns or fewer allocates nothing more than on main. I did not build it.
  • JSC has the same two tiers for the same job: PropertyNameArrayBuilder::add scans by pointer below 20 names and fills a pointer set above.
  • Headers: hash-index uncommon names past 64 entries so per-name lookup stays O(1) #35087 added a name index to HTTPHeaderMap and was closed. This index is not a field of any object. It is a local of one print_table call, only grows, and cannot go stale.

Similar scans that this PR does not change. TablePrinter is the only column lookup of console.table and Bun.inspect.table. new console.Console(stream).table is a JS implementation and is linear on main (20,000 keys: 95 ms).

Overlap with open PRs.

Tests (console.table column lookup). A ratio test renders N narrow tables and one table N times as wide, and requires the wide one to take less than 4 times as long (CPU time, the fastest of several runs).

build one row three rows three rows, names that share one hash
this PR, release 1.2 to 1.4 1.5 to 1.7 1.3 to 1.5
this PR, debug 0.8 0.8 0.8
main, release 94 to 104 69 to 70 56 to 58
main, debug 6.8 to 6.9 11.7 to 12.3 9.1 to 9.3
previous head, release 1.6 1.5 78 to 79
this PR with a scan of every column (&columns[1..]), release 16.2 to 17.4 4.2 to 4.7 3.4
this PR with no index, release 1.5 9.7 to 10.5 6.9 to 7.5

Each variant fails at least one test on a release build. On a debug build the last two variants pass all three, because the tables there are 8 to 16 times narrower to keep the test short. The block passed 100 of 100 runs on a release build, 20 of 20 on a debug build, and 80 of 80 on a release build with 24 and with 48 busy processes on 12 cores.

The other tests check the output: a 2-row, 300-column table spelled out, rows that share some columns and add their own across the scan limit, narrow rows after a wide row, Latin-1 and UTF-16 names, the empty name, names " " and Values, index names of arrays, objects and typed arrays, one name spelled by different strings (a flat 16-bit string, a concatenation, JSON.parse of separate texts, a Proxy whose ownKeys makes new strings), and names whose earlier rows were collected.

Output. 20,000 pseudo-random tables render byte-identical on release builds of main and of this PR (objects, arrays, typed arrays, primitives, proxies, class instances, getters, as an array, an object, a Map, a Set or an iterator, with and without properties). 3,000 more ran on the debug build, where the new assertion is on.

Self-review. Two things were kept against it. The ratio case for names that share one hash stays: it is the only test that fails on an index keyed on a hash of the text (78 on the previous head). An ArrayHashMap variant was not built: the reason above is from its layout, not from a measurement. The deferred index build it offered as optional is not taken, and its cost is in Downsides.

Not in scope. Names that collide in JavaScriptCore's own string hash. The engine's property read for each cell is slow for them with or without this PR.


no test proof · iteration 7 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/console/console-table.test.ts

@coderabbitai

coderabbitai Bot commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: e9c3b3f1-9ef2-4ee3-a848-dfff5575a776

📥 Commits

Reviewing files that changed from the base of the PR and between 120fcd9 and 954fd20.

📒 Files selected for processing (2)
  • src/jsc/ConsoleObject.rs
  • test/js/bun/console/console-table.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 4 remain after this review.

Walkthrough

console.table now stores columns in an ordered wrapper with a lazy name index for wide tables. Row collection and rendering use the wrapper. Tests cover column order, cell placement, special property names, and large tables.

Changes

console.table Column Lookup

Layer / File(s) Summary
Ordered columns and name index
src/jsc/ConsoleObject.rs
Adds the Columns wrapper to maintain column order and lazily index names for lookup in wide tables.
Column lookup, rendering, and tests
src/jsc/ConsoleObject.rs, test/js/bun/console/console-table.test.ts
Uses Columns during row collection and rendering. Tests cover column order, cell placement, special property names, and performance with large tables.

Suggested reviewers: dylan-conway

Priority: Low

Merge Risk: Minimal · up to 954fd

The column lookup change has no identified merge-blocking risk after normal checks.

Pre-merge checks: Passed 4
Check name Status Explanation
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Title check Passed The title names the core change: console.table columns are now found by the address of the property-name atom. The PR objectives describe indexing columns by name with a hash map and scanning up to 8 …
Description check Passed The description is complete and relevant. It explains the problem, implementation, trade-offs, scope, and verification results. It does not use the template headings exactly, but it provides the requi…
Full details: Title check

Explanation

The title names the core change: console.table columns are now found by the address of the property-name atom. The PR objectives describe indexing columns by name with a hash map and scanning up to 8 columns inline. The title names the lookup key (atom address) rather than the performance goal, but the title still refers to the main change in column lookup in TablePrinter. It is technically specific and does not describe anything absent from the changeset.

Full details: Description check

Explanation

The description is complete and relevant. It explains the problem, implementation, trade-offs, scope, and verification results. It does not use the template headings exactly, but it provides the required information in equivalent sections.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: the head is c5ef958 and I do not plan to move it. No review thread is open.

CI on this head (build 133238) passed 180 of 181 jobs. Every test lane is green, and test/js/bun/console/console-table.test.ts passed on each lane with no retry. The one red job is the Windows x64 baseline verification: a JPEG test in test/js/bun/jsc-stress/fixtures/simd-baseline.test.ts took 5,010 ms against its 5,000 ms timeout under CPU emulation. This PR does not touch that test. The same job is red on main in builds 133196, 131922 and 131661, and green in the builds between them. A rerun of that one job is enough for a green build.

What changed since df6bc17: a cell finds its column by the address of its property-name atom. The index keyed on a hash of the name's text is gone. Names chosen to share one hash kept the n times n cost there and ran up to 2.5 times slower than main. The PR body has the numbers.

Reproduced on a release build of main 179664e: Bun.inspect.table([new Uint8Array(65536)]) takes 7.0 to 8.4 s of CPU, and one row of 40,000 keys takes 2.4 to 2.7 s. With this head they take 47 to 52 ms and 14 to 16 ms. The three ratio tests in test/js/bun/console/console-table.test.ts fail with main's src/ (56 to 104 on a release build, 6.8 to 12.3 on a debug build, limit 4) and pass with this head (1.2 to 1.7, and 0.8 on a debug build). test/js/bun/console/ and test/js/web/console/ pass on the debug ASAN build (117 pass, 1 skip, 2 todo).

Overlap: #46825 and #44268 touch the same files. The PR body says what each merge order needs.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. It's a carefully-built performance fix, but it introduces ~100 lines of new native code — an encoding-aware hash that must stay consistent with BunString::eql, and a refcount lifecycle moved from a scopeguard into a Drop impl — so a human look at the memory/hashing invariants would still be worthwhile.

What was reviewed:

  • ColumnNameContext::ctx_hash vs ZigString::eql: traced the mixed-encoding path and confirmed a UTF-16 name equal to an 8-bit name always narrows to the same bytes (no unit >0xFF can survive eql), so equal names hash equal.
  • Refcount balance: create refs once, Columns::drop derefs each list name once (same as the old scopeguard); map keys are Copy handles with no Drop, so no double-deref when the map drops after the loop.
  • put_no_clobber can't fire its debug-assert: the scan phase already dedupes via eql, and the map path only calls create after a miss.
  • find_or_create is unreachable from the Map and properties branches, so FIRST_PROPERTY_COLUMN = 1 holds; the index/"Key"/"Values" columns correctly stay out of the map.
Extended reasoning...

Overview

The PR fixes quadratic column lookup in console.table/Bun.inspect.table by wrapping the column Vec in a Columns struct that scans linearly up to 8 property columns and switches to a bun_collections::HashMap<BunString, usize, ColumnNameContext> beyond that. Two files: src/jsc/ConsoleObject.rs (new Columns, ColumnNameContext, and Drop; collect_row and the driver rewired to use them) and test/js/bun/console/console-table.test.ts (a new describe covering placement at 300 cols, a linear-time budget test at 64k/10k cols, and per-strategy semantic tests for Latin-1/UTF-16 name identity, non-ASCII, empty name, and collision with the index/"Values" columns).

Security risks

None. This is output formatting for console.table; input is JS objects the caller already controls. No parsing of untrusted data, no filesystem, no network.

Level of scrutiny

Medium-high. Output semantics are unchanged (existing snapshots untouched, new semantic tests pass on both strategies), so the functional risk is low. But the change lives in native code and touches two areas the repo review rules single out: refcount balance (the ref_/deref pair moves from a scopeguard to a Drop impl, and the map holds unref'd Copy aliases of the same handles) and a custom hash that must satisfy eql(a,b) ⟹ hash(a)==hash(b) across JSC's Latin-1 and UTF-16 storages. I traced both and they check out, but these are exactly the invariants a maintainer should confirm.

Other factors

  • put_no_clobber returns Ok(()) unconditionally in zig_hash_map.rs, so the .expect("unreachable") is genuinely unreachable rather than an OOM path.
  • JSPropertyIterator::next() returns a bun_core::String directly, so passing col_key straight into find_or_create (dropping the old BunString::init wrapper) preserves the WTF-backed handle that ref_() actually acts on.
  • The perf test branches on isDebug and keeps ~10x headroom under the 5 s budget, matching the repo's guidance for timing-based regression tests. The PR description's own note about ±5-15% jitter on narrow (scan-path) tables is a design tradeoff a human can weigh; it doesn't affect the indexed path.
  • No prior human reviews or outstanding comments; CodeRabbit was rate-limited and left no findings.

@robobun
robobun force-pushed the farm/b5bcdf88/console-table-column-index branch from aa8cb3c to 4fd1fb0 Compare August 21, 2026 02:25
@robobun

robobun commented Aug 21, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:40 PM PT - Oct 26th, 2026

❌ @robobun, your commit c5ef958 has 1 failures in Build #133238 (All Failures):

  • ❌ Baseline verification failed on Windows x64 — 1 check(s)
  • The baseline build crashed or failed tests under Nehalem (SSE4.2, no AVX/AVX2/AVX512) emulation (not an unsupported-instruction fault). See the step log for the crash output.

    • SIMD baseline tests

🧪   To try this PR locally:

bunx bun-pr 38472

That installs a local version of the PR into your bun-38472 executable, so you can run:

bun-38472 --bun

@robobun

robobun commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (4fd1fb0). The only conflict was the unused impl Default for Column, which #39795 removed on main next to the code this PR adds; the resolution keeps it removed. The diff is otherwise the same as before. test/js/bun/console/ passes on the rebased debug build; the indexed table call in the new budget test takes about 0.7 s there with the host at a load average above 80, against its 5 s budget.

Comment thread src/jsc/ConsoleObject.rs Resolved
Comment thread src/jsc/ConsoleObject.rs Resolved
Comment thread src/jsc/ConsoleObject.rs Resolved
Comment thread src/jsc/ConsoleObject.rs Resolved
Comment thread src/jsc/ConsoleObject.rs Resolved
Comment thread src/jsc/ConsoleObject.rs Resolved
Comment thread src/jsc/ConsoleObject.rs Resolved
@robobun
robobun force-pushed the farm/b5bcdf88/console-table-column-index branch from 4fd1fb0 to 6c19024 Compare August 21, 2026 02:32

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. Because it introduces a custom encoding-aware hash context that must stay consistent with BunString::eql, and swaps the scopeguard-based column-name deref for a Drop impl, a human look at those two pieces would still be worthwhile.

What was reviewed

  • ColumnNameContext::ctx_hash vs ZigString::eql: verified that every case where eql holds (both 8-bit, both 16-bit, mixed with all units ≤ 0xFF) hashes to the same value; a 16-bit unit > 0xFF cannot eql any 8-bit name, so the cast_slice fallback is sound.
  • Ref-count lifecycle: create refs before pushing, Columns::Drop derefs every list entry, and the map's aliased BunString handles are Copy with no Drop, so no double-deref.
  • put_no_clobber's .expect("unreachable"): zig_hash_map::put_no_clobber unconditionally returns Ok(()), so the error arm is genuinely unreachable.
  • FIRST_PROPERTY_COLUMN = 1: find_or_create is only reached on the property-iterator path (not Map, not properties), where index 0 is the sole pre-populated column.
Extended reasoning...

Overview

This PR fixes quadratic-time column lookup in console.table / Bun.inspect.table by wrapping the Vec<Column> in a Columns struct that switches from a linear scan to a bun_collections::HashMap<BunString, usize, ColumnNameContext> once a row exceeds 8 property columns. Two files: ~120 net lines in src/jsc/ConsoleObject.rs (new Columns / ColumnNameContext / Drop, plus call-site plumbing) and ~140 lines of new tests in test/js/bun/console/console-table.test.ts.

Security risks

None. The input is JS objects the caller already controls; the change only affects how column names are indexed during rendering. No new syscalls, no untrusted parsing, no allocation sizing driven by attacker-controlled headers.

Level of scrutiny

Medium-high. console.table itself is not a hot or security-sensitive path, but the change touches two areas the repo's review guidelines call out explicitly: (1) a hand-written hash that must agree with an existing encoding-aware equality (BunString::eql compares Latin-1 and UTF-16 by content, so the hash must narrow UTF-16 to Latin-1 bytes when possible — I traced ZigString::eql and the mixed-encoding to_slice() path to confirm the hash is consistent), and (2) BunString refcount management, where the scopeguard that deref'd column names is replaced by impl Drop for Columns. Both look correct to me, and the tests cover the Latin-1/UTF-16 crossover, empty names, and collision with the " " / "Values" special columns in both scan and indexed modes. But these are exactly the shapes where a subtle mistake produces a UAF or a silently duplicated column, so a maintainer eye on ctx_hash and the Drop swap is warranted.

Other factors

The tests are thorough: a 300-column exact-output test, a budget test that fails on the unpatched tree (~22 s vs 5 s budget) and passes patched (~0.4 s), plus semantic tests each run once narrow (scan) and once behind 10 filler columns (indexed). Existing snapshots are unchanged. The comment-cop bot flagged verbose comments; those were trimmed in 6c19024 and the threads are resolved. put_no_clobber(...).expect("unreachable") initially looked like it would misreport OOM, but zig_hash_map::put_no_clobber never returns Err, so the label is accurate. The one minor inefficiency I noted — ctx_hash allocating a Vec<u8> per lookup for narrowable UTF-16 names — is measured in the PR's own benchmarks (10k UTF-16 Latin-1 columns: 5.7 ms) and is dwarfed by the win, so not worth blocking on.

@robobun

robobun commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (558ed43). Both conflicts came from #40238, which made bun_core::String own its WTF ref and changed JSPropertyIterator::next to yield (StringView, JSValue):

  • Columns::find_or_create now takes &BunString (the view derefs to it) and create stores name.clone() in the list and in the map, so each holds its own ref.
  • The manual ref_() call and the impl Drop for Columns are gone; Vec<Column> and the map release their refs through String's own Drop.

No change to the lookup itself. test/js/bun/console/ passes on the rebased debug build.

@robobun
robobun force-pushed the farm/b5bcdf88/console-table-column-index branch from 6c19024 to 558ed43 Compare August 24, 2026 04:55

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — self-contained O(N²)→O(N) fix for wide console.table, with tests that pin both lookup strategies to identical output.

What was reviewed:

  • ColumnNameContext::ctx_hash is consistent with BunString::eql: an 8-bit name and a UTF-16 name with the same code points hash identically (the UTF-16 side narrows to Latin-1 when every unit ≤ 0xFF, which is exactly when an 8-bit twin can exist); covered by the Latin-1/UTF-16 symbol-key test.
  • put_no_clobber cannot see a duplicate: create is only reached after a miss, and the crossover populates from a list that was itself built via eql-scanned uniqueness. Its Result is AllocError only, matching the panicking Vec::push just above.
  • The map is only ever built from the property-iterator path, so FIRST_PROPERTY_COLUMN = 1 is correct and the Map "Key" / properties-argument columns never enter it.
  • Ownership: map keys and list entries each hold their own BunString clone; both release via Drop since #40238.
Extended reasoning...

Overview

Two files: src/jsc/ConsoleObject.rs wraps the column list in a Columns struct that switches from a linear scan to a name-keyed bun_collections::HashMap once more than 8 property columns exist, and test/js/bun/console/console-table.test.ts adds a describe block covering exact output at 300 columns, a 5s time budget at 64k/10k columns, and five semantic tests each run in both scan and indexed mode. No user-visible behavior changes; only the algorithmic complexity of column resolution.

Security risks

None. This is a diagnostic/output path (console.table / Bun.inspect.table) with no parsing of untrusted structured data, no I/O, and no privilege boundaries. The only new allocation is a Vec<u8> per UTF-16 name during hashing and the map itself, both bounded by the number of columns the caller already asked to render.

Level of scrutiny

Moderate. The core risk in a change like this is a hash/equality mismatch producing duplicate columns or wrong cell placement. I traced ctx_hash against BunString::eql (which delegates to ZigString::eql, encoding-aware): the narrowing branch guarantees that any UTF-16 name that could compare equal to an 8-bit name (all units ≤ 0xFF) hashes as its Latin-1 bytes; names with a unit > 0xFF cannot equal any 8-bit name so hashing raw UTF-16 bytes is safe. All names entering the map come from JSPropertyIterator, so they are WTF-backed (Latin-1 or UTF-16, never UTF-8-tagged ZigStrings), which is the only input space the hash needs to handle. put_no_clobber returns Result<(), AllocError> — the .expect fires only on OOM, same as the adjacent Vec::push, and the debug assertion inside it would catch a genuine clobber in ASAN CI.

Other factors

Existing snapshot tests are unchanged and still pass. The new tests exercise both the scan path (narrow) and the indexed path (behind a 10-column filler row) for each semantic case, so a divergence between the two strategies would show up. The budget test branches on isDebug and has >10x headroom per the PR's measurements. The comment-cop bot flags have all been resolved (comments trimmed to one line). Memory management was simplified after the #40238 rebase to plain clone()/Drop, which I verified matches the current BunString ownership model.

@robobun
robobun force-pushed the farm/b5bcdf88/console-table-column-index branch from 558ed43 to d0ec14d Compare August 25, 2026 03:00
@robobun

robobun commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (d0ec14d). The only conflict was the import block, where #40374 changed the bun_core imports next to the HashContext import this PR adds; the resolution keeps upstream's imports and adds HashContext. The diff is otherwise unchanged, and the console.table column lookup tests pass on the rebased debug build.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and the bug-hunting pass found no issues. The change is self-contained to TablePrinter and the tests pin both lookup strategies, but ~100 lines of new native code with a custom HashContext that must mirror BunString::eql's cross-encoding semantics is worth a maintainer glance.

What was reviewed:

  • ColumnNameContext::ctx_hash vs BunString::eql: traced Latin-1↔UTF-16 cases — narrowing units ≤0xFF to bytes matches eql's to_utf8() mixed-encoding path for the WTF-backed property names JSPropertyIterator yields.
  • Ownership: name.clone() in both list and map takes independent WTF refs; Columns drops both via RAII on every path including ? returns.
  • put_no_clobber(...).expect("unreachable"): zig_hash_map::put_no_clobber never returns Err, and duplicates are ruled out (scan/lookup precedes every create; crossover populates from a list already free of duplicates).
  • FIRST_PROPERTY_COLUMN = 1: confirmed find_or_create is only reached when neither the Map "Key" column nor properties columns were pushed.
Extended reasoning...

Overview

The PR wraps TablePrinter's Vec<Column> in a Columns struct that keeps a name→index bun_collections::HashMap once more than 8 property columns exist, turning the per-cell column lookup from O(N) scan to O(1). A new ColumnNameContext supplies the hash/eql for BunString keys. ~130 lines added in src/jsc/ConsoleObject.rs and ~140 lines of new tests in test/js/bun/console/console-table.test.ts.

Security risks

None. This is output formatting for console.table / Bun.inspect.table; no untrusted-input parsing, no I/O, no auth. The only user-controlled input is property names, which are hashed and compared — worst case is hash collisions degrading to eql calls, not a correctness or safety issue.

Level of scrutiny

Medium. The change is contained to one file and one code path (collect_row's discovered-columns branch), and existing snapshots plus the new scan-vs-index tests pin output identically. The subtle part is ColumnNameContext::ctx_hash: it must give equal hashes wherever BunString::eql returns true. I traced this against EncodedSlice::eql (src/bun_core/string/mod.rs:1360): 8-bit names hash their Latin-1 bytes; UTF-16 names with all units ≤0xFF narrow to those same bytes; UTF-16 names with a unit >0xFF hash raw UTF-16 bytes and can never eql an 8-bit name. Since property names from JSPropertyIterator are always WTF-backed (never UTF-8-tagged EncodedSlice), the hash is consistent with eql over the actual input domain. That said, someone familiar with bun_core::String encoding invariants should confirm the reasoning.

Other factors

  • Memory: String is Clone-refs / Drop-derefs since #40238; both the list entry and the map key take their own clone, so refcounts balance on every exit including JsResult early returns. No manual ref/deref remains.
  • put_no_clobber(...).expect("unreachable"): zig_hash_map::put_no_clobber unconditionally returns Ok(()) (src/collections/zig_hash_map.rs:470), so this is not a user-reachable panic; the duplicate-key debug_assert holds because create is only reached after a miss, and the crossover loop populates from a list that was itself built via find-then-create.
  • FIRST_PROPERTY_COLUMN = 1: find_or_create is only called from the !is_map && properties.is_undefined() branch of collect_row, where only the index column was pre-pushed — the Map "Key" and properties-argument columns never coexist with this lookup.
  • Tests: the new suite exercises both strategies via a 10-column filler row, covers Latin-1/UTF-16 name equivalence, empty names, and collision with the reserved " " / Values columns. The 5 s budget test has >10× headroom per the PR's measurements and branches on isDebug. The comment-cop feedback on long comments was addressed and all threads are resolved.

Deferring because a custom hash context paired with an existing equality contract is the kind of thing a maintainer should sign off on, even though I found nothing wrong with it.

@robobun
robobun force-pushed the farm/b5bcdf88/console-table-column-index branch from d0ec14d to 7dc0656 Compare September 24, 2026 16:36
@robobun robobun changed the title Index console.table columns by name once a row gets wide Index console.table columns by name once a table gets wide Sep 24, 2026
@robobun

robobun commented Sep 24, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (7dc0656) and reworked after #41910.

  • JSPropertyIterator: do not yield Symbol keys as strings (macros, http2 sensitiveHeaders, spawn env) #41910 stopped Symbol keys from becoming console.table columns. The test a name stored as Latin-1 by one row and as UTF-16 by another is one column built its case from Symbol keys, so it failed on main. It is removed.
  • With string keys only, every looked-up name is a JSC atom: one StringImpl for each distinct text. One name can no longer arrive in two storages, so the hash no longer narrows UTF-16 names to Latin-1. It hashes the stored bytes. eql still compares by content.
  • A cell now searches only the columns that earlier rows made, because the property names of one object are distinct. The first row searches nothing and never builds the map. This removes the cost that the previous head added to one row of 9 or more columns (1x9 was +12% against main, it is now -3%. 1x16 was +11%, it is now -6%).
  • Two new tests cover rows that find columns of earlier rows and add their own across the scan limit, and narrow rows after a wide first row.

The PR body has the full table. The numbers are wall clock from interleaved runs, because perf and valgrind are not available in the build container. test/js/bun/console/ and test/js/web/console/ pass (110 pass, 1 skip).

@robobun
robobun force-pushed the farm/b5bcdf88/console-table-column-index branch from 7dc0656 to 5baab32 Compare September 24, 2026 16:50

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

@robobun
robobun force-pushed the farm/b5bcdf88/console-table-column-index branch from 5baab32 to 617b86d Compare September 24, 2026 17:24

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed the new commit and found no bugs; it also addresses both inline notes from the previous run (ASAN now takes the 10,000-column size, and the border is built with Buffer.alloc). Because the hash's correctness rests on a JSC invariant the compiler cannot check (equal property names always share one atom, hence one storage encoding), a human look at that design choice would still be worthwhile.

What was reviewed:

  • Columns::find_or_create / index / create in src/jsc/ConsoleObject.rs: the narrowed scan [1..row_start] and the whole-map lookup are both behavior-preserving because a PropertyNameArray never yields a name twice per row; Map rows return before any lookup, and the properties-arg path never uses the index, so FIRST_PROPERTY_COLUMN = 1 holds on every path that reaches it.
  • The two .expect("unreachable") calls: bun_collections::HashMap::put_no_clobber always returns Ok (its Result exists for call-site symmetry), so neither is an OOM or user-reachable panic.
  • Hashing byte_slice() vs encoding-aware eql: names come from Bun::toString(prop.impl()) on Identifiers (atoms), and Column.name is a refcounted clone that pins the atom, so two encodings of one text cannot coexist while a column holds it; the empty name hashes/compares consistently as well.
  • Test file: the timing budget now branches on isASAN, and the 300-column exact-output test plus the scan-vs-indexed expectColumns matrix (non-ASCII, empty name, " ", Values) exercise both lookup paths.
Extended reasoning...

The change rewrites column lookup in the console.table TablePrinter (src/jsc/ConsoleObject.rs, about 145 lines of Rust) to scan at most 8 prior-row columns and otherwise consult a lazily built bun_collections HashMap keyed on raw name bytes, plus 155 lines of new tests. It touches no security-sensitive surface (no auth, injection, or data exposure), and neither file is covered by CODEOWNERS. No findings were reported and the prior run's two inline nits are addressed in commit 617b86d, but the hash context deliberately hashes storage bytes while comparing with encoding-aware eql, relying on a JSC atom-table invariant rather than anything enforced in code, which is a design judgment a maintainer should confirm rather than an automated approval.

TablePrinter found the column of each cell by scanning every column made
so far, so a row with N properties cost O(N^2) comparisons: one row with
40k keys took about 4 seconds to print.

A cell now searches only the columns that earlier rows made (the names of
one object are distinct), by a scan while they are few and through a
name-to-index map once they are not. The first row searches nothing.
@robobun
robobun force-pushed the farm/b5bcdf88/console-table-column-index branch from 120fcd9 to 954fd20 Compare September 24, 2026 20:48
@robobun

robobun commented Sep 24, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 954fd20. Two changes since 617b86d.

  • The scan for narrow tables is inline in collect_row again. #[inline] had left Columns::find_or_create out of line, which cost every cell of a table of 8 columns or fewer about 30 instructions. The function is now #[inline(always)] and the indexed arm is its own function. Counted with qemu on release builds, inside the lookup code: 100x8 goes from 347,521 to 328,969 instructions (main: 325,670), 1000x2 from 572,861 to 530,873 (main: 508,888). The PR body has the full table.
  • The time test is now a ratio. It renders two tables, one 8 times as wide as the other, and requires the time ratio to stay under 24. With the fix the ratio is 7 to 12. With main's ConsoleObject.rs it is 57 on a debug ASAN build and 64 on a release build. The 5 s budget it replaces could not tell the builds apart at the 10,000 columns that ASAN lanes ran.

Output is unchanged: 20,000 random tables render byte-identical on release builds of main and of this head. test/js/bun/console/ and test/js/web/console/ pass (110 pass, 1 skip). The branch is one commit on top of main again (the empty commit that reran CI is gone).

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed the latest push (954fd20) and didn't find any bugs; the two points raised on the previous version (fixed wall-clock budget, .repeat in the wide-table helper) are addressed by the ratio test and Buffer.alloc. Because this replaces the lookup on a native hot path and its correctness rests on a JSC invariant rather than on the code alone, a human look is still worthwhile.

What was reviewed:

  • The hash/eql agreement for ColumnNameContext: every name reaching find_or_create comes from Bun::toString(prop.impl()) on a PropertyNameArray Identifier (an atom of the VM's table, which is keyed by content), and the Map and properties paths never call it, so the byte hash cannot split one name across two columns.
  • The scan-only-earlier-rows shortcut: PropertyNameArray::add dedups within one iterator (own and prototype-chain walks share the array), and Proxy ownKeys duplicates throw before the printer runs.
  • The two put_no_clobber(...).expect("unreachable") calls: bun_collections::HashMap::put_no_clobber always returns Ok (its Vec storage aborts on OOM), so the panic arm is dead rather than a user-reachable path.
  • The ratio test has no absolute time cap, branches sizes on isDebug || isASAN, and takes the min of repeated runs, so a slow lane cannot fail it on a correct build.
Extended reasoning...

The change touches only src/jsc/ConsoleObject.rs (the console.table TablePrinter column lookup, ~150 lines: a lazily built name-to-index HashMap once a table exceeds 8 columns, plus an initial_columns() split) and test/js/bun/console/console-table.test.ts (a new describe block with exact-output, ratio-timing, and cross-encoding name tests). It touches no security-sensitive surface: no user input parsing beyond property names already handled by the existing printer, no auth, crypto, or I/O. Deferring rather than approving because the correctness of the byte-level hash depends on an engine-level invariant (one StringImpl per atomized property name in a VM) that a maintainer familiar with JSC's atom table should confirm, and because the file is native hot-path code with no CODEOWNERS entry. The bug hunt ran dry, and the two prior inline findings are addressed in the current commit.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed the latest push and found no bugs; the earlier timing-cap and .repeat concerns are addressed by the ratio-based test and Buffer.alloc. Since this swaps the column lookup data structure in a native hot path on a hashing premise, a human look is still worthwhile before merge.

What was reviewed:

  • The "atoms share one byte run" premise behind hashing byte_slice(): names come from Bun::toString(prop.impl()) on Identifiers out of a PropertyNameArray, which are always atomic, and each column clones (refs) its name so the atom stays in the table — equal text resolves to one StringImpl across rows.
  • put_no_clobber(..).expect("unreachable"): this HashMap always returns Ok(()) (growth goes through Vec), so no user-reachable panic; a duplicate would only trip a debug assert.
  • Scan/index switching: row_start is monotonic across rows, so once by_name exists every later lookup goes through it and create keeps it in sync; the Map, properties, and primitive-row paths never call find_or_create and are byte-for-byte the old logic.
  • Tests exercise both the scanned and indexed paths (10 filler columns), exact 300-column output, empty/" "/"Values"/non-ASCII names.
Extended reasoning...

The diff replaces the per-cell linear column scan in TablePrinter (src/jsc/ConsoleObject.rs) with a Columns struct that scans up to 8 earlier-row columns and otherwise lazily builds a wyhash-keyed HashMap<BunString, usize>, plus a new describe block in test/js/bun/console/console-table.test.ts. It touches no security-sensitive surface (no unsafe, no FFI changes, no parsing of untrusted bytes beyond property names already handled). The code is correct as far as I could verify, prior review feedback was addressed in commit 954fd20, and no CODEOWNER covers the files; I deferred rather than approved because the change is a non-trivial data-structure swap whose correctness rests on JSC atom identity, and the ratio-based timing test still carries some CI-noise risk that a maintainer may want to weigh.

TablePrinter::collect_row found the column of a cell by comparing the
text of its property name with every column made so far, so a row of n
keys cost n times n. One row of 40,000 keys took 2.4 to 2.6 s of CPU.

A property name from JSPropertyIterator is a JSC atom: one StringImpl
for each distinct text on a thread. The lookup now compares and hashes
that address, and reads no text. A Column holds a ref on its name, so
the address stays unique while the table is built.

- A cell checks only the columns of earlier rows, because one object
  has distinct names. A first row does no lookups.
- A row that starts behind 8 columns or fewer compares addresses
  inline. A wider table uses a HashMap<usize, usize> that the first
  lookup builds.
- String::atom_address returns the address.
- JSPropertyIterator::next asserts in debug builds that a name is empty
  or an atom.

An earlier head of this branch indexed the columns by a wyhash of the
name's text. Names chosen to share one hash kept the n times n cost
there and ran slower than on main. The ratio tests cover that case.
@robobun robobun changed the title Index console.table columns by name once a table gets wide Find console.table columns by the address of the property-name atom Oct 26, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed the current head (atom-address index) and didn't find any bugs; the earlier wall-clock and .repeat concerns on the timing test are addressed by the ratio-with-retry design and Buffer.alloc. Because this changes the identity rule for console.table columns from text equality to JSC atom identity and adds a timing-based assertion, a human look at the invariant and the test's CI stability is still worthwhile.

What was reviewed:

  • Column identity: every name reaches collect_row via Bun::toString(Identifier::impl()), so non-empty names are atoms and empty names are Tag::Empty (key 0) on both the scan and index paths; each Column holds a clone() ref, so an indexed address cannot be reused by a different atom during the call.
  • Index lifecycle: it is built only on the first row with more than 9 earlier columns, every later column insert goes through indexed_column, and it is unreachable on the properties and Map paths where columns have static or user-supplied non-atom names.
  • .expect("unreachable") on get_or_put/put_no_clobber matches the file's existing Counter/visited-map usage; CODEOWNERS covers none of the changed files.
Extended reasoning...

The change touches src/jsc/ConsoleObject.rs (TablePrinter column lookup, ~50 lines), adds a public String::atom_address accessor in src/bun_core/string/mod.rs, adds a debug assertion in src/jsc/JSPropertyIterator.rs, and adds a new describe block of correctness and CPU-time linearity tests in test/js/bun/console/console-table.test.ts. It touches no security-sensitive surface; the only unsafe-adjacent piece is reading the WTFStringImpl pointer address, which is kept alive by the column's own ref. Release correctness depends on a JSC invariant (property names from PropertyNameArray are atoms) that is only debug-asserted, and the new test asserts a wide/narrow timing ratio under 4 with a 1.5 s retry window, both of which merit a maintainer's judgment rather than an automated approve. The hunt exited on dry_streak with no findings, and no outstanding third-party objections appear in the timeline.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants