Skip to content

node:console: fix Console#table throwing on object cells, Maps and Sets - #33452

Open
robobun wants to merge 1 commit into
mainfrom
farm/c6ca6612/console-class-table-isbuffer
Open

robobun wants to merge 1 commit into
mainfrom
farm/c6ca6612/console-class-table-isbuffer

Conversation

@robobun

@robobun robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

new Console(stream).table(data) throws TypeError: undefined is not a function for any table with an object cell, and for every Map or Set whose values are objects. Only tables made entirely of primitives render, which is why a smoke test never catches it. The global console.table is a separate native renderer, so a library that builds its own Console over a file stream or a capture buffer is the only thing that hits this.

Repro

import { Console } from "node:console";
import { Writable } from "node:stream";

let out = "";
const sink = () => new Writable({ decodeStrings: false, write(c, e, cb) { out += c; cb(); } });
const c = new Console({ stdout: sink(), stderr: sink() });

c.table([{ a: { x: 1 } }, { a: 2 }]);  // TypeError: undefined is not a function
c.table([[1, { y: 2 }]]);              // TypeError: undefined is not a function
c.table(new Map([["k1", { a: 1 }]]));  // TypeError: undefined is not a function
c.table(new Set([1, { x: 3 }]));       // TypeError: undefined is not a function

c.table([{ a: 1, b: "x" }]);           // renders

Cause

src/js/builtins/ConsoleObject.ts classifies table cells with

const isArray = v => $isJSArray(v) || $isTypedArrayView(v) || isBuffer(v);

and imported the predicate as const { isBuffer } = require("node:buffer"). node:buffer has no top-level isBuffer export (in Bun or in Node), so isBuffer was undefined. isArray is evaluated for every cell that is an object, and the first one that is neither an array nor a typed array reaches the isBuffer(v) call and throws. Arrays and typed arrays short-circuit, and primitives never reach isArray at all, which is why primitive-only tables work.

This has been wrong since console.Console landed in #5448.

Fix

Destructure isBuffer off Buffer, which is what Node's lib/internal/console/constructor.js does:

const { Buffer: { isBuffer } } = require('buffer');

Verification

Checked every case against node v26.3.0: object cells in object rows and in array rows, Map with object keys and object values, Set with object values, Buffer cells, typed array cells, nested array cells, the properties filter, and the > 2 keys path that collapses to [Object]. Cell contents and column layout now match Node in all of them.

New tests live in test/js/node/console/console.test.ts; 6 of the 8 fail on main with the TypeError and pass with the fix.

Before / after
$ bun-1.4.0 repro.js
THREW TypeError: undefined is not a function
THREW TypeError: undefined is not a function
THREW TypeError: undefined is not a function
THREW TypeError: undefined is not a function

$ bun-debug repro.js
┌─────────┬──────────┐
│ (index) │    a     │
├─────────┼──────────┤
│    0    │ { x: 1 } │
│    1    │    2     │
└─────────┴──────────┘
┌─────────┬───┬──────────┐
│ (index) │ 0 │    1     │
├─────────┼───┼──────────┤
│    0    │ 1 │ { y: 2 } │
└─────────┴───┴──────────┘
┌───────────────────┬──────┬──────────┐
│ (iteration index) │ Key  │  Values  │
├───────────────────┼──────┼──────────┤
│         0         │ 'k1' │ { a: 1 } │
└───────────────────┴──────┴──────────┘
┌───────────────────┬──────────┐
│ (iteration index) │  Values  │
├───────────────────┼──────────┤
│         0         │    1     │
│         1         │ { x: 3 } │
└───────────────────┴──────────┘

@coderabbitai

coderabbitai Bot commented Jul 6, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 15 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8c137684-17b3-4d1e-8f82-3d4ad382f46e

📥 Commits

Reviewing files that changed from the base of the PR and between 2291e1a and d4c969e.

📒 Files selected for processing (2)
  • src/js/builtins/ConsoleObject.ts
  • test/js/node/console/console.test.ts

Walkthrough

This PR modifies the node:buffer import in ConsoleObject.ts to source isBuffer via nested destructuring from Buffer, and adds a new test suite for Console#table covering rendering of objects, arrays, Maps, Sets, Buffers, property filtering, and primitive cells using inline snapshot assertions.

Changes

Console#table Buffer Detection and Tests

Layer / File(s) Summary
isBuffer destructuring fix
src/js/builtins/ConsoleObject.ts
Changes how isBuffer is obtained from node:buffer inside createConsoleConstructor, using nested Buffer: { isBuffer } destructuring instead of a top-level require destructure.
Console#table test suite
test/js/node/console/console.test.ts
Adds a table() helper that captures Console#table output via a custom Writable sink, and adds inline snapshot tests for object cells, array rows with objects, Map, Set, Buffer, property filtering, multi-key object collapsing, and primitive cells.

Possibly related PRs

  • oven-sh/bun#33133: Updates Map/Set iterator detection used by Console#table, which is the same rendering path exercised by the new tests in this PR.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main fix: Console#table no longer throws on object cells, Maps, and Sets.
Description check ✅ Passed The description includes the PR intent and verification details, even though it uses custom subsections instead of the exact template headings.
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.

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

@github-actions github-actions Bot added the claude label Jul 6, 2026
@robobun

robobun commented Jul 6, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:48 AM PT - Jul 6th, 2026

@robobun, your commit d4c969e is building: #69071

@robobun

robobun commented Jul 6, 2026 •

Copy link
Copy Markdown
Collaborator Author

Reproduced on stock 1.4.0 and on main with a new Console({ stdout }) over a node:stream Writable: every table() call with an object cell, or a Map/Set holding one, throws TypeError: undefined is not a function.

Fail-before/pass-after: test/js/node/console/console.test.ts has 6 of its 8 new table cases failing without the src/ change and passing with it. Node's test/parallel/test-console-*.js suite still passes, including test-console-with-frozen-intrinsics.js.

CI status on d4c969eb (build 69071)

The diff is green. The build is marked failed, but nothing actually failed:

passed 275
failed 0
expired 11

The 11 expired jobs have started_at: null and exit_status: null — they were never dispatched to an agent, they aged out of the queue. They are all on the scarcer pools (darwin x6, ubuntu x2, alpine x3). 219 of the 230 test-bun lanes ran and passed, and the build carries no error annotations at all; the only non-info annotation is a flaky warning for test/napi/napi.test.ts on Windows x64-baseline, which passed on its retry.

Nothing here is reachable from this change: the diff is two .ts files, one of them a test.

I'd rather not force-push a no-op commit just to re-queue 277 jobs into the pool that just dropped 11, so I've left it. A "Rebuild" or "Retry failed jobs" from the Buildkite UI is the cheap fix if a green tick is wanted before merge (my API token is read-scoped, so I can't retry the expired jobs myself).

For context on the earlier red run (build 68845): that was a genuine failure, but not from this change either. The branch predated #33425, so it carried cookie-map.test.ts's pre-IMF-fixdate assertions against the new serializer from #32926. Rebasing onto 48ff9eb fixed it, and every cookie-map lane has passed since.

@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 — one-line destructuring fix matching Node's own constructor.js, with solid snapshot coverage.

Extended reasoning...

Overview

This PR fixes a single incorrect destructuring in src/js/builtins/ConsoleObject.ts: const { isBuffer } = require("node:buffer") becomes const { Buffer: { isBuffer } } = require("node:buffer"). The node:buffer module has no top-level isBuffer export (in Bun or Node) — it lives on Buffer. The undefined binding meant Console#table threw TypeError: undefined is not a function whenever a cell was a non-array object, since the isArray classifier fell through to calling isBuffer(v). The fix mirrors Node's own lib/internal/console/constructor.js verbatim. Eight new inline-snapshot tests are added to the existing test/js/node/console/console.test.ts covering object cells, array rows, Map/Set with object keys/values, Buffer cells, the properties filter, the >2-key collapse path, and a primitive-only baseline.

Security risks

None. This is output formatting for console.table on a user-constructed Console instance. No parsing of untrusted input, no auth, no filesystem or network I/O, no memory management. The change only makes an already-referenced predicate resolve to a real function instead of undefined.

Level of scrutiny

Low. The source change is three lines (a destructuring pattern) and is mechanically verifiable: Buffer.isBuffer is a documented static method and Bun defines it in src/js/builtins/JSBufferConstructor.ts. The only consumer of the binding is the local isArray helper used inside table(). There are no other call sites to audit and no behavioral change beyond "stops throwing and renders like Node".

Other factors

Tests are added to the existing test file (not a new one), use inline snapshots verified against Node v26.3.0 output per the PR description, and exercise every branch the isArray predicate feeds (_inspect depth selection, Map/Set paths, properties filter). Six of the eight new tests fail on main with the exact TypeError, so the regression guard is real. No outstanding reviewer comments; only bot noise (CodeRabbit rate-limit notice, robobun build link) in the timeline. No prior review from me.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

No changes needed from the review.

One note for anyone triaging this next to the other open console.table PRs: this one is disjoint from them. #32619 (index header / alignment) and #32224 (control-char escaping) both touch the native renderer in src/jsc/ConsoleObject.rs, which backs the global console.table. This PR only touches src/js/builtins/ConsoleObject.ts, the separate JS renderer behind the node:console Console class. No overlapping files, no overlapping snapshots.

Relatedly, Bun's Console class still centers cells where Node left-aligns, and still renders map.entries() as a single Values column instead of Key/Values (Node uses previewEntries, which Bun has commented out). Both are pre-existing and out of scope here.

@robobun
robobun force-pushed the farm/c6ca6612/console-class-table-isbuffer branch from 54161f6 to 2291e1a Compare July 6, 2026 14:21

@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 — one-line destructuring fix that mirrors Node's own constructor.js, with solid snapshot coverage.

Extended reasoning...

Overview

The PR fixes Console#table (the node:console class, not the native global console.table) throwing TypeError: undefined is not a function whenever a table cell is a non-array object, or when passed a Map/Set containing objects. The root cause is a bad destructure: const { isBuffer } = require("node:buffer") binds undefined because node:buffer has no top-level isBuffer export (only Buffer.isBuffer). The fix changes it to const { Buffer: { isBuffer } } = require("node:buffer"), which is exactly what Node's lib/internal/console/constructor.js does. The only source change is that single destructure in src/js/builtins/ConsoleObject.ts; the rest is 8 new snapshot tests in test/js/node/console/console.test.ts.

Security risks

None. This is a pure output-formatting path (Console#table) with no auth, filesystem, network, or crypto involvement. The change swaps one undefined binding for a correct static method reference; it does not introduce any new user-controlled input handling or change any trust boundary.

Level of scrutiny

Low. The functional change is a single, mechanical destructuring fix in a Node-compat JS builtin, with a clear root cause and a direct upstream reference implementation to compare against. It is not in a hot path for the default global console (which uses the native renderer), and it cannot make anything worse — the previous binding was undefined, so any code path reaching isBuffer(v) already threw.

Other factors

  • No CODEOWNERS entries cover the touched files.
  • Bug hunter found no issues.
  • Tests are well-constructed: they exercise object cells, array rows, Map (object key + object value), Set, Buffer cells, the properties filter, the > 2 keys → [Object] collapse, and a primitive-only baseline. 6 of 8 fail-before/pass-after per the PR verification.
  • No outstanding human review comments; only bot noise (CodeRabbit rate limit, robobun build/verification).
  • The pre-existing divergences noted (centering vs left-align, previewEntries) are correctly scoped out and unrelated to this fix.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Force-pushed a rebase onto main (54161f6 -> 2291e1a). The diff is unchanged: same two files, same 120 lines.

Worth flagging why the earlier run on this PR was red, because it was not this change. Every test-bun lane failed on test/js/bun/cookie/cookie-map.test.ts:

- "__Host-id=; Path=/; Expires=Fri, 1 Jan 1970 00:00:00 -0000; Secure; SameSite=Lax",
+ "__Host-id=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Secure; SameSite=Lax",

The branch was cut from 9f29e55, which carries #32926 (Bun.Cookie now emits an IMF-fixdate for Expires) but not #33425, the test-only follow-up that updates those four assertions. So the branch had the new serializer against the old expectations. Main's own build at #33425 has no failure annotations, and that commit's diff is exactly the four strings CI printed.

The rebase picks it up. cookie-map.test.ts and console.test.ts pass together locally now (48 pass, 0 fail). The darwin-x64 terminal.test.ts timeout and the Windows napi / net-dns-custom-lookup failures in that run were already bucketed by Buildkite as flaky-with-retry.

The Console class renderer classifies cells with
`$isJSArray(v) || $isTypedArrayView(v) || isBuffer(v)`, but isBuffer was
destructured off the node:buffer module namespace, which has no top-level
isBuffer export. It was undefined, so the first non-array object cell threw
"TypeError: undefined is not a function".

Destructure it off Buffer, matching node's lib/internal/console/constructor.js.
@robobun
robobun force-pushed the farm/c6ca6612/console-class-table-isbuffer branch from 2291e1a to d4c969e Compare July 6, 2026 14:48

@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 — one-line destructuring fix (Buffer.isBuffer instead of the nonexistent top-level isBuffer export) with solid snapshot coverage.

Extended reasoning...

Overview

The source change is a single destructuring fix in src/js/builtins/ConsoleObject.ts: const { isBuffer } = require("node:buffer") → const { Buffer: { isBuffer } } = require("node:buffer"). Verified independently that require("node:buffer").isBuffer is undefined in Node (and Bun), while Buffer.isBuffer is a function — so the previous binding was always undefined and the isArray predicate would throw on the first non-array/non-typed-array object cell. The rest of the diff is 8 new inline-snapshot tests in test/js/node/console/console.test.ts exercising object cells, array rows, Map/Set, Buffer cells, the properties filter, and the >2-key collapse path.

Security risks

None. This is a Node-compat rendering path for console.Console#table; no auth, crypto, filesystem, or network surface is touched, and the change only affects which existing static method is bound at module init.

Level of scrutiny

Low. The fix is mechanical and matches Node's own lib/internal/console/constructor.js (const { Buffer: { isBuffer } } = require('buffer')). It only affects the JS Console constructor path, not the native global console.table renderer, so blast radius is limited to code that constructs its own Console instance.

Other factors

Tests are placed in the correct existing file, use colorMode: false for stable snapshots, and 6 of 8 fail on main per the PR's fail-before/pass-after check. The earlier CI red was an unrelated cookie-map snapshot drift resolved by rebase. No prior reviewer comments to address and no CODEOWNER-gated paths involved.

@robobun

robobun commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

New evidence for this PR, found in the review of #42359. The bug is not limited to new Console(...). A Worker uses this JS Console class as its global console, so the global console.table with an object cell throws inside a node:worker_threads Worker:

const { Worker, isMainThread } = require("node:worker_threads");
if (isMainThread) {
  const w = new Worker(__filename);
  w.on("error", e => console.log("worker error:", e.message));
  w.on("exit", code => console.log("worker exit", code));
} else {
  console.table([{ createdAt: new Date(0) }]);
}
// bun 1.4.2:  worker error: undefined is not a function, then worker exit 1
// node 26.3:  prints the table, then worker exit 0

#42359 carries the identical import line, because Node's per-cell path for table(map.entries()) needs it. The lines are the same, so the PR that lands second rebases without a conflict. This one is the smaller change and it fixes the wider bug, so it is the better one to land first.

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.

1 participant