Skip to content

util.inspect: print numbers, errors, tags and getters as Node.js v26.12.1 does - #47303

Open
robobun wants to merge 6 commits into
mainfrom
robobun/ea4dc773/inspect-node-26-12
Open

robobun wants to merge 6 commits into
mainfrom
robobun/ea4dc773/inspect-node-26-12

Conversation

@robobun

@robobun robobun commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #23098

Problem

  • util.inspect() prints several values otherwise than Node.js v26.12.1, which main reports (Build with Bun 1.4.4, update Node.js to 26.12.1 #46533). User report: inspect(-0.12, {numericSeparator: true}) is 0..12.
  • A failed using block prints [SuppressedError] without its errors. A getter that returns a function prints <Inspection threw ...>.
  • Cause: src/js/internal/util/inspect.ts, a port of Node's lib/internal/util/inspect.js, is behind upstream.

Fix

Background

  • util.format, node:assert and Console use inspect.ts too. console.log and Bun.inspect use a native formatter.
  • Considered the reported rows only: -0.5 stays 0..5. Considered no guards: 10 of 16 hostile members made inspect throw.
  • Considered Node's formatValue in the getter catch: a thrown DOMException of Bun's then prints a raw stack and no message (DOMException (AbortError) prints raw error object and an incorrect stack frame #37419).

Downsides

  • Programs that compare this text see changes (Notes). Inside <Inspection threw (...)> Bun still prints the message, and Node.js the thrown value.
  • Per call (release): a Map +141 instructions (+3.9%, 1 more String#lastIndexOf), a fraction with numericSeparator +646 (+50%), a colored error with frames outside the cwd and node_modules +8.2%. Module text: +2,412 bytes.
Notes

Each change, with the output of main 11c3671 and of this branch. The branch column is also what a Node.js v26.12.1 binary prints, unless the cell says otherwise. "Older" means that Node.js v26.3.0 already had it.

Function Input main This branch, Node.js v26.12.1 Upstream
formatNumber inspect(-0, { numericSeparator: true }) 0 -0 nodejs/node#63815, v26.4.0
formatNumber inspect(1.5e-10, { numericSeparator: true }) 0.5e-_10 1.5e-10 nodejs/node#63823, v26.4.0
formatNumber inspect(-0.12, { numericSeparator: true }) 0..12 -0.12 older
markNodeModules colored frame (/a/node_modules/@s/pkg/i.js:1:1) @s underlined @s/pkg underlined older
markNodeModules colored frame at /a/node_modules/pkg no underline pkg underlined nodejs/node#64022, v26.5.0
reduceToSingleString inspect({ a: twoLines, b: 1 }, { breakLength: Infinity, compact: true }) { a: l1\n l2, b: 1 } { a: l1\n l2,\n b: 1 } nodejs/node#64238, v26.8.0
formatProperty inspect({ get a() { return function foo() {} } }, { getters: true }) [Getter: <Inspection threw (Symbol.prototype.toString requires ...)>], and the rest is indented 2 more { a: [Getter] [Function: foo] } nodejs/node#64839, v26.8.0
formatProperty inspect({ get a() { throw null } }, { getters: true }) throws an AssertionError { a: [Getter: <Inspection threw (null)>] } older
formatProperty a getter that throws an Error whose message getter throws throws an AssertionError { a: [Getter: <Inspection threw (undefined)>] }. Node.js prints the error in the parentheses Bun only
formatProperty a getter that returns a function whose inspect.custom throws that Error [Getter: <Inspection threw (Symbol.prototype.toString requires ...)>] the same as the row above nodejs/node#64839, and Bun only
getPrefix inspect(new Intl.NumberFormat("en")) NumberFormat [Intl.NumberFormat] {} Intl.NumberFormat {} nodejs/node#63257, v26.9.0
formatError inspect(new SuppressedError(1, 2, "m")), no stack frames [SuppressedError: m] [SuppressedError: m] { [error]: 1, [suppressed]: 2 } nodejs/node#66159, v26.11.0
formatError an error class with a cause() method [E [Error]: m] { cause: [Function: cause] } [E [Error]: m] nodejs/node#61032, older
formatError, getStackString an Error whose stack getter throws throws [Error: m] older, from #45254
formatExtraProperties inspect([detachedDataView, { a: 1 }]) every line indented 4 more as for a live DataView nodejs/node#66421, v26.11.0
formatRaw inspect({ a: { [inspect.custom]() { throw new RangeError("boom") } } }) throws AssertionError: handleMaxCallStackSize assertion failed: RangeError: boom throws the RangeError nodejs/node#55544, older
formatRaw a value whose Symbol.toStringTag getter throws throws {} older, from #46414
builtInObjects inspect(new Float16Array(2), { showHidden: true }) walks into the prototype as the other typed arrays the V8 of v26.12.1

Not in this PR.

Other open PRs that this touches.

Against Node.js v26.12.1 (real binary, same scripts).

  • The 7-row script of the report: exit 0, all 7 as Node.js 26.12.1. Main: exit 1, 7 of 7 as Node.js 26.3.0.
  • 100 probes of the nine upstream hunks: 92 equal (main 49). The 8 left are 7 ansi rows and 1 showHidden row that differs by JavaScriptCore's own line, column and sourceURL keys.
  • The 99 rows of the fixture: 98 equal (main 21). The one left is the Proxy row. inspect throws for 3 rows, the same 3 as on Node.js, with the same error. The rows with a getter that throws leave out the text in the parentheses.
  • 16 rows with a hostile own error or suppressed (a throwing Symbol.toStringTag, stack, name, message, cause or errors getter, a revoked Proxy, a throwing inspect.custom): 2 throw, the same 2 as on Node.js, with the same error. Main printed none of these members.
  • 53 more rows with accessors and node:assert messages: none throws that did not throw on main.
  • 23 hostile errors (a Symbol, an object, a throwing getter or a throwing toString as name, message or stack, an Error.prepareStackTrace that throws or returns no string, and others), each inspected directly and thrown by a getter: none of the 46 rows throws that did not throw on main, and no getter row throws. 12 rows print where main threw.
  • The 7-row script, the 100 probes, the 16 hostile rows and the 53 rows ran at e797ab5. Apart from a merge of main, the commits after it change only the catch of the getter arm.

Cost, main against this branch.

  • Instructions per call, user space, perf_event_open over a fixed loop of the built-in util.inspect on release builds of main and of e797ab5, concurrent JIT off, median of 9 pairings. The commits after e797ab5 change only the catch of the getter arm, which no row enters:
Call main branch
inspect(5) 316 316 0
inspect({ a: 1, b: "x" }) 5,676 5,670 within noise
nested object, 6 containers 32,233 32,290 +0.2%
inspect(new Map([[1, 2]])) 3,585 3,727 +3.9%
inspect(new Set([1])) 2,930 3,062 +4.5%
inspect(new Uint8Array(4)) 5,660 5,805 +2.6%
inspect(new Error("x")) 8,622 8,463 -1.8%
error with a cause 23,753 23,360 -1.7%
nested, breakLength: Infinity 31,859 30,826 -3.2%
nested, breakLength: Infinity, compact: true 57,302 58,675 +2.4%
inspect(1234567, { numericSeparator: true }) 854 863 +1.1%
inspect(1234.5678, { numericSeparator: true }) 1,290 1,936 +50.1%
colored error, 10 frames under the working directory 33,481 31,636 -5.5%
colored error, 10 frames in node_modules 49,281 48,046 -2.5%
colored error, 10 frames in neither 26,738 28,927 +8.2%
  • Host calls per call (83 scenarios, the captured primordials wrapped, debug build): 26 of 52 default-option scenarios are identical. A tagged value has 1 more String#lastIndexOf. An Error has 3 more Object#hasOwnProperty, and startsWith and slice in place of a RegExp replace.
  • GC cells per call (BUN_JSC_useJIT=0 BUN_JSC_useGC=0, heapStats().objectTypeCounts): no default-option scenario allocates more. The 7 with an Error allocate 1 to 3 fewer. numericSeparator with a fraction allocates 1 more string.
  • Bytecode instructions, static: formatNumber 91 to 110, getPrefix 56 to 79, formatError 289 to 378, markNodeModules 53 to 92, reduceToSingleString 198 to 209, formatProperty 309 to 316, formatRaw 1,049 to 1,041.
  • Bytes: the bundled text of this module in release form is 76,232 bytes on main and 78,644 here: +2,412. At e797ab5 it was +2,361, and size of the two release binaries gave text 138,010,898 and 138,013,261 (+2,363), with data and bss unchanged and a file 4,096 bytes larger.
  • First read of util.inspect: 1,012 cells (main 1,013). One module-level RegExp fewer.

Tests.

Suites run on the debug build of this branch. test/js/node/util/: 645 pass. test/js/node/assert/, test/js/node/console/, test/js/bun/console/, test/js/node/events/ and 3 files of test/js/bun/util/: 906 pass. Vendored test-assert*, test-console*, test-repl*, test-util*, test-errors-* and test-event-emitter-errors: 142 of 142. These ran with a 60 s limit per test, because 3 tests of test/js/node/util/ pass the 5 s limit on a debug build of main too. bun lint, bun fmt --check and the type check of the built-in modules report nothing for the changed files.


no test proof · iteration 1 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/util/node-inspect-tests/parallel/util-inspect.test.js

robobun and others added 3 commits October 28, 2026 14:01
…0 to v26.12.1

Bun reports Node.js v26.12.1. Its port of inspect.js still printed these
values as v26.3.0 or an older release did.

- formatNumber: the -0 test runs before the numericSeparator branch and the
  number is split at its decimal point. -0 keeps its sign, -0.5 is no longer
  "0..5", and exponent forms are left alone.
- formatError: an own `error` or `suppressed` property is printed, so a
  SuppressedError shows the two errors it carries.
- getPrefix: a Symbol.toStringTag that ends in the constructor name after a
  dot is printed alone ("Intl.NumberFormat {}").
- reduceToSingleString: with breakLength: Infinity and compact: true, an entry
  that has a line break ends the line.
- formatExtraProperties: the value is read before the indent, so a detached
  DataView does not indent the rest of the output.
- formatProperty: a getter that returns a function is printed as a value. The
  indent is restored when the value throws. A getter that throws null or
  undefined is reported.
- markNodeModules: the scan of Node.js replaces the RegExp. The name of a
  scoped package is underlined whole.

The ansi RegExp of stripVTControlCharacters is not part of this change.

Co-authored-by: 0xfandom <kashyapshivank01@gmail.com>
An own `error` or `suppressed` of an Error now reaches formatValue. These two
changes keep util.inspect from throwing for the values Node.js prints there.

- #45254: getStackString, removeDuplicateErrorKeys and formatError do not throw
  when the stack, name, message or errors getter of an error throws.
- #46414: formatRaw reads Symbol.toStringTag in a try.
…throws, Float16Array

- formatError prints `cause` and `errors` when they are own properties, as it
  does for `error` and `suppressed` (nodejs/node#61032). An inherited `cause`
  method or `errors` getter is no longer printed as a key.
- formatRaw rethrows an error that is not a stack overflow (nodejs/node#55544).
  Before, it threw an AssertionError with the text of that error.
- Float16Array is in builtInObjects, as in Node.js v26.12.1: showHidden does not
  walk into its prototype.
- The inputs of the new tests are in a fixture that Node.js runs too. One test
  gives them to a Node.js that is v26.12.1 or later and compares the output.
@coderabbitai

coderabbitai Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 12 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

Run configuration
Commits

Reviewing files that changed from the base of the PR and between 9fe1cd6 and 1211881.

Files selected for processing: 7
  • src/js/internal/util/inspect.ts
  • test/js/node/assert/assert.spec.ts
  • test/js/node/inherited-errors.ts
  • test/js/node/util/node-inspect-tests/internal-inspect-fixture.cjs
  • test/js/node/util/node-inspect-tests/internal-inspect.test.js
  • test/js/node/util/node-inspect-tests/parallel/util-inspect.test.js
  • test/js/node/util/util.test.js
  • 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 Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:04 PM PT - Oct 29th, 2026

❌ @robobun, your commit 1211881 has 2 failures in Build #135563 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 47303

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

bun-47303 --bun

@robobun

robobun commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on main 11c3671 with this script. The same script exits 0 on a Node.js v26.12.1 binary.

import { inspect } from "node:util";
const twoLines = { [inspect.custom]: () => "l1\nl2" };
const rows = [
  [inspect(-0, { numericSeparator: true }), "-0"],
  [inspect(-0.12, { numericSeparator: true }), "-0.12"],
  [inspect(new Intl.NumberFormat("en")), "Intl.NumberFormat {}"],
  [inspect({ a: twoLines, b: 1 }, { breakLength: Infinity, compact: true }), "{ a: l1\n   l2,\n  b: 1 }"],
  [inspect({ get a() { return function foo() {}; } }, { getters: true }), "{ a: [Getter] [Function: foo] }"],
  [inspect(new SuppressedError(1, 2, "m")).includes("[suppressed]: 2"), true],
];
const wrong = rows.filter(([got, want]) => got !== want);
console.log(`${wrong.length} of ${rows.length} differ from Node.js v26.12.1`);
process.exit(wrong.length ? 1 : 0);
  • main: 6 of 6 differ from Node.js v26.12.1, exit 1
  • this branch: 0 of 6 differ from Node.js v26.12.1, exit 0

PR: #47303

CI (build 135563, head 1211881, finished)

  • The tests of this PR pass on every lane.
  • Two tests are red. Neither comes from this PR, which does not touch the AOT engine or node:http.
    • test/aot/engine/ir-6.test.ts (pipeline/gc/custom-accessor-reached-computed-key) is red on main too, in builds 135526 (f7607a3) and 135376 (9fe1cd6).
    • test/aot/parallel/node-http/objects-closed-through-others.ts failed on macOS x64. It also failed in builds 135662 and 135642 of other branches.

@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.

Beyond the inline finding, I also checked the observable-behavior shifts this port introduces and ruled them out as deliberate upstream ports rather than regressions: cause/errors switching to an own-property check (nodejs/node#61032), own error/suppressed being printed for any error rather than only SuppressedError (matches upstream's formatError), and the compact: true + breakLength: Infinity line-break change (nodejs/node#64238). Also confirmed the new primordials (StringPrototypeLastIndexOf, StringPrototypeStartsWith, ObjectPrototypeToString) are captured at module load and that isForced and nodeModulesRegExp have no remaining references.

Extended reasoning...

The change ports upstream Node.js inspect.js hunks into src/js/internal/util/inspect.ts (formatNumber, getPrefix, formatError, getStackString, markNodeModules, formatProperty, formatExtraProperties, reduceToSingleString) and adds tests across five test files; it touches no auth, crypto or injection surface, only diagnostic string formatting. The one inline finding is a pre-existing indentation issue in the getter path, and the remaining candidate concerns were traced to intentional upstream behavior, so this note records what else was examined.

Comment thread src/js/internal/util/inspect.ts Resolved
@robobun

robobun commented Oct 29, 2026

Copy link
Copy Markdown
Collaborator Author

There is a second set of branches for the same outputs. No PR is open for them, so that this PR stays the only one.

One input prints on main and on Node.js v26.12.1, and util.inspect throws for it with the inspect.ts of this PR (e797ab5):

const util = require("node:util");
const inner = new Error("x");
inner.stack = "Error: x";
Object.defineProperty(inner, "message", { get() { throw new Error("from the message getter"); } });
const fn = Object.assign(function f() {}, { [util.inspect.custom]() { throw inner; } });
console.log(util.inspect({ get a() { return fn; } }, { getters: true }));
inspect.ts Result
main 11c3671 prints a: [Getter: <Inspection threw (Symbol.prototype.toString requires that |this| be a symbol or a symbol object)>]
Node.js v26.12.1 prints { a: [Getter: <Inspection threw ([Error: x])>] }
this PR throws Error: from the message getter
the first branch above prints { a: [Getter: <Inspection threw ([Error: x])>] }

I ran each inspect.ts as the module text of one debug build of main. With this PR the getter arm sends the function to formatValue, its inspect.custom throws, and the catch then reads err.message.

…s not make inspect throw

The getter arm of formatProperty sends a function to formatValue since
nodejs/node#64839, so what the printing of that function throws reaches
the catch, which read err.message with no guard. A thrown error whose
message getter throws made util.inspect throw. The catch now reads the
message in a try and prints undefined when it cannot.

Node.js prints the thrown value there with formatValue. Bun keeps the
message: formatError prints a DOMException that Bun throws without its
name and message (#37419).
Comment thread src/js/internal/util/inspect.ts Resolved

@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.

Comment thread src/js/internal/util/inspect.ts Resolved

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.

util.inspect incorrectly formats negative fractional numbers with numericSeparator: true

2 participants