Skip to content

util.inspect: freeze builtInObjects to Node's 47-name bootstrap set - #36138

Open
robobun wants to merge 6 commits into
mainfrom
farm/e65b4392/util-format-s-buffer
Open

util.inspect: freeze builtInObjects to Node's 47-name bootstrap set#36138
robobun wants to merge 6 commits into
mainfrom
farm/e65b4392/util-format-s-buffer

Conversation

@robobun

@robobun robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Reproduction

import util from 'node:util';
console.log(util.format('%s', Buffer.from('ab')));
console.log(util.format('%s', new URLSearchParams('a=1')));
Node 26.3.0 Bun before Bun after
util.format('%s', Buffer.from('ab')) ab <Buffer 61 62> ab
util.format('%s', new URLSearchParams('a=1')) a=1 URLSearchParams {} a=1

Any logger built on util.format (winston, pino-pretty style %s interpolation) printed <Buffer ...> noise where Node prints the decoded content, and URLSearchParams {} where Node prints the query string.

Cause

hasBuiltInToString in src/js/internal/util/inspect.js classifies an inherited toString as "built-in" when the prototype that owns it has a constructor whose name is in builtInObjects. Node populates that set at bootstrap from Object.getOwnPropertyNames(globalThis), at which point only the ECMAScript language intrinsics are present. Buffer, URL, URLSearchParams, Request, and every other Node/Web global are installed later, so they never appear in Node's set.

Bun already has those globals on globalThis when this module loads, so the live scrape collected 115 names. hasBuiltInToString(Buffer.from('ab')) walked to Buffer.prototype, saw constructor name "Buffer", found it in the set, returned true, and %s took the inspect branch instead of calling Buffer.prototype.toString.

Fix

Replace the dynamic scrape with the 47 names Node v26.3.0 observes at bootstrap:

AggregateError Array ArrayBuffer Atomics BigInt BigInt64Array BigUint64Array
Boolean DataView Date Error EvalError FinalizationRegistry Float32Array
Float64Array Function Infinity Int16Array Int32Array Int8Array Intl Iterator
JSON Map Math NaN Number Object Promise Proxy RangeError ReferenceError Reflect
RegExp Set String Symbol SyntaxError TypeError URIError Uint16Array Uint32Array
Uint8Array Uint8ClampedArray WeakMap WeakRef WeakSet

This deliberately excludes SharedArrayBuffer, WebAssembly, Float16Array, DisposableStack, AsyncDisposableStack and SuppressedError. Those are language globals too, but V8 installs them after Node computes the set, so they are absent from Node's builtInObjects and the difference is observable: util.inspect(new Float16Array(1), { showHidden: true }) walks into Float16Array.prototype in Node, while Float32Array does not. Matching Node means matching that asymmetry.

Buffer, URL, URLSearchParams and any user class with a toString now take the String() path through %s. ECMAScript built-ins (Array, Error, Map, Date, boxed primitives, ...) keep the inspect path as before.

The util-inspect.test.js typed-array showHidden loops drop Float16Array (upstream's test-util-inspect.js never had it), and that 1500-line test body gets a debug-build timeout: its surrogate-pair loop runs ~10k util.inspect calls and already overshoots the 5s default under bun bd on main.

Verification

$ git stash push -- src/ && bun bd test test/js/node/util/node-inspect-tests/parallel/util-format.test.js
AssertionError: Expected values to be strictly equal:
+ '<Buffer 61 62>'
- 'ab'
(fail) no assertion failures

$ git stash pop && bun bd test test/js/node/util/node-inspect-tests/parallel/util-format.test.js
(pass) no assertion failures

Also green: test/js/bun/util/inspect.test.js, test/js/node/util/bun-inspect.test.ts, test/js/node/util/util.test.js, test/js/node/util/custom-inspect.test.js, test/js/node/util/node-inspect-tests/parallel/util-inspect.test.js, test/js/node/util/node-inspect-tests/parallel/util-inspect-proxy.test.js, test/js/node/console/, test/js/bun/console/, test/js/web/console/.

The global console.log('%s', ...) throw on Symbol/null-prototype/revoked-proxy is a separate native-side path (PercentTag::S in ConsoleObject.rs); #34603 addresses that.


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

Node computes the builtInObjects set during bootstrap before Buffer, URL,
and other Node/Web globals are installed on globalThis, so only ECMAScript
language built-ins end up in the set. Bun has those globals present when
inspect.js loads, so the set was too broad and hasBuiltInToString treated
Buffer.prototype.toString as a generic built-in toString, sending Buffers
down the inspect path for %s.

Hardcode the ECMAScript language built-in names so that %s with a Buffer,
URL, URLSearchParams, or any class that defines its own toString on a
non-language prototype goes through String() like Node does.
@robobun

robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced with util.format('%s', Buffer.from('ab')) returning <Buffer 61 62> instead of ab. Fix: hardcode builtInObjects in inspect.js to the ECMAScript language intrinsics Node observes at bootstrap, so Buffer/URL/URLSearchParams take the String() path through %s.

Gate passed (test fails without the fix and passes with it on ASAN and release). All review feedback addressed through 38e2f55. CI build 83594 is red only on test/js/bun/http/serve.test.ts ("request body backpressure > releases a paused request body...") on darwin, which also fails on main build 83238 and is unrelated to this change. The util-format and util-inspect tests pass on every lane.

This is the focused util.format-only fix. #36141 is a broader change that also routes the native console.log('%s', ...) path through the same JS routine; it includes the same builtInObjects list. Either can land.

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

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

Next review available in: 4 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: 761b9812-0d50-4564-b5e4-0b61892a63ba

📥 Commits

Reviewing files that changed from the base of the PR and between 4eb6f99 and 38e2f55.

📒 Files selected for processing (3)
  • src/js/internal/util/inspect.js
  • test/js/node/util/node-inspect-tests/parallel/util-format.test.js
  • test/js/node/util/node-inspect-tests/parallel/util-inspect.test.js

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

@robobun

robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 2:47 PM PT - Jul 27th, 2026

@robobun, your commit 38e2f55 has 1 failures in Build #83594 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 36138

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

bun-36138 --bun

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. util: %s should inspect built-ins that inherit Symbol.toPrimitive #33462 - Also modifies hasBuiltInToString in src/js/internal/util/inspect.js to fix %s formatting incorrectly treating non-ECMAScript built-ins like Buffer and URL, and adds tests to the same test file

🤖 Generated with Claude Code

@robobun

robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

Not a duplicate of #33462. That PR updates the prototype walk inside hasBuiltInToString so an inherited Symbol.toPrimitive from Date/boxed Symbol is recognized as built-in. It leaves builtInObjects alone, so Buffer.prototype.toString still lands in the set and %s still prints <Buffer ...>.

This PR changes the builtInObjects set itself (hardcodes the ECMAScript language intrinsics, dropping Buffer/URL/Request/...). The two are complementary; neither subsumes the other.

@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 — correct root-cause fix for util.format('%s', Buffer) Node compat.

What was reviewed:

  • Traced all three consumers of builtInObjects (hasBuiltInToString, getConstructorName, addPrototypeProperties) — each moves closer to Node's behavior since Node's set never contained Buffer/URL/etc.
  • Verified the hardcoded list covers Node's bootstrap-time set (plus newer language intrinsics like Float16Array, DisposableStack) and excludes Node/Web globals as intended.
  • Confirmed the removed primordials (ArrayPrototypeFilter, RegExpPrototypeExec, ObjectGetOwnPropertyNames) are still used elsewhere — no dead code introduced.
  • Tests cover positive cases (Buffer, URL, URLSearchParams, Buffer subclass) and negative controls (Uint8Array via %TypedArray%.prototype.toString → "65", Array still inspects).
Extended reasoning...

Overview

This PR fixes a Node.js compatibility bug where util.format('%s', buffer) returned <Buffer 61 62> instead of ab. The root cause is that builtInObjects in src/js/internal/util/inspect.js was populated via Object.getOwnPropertyNames(globalThis) at module load time. In Node this runs at bootstrap before Node/Web globals are installed, so the set contains only ~50 ECMAScript intrinsics. In Bun, all globals are already present, so the set contained ~115 names including Buffer, URL, Request, etc. — causing hasBuiltInToString to misclassify Buffer.prototype.toString as a language built-in and route %s through inspect() instead of String().

The fix replaces the dynamic scan with a hardcoded list of ECMAScript language built-in names, matching what Node observes at bootstrap (plus newer language intrinsics like Float16Array and DisposableStack that Node itself misses only due to load ordering).

Security risks

None. This is a formatting/inspection code path with no auth, crypto, or filesystem implications. The change replaces dynamic global enumeration with a static string list.

Level of scrutiny

Low-to-medium. This is a small, well-scoped Node compat fix in the util.inspect layer. The root-cause analysis is precise and the fix is at the correct layer (the set itself, not the consumers). I traced all three uses of builtInObjects:

  1. hasBuiltInToString (the reported bug) — now correctly returns false for Buffer/URL, routing %s through String().
  2. getConstructorName with protoProps — now calls addPrototypeProperties for Buffer/URL under showHidden: true, matching Node.
  3. addPrototypeProperties stop condition — now walks past Buffer.prototype to Uint8Array.prototype, matching Node.

All three become more Node-compatible; none regress. The showHidden: true output for Web/Node globals will now include prototype properties it previously omitted, but that is the Node behavior and the author confirmed the inspect/console test suites remain green.

Other factors

  • Tests are gate-verified (fail on system Bun, pass on debug build) per the PR description.
  • Test coverage is thorough: Buffer, multi-byte UTF-8 Buffer, URL, URLSearchParams, Buffer subclass, plus negative controls (Uint8Array still stringifies via %TypedArray%.prototype → "65", Array still takes the inspect path → [ 1, 2 ]).
  • No dead primordials: ArrayPrototypeFilter, RegExpPrototypeExec, and ObjectGetOwnPropertyNames are all still used elsewhere in the file.
  • The hardcoded list includes non-constructor names like Infinity/NaN/Math/JSON — harmless (they can never be a prototype's constructor.name) and present because Node's regex-filtered set includes them too.
  • The comment explaining why the list is hardcoded is durable and non-obvious — exactly the kind of comment the review guide asks for.

@robobun

robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #36141, which includes the same builtInObjects hardcoding plus the native-console unification so all three %s paths share one routine.

… set (47 names)

Drop the five language globals Node itself does not see at bootstrap
(SharedArrayBuffer, Float16Array, DisposableStack, AsyncDisposableStack,
SuppressedError). They are observably absent from Node's set: showHidden
on a Float16Array walks into Float16Array.prototype in Node but not in
the other typed arrays, and that asymmetry must match.

Also drop Float16Array from the showHidden typed-array loops in
util-inspect.test.js (upstream Node's test never had it), and give that
test body a debug-build timeout: the surrogate-pair loop runs ~10k
inspects and already exceeds the 5s default on bun bd with or without
this change.
Comment thread src/js/internal/util/inspect.js Outdated
@robobun robobun changed the title util: format('%s', Buffer) should call toString(), not inspect util.inspect: freeze builtInObjects to Node's 47-name bootstrap set Jul 27, 2026
Comment thread src/js/internal/util/inspect.js

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 test/js/node/util/node-inspect-tests/parallel/util-inspect.test.js:244-247 — Dropping Float16Array from the showHidden loops removes the only assertion covering util.inspect(new Float16Array(...), { showHidden: true }), whose output this PR intentionally changes (it now walks prototype getters, matching Node). Consider adding a standalone assertion on the new output right after the loop so a future edit that re-adds Float16Array to builtInObjects — or otherwise changes its prototype walk — trips a test.

    Extended reasoning...

    What changed

    builtInObjects gates addPrototypeProperties at inspect.js:1240/:1295: when a constructor name is in the set, showHidden stops at the typed-array's own hidden fields; when it is absent, showHidden also walks up the prototype chain and prints inherited getters.

    Before this PR, the live globalThis scrape included "Float16Array", so util.inspect(new Float16Array(2), { showHidden: true }) produced the same shape as Float32Array and passed the shared loop assertion at util-inspect.test.js:264. After this PR, "Float16Array" is deliberately excluded from the frozen 47-name set, so the same call now additionally emits Float16Array.prototype's getters — the PR description explicitly calls this out as an observable, Node-matching asymmetry.

    Why this is a coverage gap

    The old assertion would now fail, so it was removed from both loops with an inline comment. That satisfies the letter of REVIEW.md's "every deletion needs a stated reason or replacement", but not the stronger rule that "every behavioral change ships an automated test in the same PR": the new Float16Array showHidden output — the very asymmetry the PR is implementing — has no positive assertion locking it in.

    The remaining Float16Array coverage in this file doesn't reach this path:

    • line 2058 is a __proto__: null case (no prototype to walk)
    • line 2652 is the sorted-keys array test (doesn't call inspect on a Float16Array instance with showHidden)

    Concrete regression scenario

    1. A future contributor, seeing that Float16Array is a language global just like Float32Array, adds it to the builtInObjects array in inspect.js.
    2. util.inspect(new Float16Array(2), { showHidden: true }) reverts to the pre-PR shape (no prototype getters), diverging from Node again.
    3. bun bd test util-inspect.test.js is green — nothing in the suite asserts on Float16Array's showHidden output anymore, and the loop at line 247 still doesn't include it.
    4. The Node-compat regression ships undetected.

    Suggested fix

    Add one assertion right after the first loop (or anywhere in this test body) that pins the actual Node-matching output, e.g.:

    // Float16Array is NOT in builtInObjects, so showHidden walks its prototype.
    assert.match(
      util.inspect(new Float16Array(2), { showHidden: true }),
      /\[Symbol\(Symbol\.toStringTag\)\]: 'Float16Array'/,
    );

    (or an exact strictEqual on whatever Node v26.3.0 prints — whichever is stable across JSC prototype-getter ordering). This is a one-liner and turns the inline comment's claim into something CI enforces.

    Severity

    Nit — this is missing test coverage for a secondary consequence of the fix, not a runtime defect. The primary Buffer/URL/%s behavior is well-covered by the new util-format.test.js assertions.

Comment thread test/js/node/util/node-inspect-tests/parallel/util-format.test.js
Comment thread test/js/node/util/node-inspect-tests/parallel/util-inspect.test.js Outdated
- %s subclass fixture: use MyURL (honors new.target) and setPrototypeOf
  for the Buffer subclass so the extra prototype hop is actually walked
- Float16Array: add a positive showHidden assertion that locks in the
  prototype-getter walk, instead of only dropping it from the loop
- Extract the ~10k-inspect surrogate-pair loop into its own test with a
  30s timeout, dropping the file-wide prettier-ignore and keeping the
  large ported test body formatted
@robobun

robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the three review findings in d9ce557:

  • Float16Array coverage: added a positive assert.match on util.inspect(new Float16Array(2), { showHidden: true }) that requires the prototype-getter walk (matches Node), so re-adding Float16Array to builtInObjects trips a test.
  • Subclass fixture: new MyBuffer(2) returns a plain Buffer because the deprecated constructor ignores new.target. Replaced with class MyURL extends URL {} plus an explicit Object.setPrototypeOf(buf, MyBuffer.prototype) so the walk actually traverses the subclass prototype.
  • prettier-ignore scope: extracted the ~10k-inspect surrogate-pair loop into its own test with the 30s timeout; the 1580-line ported body keeps the default timeout (now ~2s on debug+ASAN) and stays formatted.

Comment thread test/js/node/util/node-inspect-tests/parallel/util-inspect.test.js Outdated
… raising the timeout

Release builds still sweep every code point; debug+ASAN samples every 17th
(coprime with the high/low-surrogate boundary), which hits both branches
and brings the test from ~6s to ~0.4s.

@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 — all three earlier review points are addressed in the current revision.

What was reviewed:

  • builtInObjects is consumed at three sites (hasBuiltInToString, getConstructorName, addPrototypeProperties); the Float16Array showHidden assertion locks in the intended behavior at the latter two.
  • Subclass fixtures now genuinely exercise the extra prototype hop (MyURL honors new.target; MyBuffer via explicit setPrototypeOf).
  • Extracted surrogate-pair test strides by 17 under debug with no explicit timeout; both high/low-surrogate branches are still reached (0xd800 and 0xdc0d).
Extended reasoning...

Overview

Replaces the live Object.getOwnPropertyNames(globalThis) scrape in src/js/internal/util/inspect.js with a hardcoded 47-name SafeSet matching what Node v26.3.0 observes at bootstrap. Adds %s coverage for Buffer/URL/URLSearchParams (plus subclass and negative-control cases) to util-format.test.js, drops Float16Array from the two typed-array showHidden loops in util-inspect.test.js and adds a positive assertion locking in its divergent output, and extracts the ~10k-call surrogate-pair loop into its own test that strides by 17 under isDebug.

Security risks

None. Pure output-formatting compat change in a JS builtin; no untrusted-input parsing, no allocation sizing, no auth/crypto surface.

Level of scrutiny

Moderate — Node-compat behavior change in a widely-used module (util.format/util.inspect), but the change is a data-only substitution (a smaller constant set) whose three consumers I traced. The set only shrinks, so the effect is monotone: fewer classes get treated as "built-in", meaning more user-defined-looking toStrings are honored by %s and more prototypes are walked under showHidden — both of which move Bun toward Node's observed behavior. The deliberate exclusions (SharedArrayBuffer, Float16Array, WebAssembly, DisposableStack, etc.) are explained in the PR body and one of them is pinned by a test.

Other factors

This PR has been through two rounds of my own review; all three findings (redundant MyBuffer fixture, 1580-line prettier-ignore scope, raise-timeout-instead-of-shrink-workload) are resolved in 38e2f55/d9ce5574 and visible in the final diff. The comment-cop bot's "paragraph-long comment" warning was addressed by trimming to three lines, and the remaining comment is load-bearing (prevents a future Node-sync from restoring the scrape). Test coverage includes both directions: values that must now take String() (Buffer, URL, URLSearchParams, subclasses) and values that must still take the inspect path ([1, 2], Uint8Array). The author noted #36141 supersedes this with a broader native-console fix, but also stated either can land; this one is the minimal, self-contained version.

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