Skip to content

console: list non-enumerable keys for %o and console.dir(v, { showHidden: true }) - #48210

Open
robobun wants to merge 2 commits into
mainfrom
robobun/41eeb868/console-native-show-hidden
Open

robobun wants to merge 2 commits into
mainfrom
robobun/41eeb868/console-native-show-hidden

Conversation

@robobun

@robobun robobun commented Nov 8, 2026 •

Copy link
Copy Markdown
Collaborator

Related to #1713

Problem

  • console.log("%o", v) and console.dir(v, { showHidden: true }) print { shown: 1 }. Node.js prints { shown: 1, [hidden]: 2 }.
  • bun test: Vitest compatibility #44833 (unreleased) made the native property walk skip non-enumerable keys (src/jsc/bindings/bindings.cpp:6145, :6278). The formatter has no showHidden.
  • No user reported this. util.inspect(v, { showHidden: true }) works on main. One docs sentence can replace this PR (Notes).

Fix

  • %o in the first argument's format string, and showHidden of console.dir, turn on a Formatter switch. The walk then passes each non-enumerable property to a second callback, which prints [key]: value. Buffer's inspect method gets the switch, and prints a Buffer that it meets again as [Circular].
  • Bun 1.4 printed these keys bare in every print: the brackets are new output. The %o half is separable.
  • Verified: test/js/web/console/console-log.test.ts (11 of 13 new tests fail on main), buffer.test.js, inspect.test.js, 4 AOT programs, 21 related test files.
  • Self-reviewed: 28 concerns raised, 25 addressed. Notes name the 3 rejected.

Background

  • Object.keys skips a non-enumerable property. Node's showHidden prints its key as [key]. %o implies it.
  • console.log and Bun.inspect share one native formatter. A C++ walk hands it each property.
  • Considered util.inspect for %o: it changes the layout of every %o print.

Downsides

  • New output: %o prints the methods of a class instance as [method].
  • Each printed key costs 17 more instructions (32 keys: 114,391 to 114,943). Allocations are equal. Release .text grows 2,560 bytes.
  • A nested print took -9% to +3% time across three link layouts (Notes).
Notes

The choice

The report that started this asks for one of two things: the hidden key in the %o and showHidden rows, or a docs line that says that the console takes neither. This PR is the first. The second is this sentence for docs/runtime/nodejs-compat.mdx, which is true on main:

console.dir() reads depth and colors from its options. The global console lists no non-enumerable key: it ignores showHidden and prints %o as %O. To list them, call util.inspect(value, { showHidden: true }) or util.format("%o", value).

If the sentence is the pick, close this PR. One row then still needs a decision: on main util.inspect(buffer, { showHidden: true }) prints <Buffer 61 62, prop: { shown: 1 }, hiddenProp: 7>, where Node.js prints prop: { shown: 1, [hidden]: 2 }. Only code can repair that row. The %o arm and the console.dir option are separate in the diff (format2 and the %o arm against the option read in message_with_type_and_level_).

Not in this PR: a showHidden option for Bun.inspect. Nobody asked for it. test/js/bun/globals.test.js:408 passes the option, and Bun.inspect drops it on main and here. It is a small follow-up if a maintainer wants it.

What prints what

v is { shown: 1 } with a non-enumerable hidden: 2.

Call Bun 1.4.4 main this PR Node.js 26.3.0
console.log(v), console.dir(v), console.log("%O", v) shown, hidden shown shown shown
console.log("%o", v) shown, hidden shown shown, [hidden] shown, [hidden]
console.dir(v, { showHidden: true }) shown, hidden shown shown, [hidden] shown, [hidden]
util.inspect(buffer, { showHidden: true }), a value of the Buffer shown, hidden shown shown, [hidden] shown, [hidden]

No release had an effect of %o or showHidden of its own. #1713 asks for the bracketed key under console.dir(obj, { showHidden: true }) in its last paragraph. Its main case (an inherited enumerable key is printed) is not changed here.

How it works

  • HiddenKeys on the Formatter has three states: Skip, InPercentO (a %o of the format string that is being printed lists the keys for its value) and List.
  • format2 sets InPercentO only when the first argument is a primitive string, and clears it when that argument is printed. Bun also fills the % directives of later string arguments (Fix console.log in Lambda runtime #4826, open console: only a primitive-string first argument is a format string #45571). A %o in such a string lists no hidden key.
  • ForEachPropertyMode::WithHidden is a third mode of JSC__JSValue__forEachPropertyImpl. Both loops of the walk pick the callback (deliver) from the DontEnum attribute. A hidden property passes the same private-name check as every other property.
  • The second callback, for_each_hidden, prints the key through the same writer with KeyKind::Hidden.
  • The hook of Buffer is native. It prints each extra value with Bun__inspect_singleline. A flags byte tells it the switch. Other [util.inspect.custom] functions get the options that they get on main.
  • Each extra value of a Buffer is printed by a formatter of its own, so a value that leads back to the Buffer never ended. The hook now marks the Buffer while it prints each of its values, and prints a Buffer that it meets again in such a print as [Circular]. The read of the property is outside that mark: a getter that prints its Buffer starts a print of its own. On main, buf.holder = { back: buf }; console.log(buf) throws RangeError: Maximum call stack size exceeded. With the switch passed on, a non-enumerable such value would throw too.
  • The walk calls no getter of the program: an accessor of an object or an array prints as [Getter], [Setter] or [Getter/Setter]. The inspect method of Buffer is different. It reads each own property of the Buffer, as the one of Node.js does. So a non-enumerable getter of a Buffer itself is called under %o and showHidden, and its value is printed: <Buffer 61, g: 1>.
  • The options that the formatter passes to the inspect method of Buffer have no prototype. With Object.prototype.showHidden = true, main lists the own non-enumerable keys of a Buffer in a plain console.log(buffer). This branch lists none there, as Node.js.
  • An own accessor or lazy value of native code is not read either, if the object holds a cell for it: console._stdout prints as [Getter] and the stream is not created, and process.env prints [TZ]: [Getter/Setter]. One without a cell prints its value, as in a plain print: DataView.prototype lists [buffer]: undefined.

Limits, with their trackers

Measurements

Release builds of the merge base 2596ac5 and of this branch at f2fee93, linux-x64. The later commits change comments and the inspect method of Buffer only. Instructions are user-mode instructions per call: (I(400,000 calls) - I(200,000 calls)) / 200,000, BUN_JSC_useConcurrentJIT=0, 5 interleaved rounds, minimum.

Call main this PR
Bun.inspect({ a: 1 }) 5,794 5,822
Bun.inspect of a 32-key object 114,391 114,943
Bun.inspect([1, 2, 3]) 5,737 5,748
Bun.inspect of a class instance 8,735 8,803
Bun.inspect of an 8-key object with a getter 19,667 19,797
Bun.inspect of a Buffer 3,814 3,828
console.log(v), v = { a: 1, b: "x", c: [1, { d: 2 }] } 17,223 17,318
console.log("%o", v) 17,906 18,110
console.dir(v, { depth: 2 }) 18,573 18,890
  • Per key, 12 of the 17 instructions are in write_property_key (it gets a three-state key kind in place of a bool), 3 in for_each and 2 in the walk. A const generic twin of the key writer costs no instruction, but 4.9 KB of code and new symbol names for two hot functions. It was measured 4% to 10% slower on the links with an order file below, so the runtime kind is what is here.
  • Allocator calls per print are equal in every row that was counted: 7, 15, 10, 12, 10.
  • .text: 108,257,833 to 108,260,393 bytes. File: 138,119,440 to 138,123,536 bytes.
  • size_of::<Formatter>(): 152 to 160 bytes, on the stack, one per print. FormatOptions stays at 12.
  • Deepest object that Bun.inspect prints before RangeError: debug+ASAN 615 to 596 (the test in bun-inspect.test.ts needs 512), release 2,912 to 2,886.

Time of Bun.inspect, nanoseconds per call, best of 9, 6 interleaved runs, one pinned core (AMD EPYC, 32 KiB L1i):

Link { a: 1 } 4 levels 8 levels 32 keys
plain link, main 496 to 507 1,332 to 1,458 2,581 to 2,708 7,528 to 7,842
plain link, this PR 447 to 471 1,280 to 1,332 2,447 to 2,582 7,320 to 7,663
symbol order file traced from main, main 473 to 520 1,320 to 1,347 2,538 to 2,671 7,489 to 7,791
the same order file, this PR 462 to 486 1,286 to 1,342 2,449 to 2,588 7,447 to 7,666
order file traced from this PR, this PR 481 to 502 1,361 to 1,407 2,601 to 2,769 7,528 to 7,679
  • CI links linux-x64 with a symbol order file (scripts/orderfile/generate.ts). The rows with an order file use one that this script wrote locally, and were measured one commit earlier on this branch (the walk and the formatter are the same). The artifact of CI could not be downloaded here.
  • The instructions of one nested print touch about 29 KiB of code, against a 32 KiB L1 instruction cache. For the 8-level print on the plain link, this PR runs 203 more instructions (35,861 to 36,064) in 4% fewer cycles (9,081 to 8,719). The time follows the L1i misses, and they follow where the linker puts the functions. An earlier version of this branch, on an earlier main, ran 83 more instructions for that print and was 10% slower on the plain link, with 30% more L1i misses.

Open pull requests that touch the same code

Review concerns that were rejected

Of the two self-reviews:

Tests

  • console-log.test.ts: 13 new tests. The 2 that pass on main pin that a later string or a non-string first argument lists no key, without colors and in color.
  • buffer.test.js: 2 new tests. The one for a Buffer that leads back to itself fails on main with the RangeError.
  • inspect.test.js: an object that a hook changes while its hidden keys are listed. It runs on every build. An ASAN build also reports a read of a freed property table.
  • test/aot/parallel/hidden-members: 2 new programs and 2 corrected ones. Without "showHidden" in console.builtins.ts, console-dir-reads-show-hidden-of-its-options.js fails: 'showHidden' was not compiled into this executable.
  • Also run on the debug build: the other files of test/js/web/console, test/js/bun/console, test/js/node/console, bun-inspect.test.ts, inspect-error.test.js, custom-inspect.test.js, util.test.js, globals.test.js, jest-each.test.ts, dom-nodes.test.ts, url.test.ts, inspect-strings-parsed-from-json.test.ts, vitest-flavor.test.ts, and the 23 test-console-*, test-util-inspect-* and test-buffer-*inspect* scripts of test/js/node/test/parallel.

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

@coderabbitai

coderabbitai Bot commented Nov 8, 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 8 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 2596ac5 and 4828f58.

Files selected for processing: 18

  • docs/runtime/nodejs-compat.mdx
  • src/codegen/builtins/host/console.builtins.ts
  • src/jsc/CommonStrings.rs
  • src/jsc/ConsoleObject.rs
  • src/jsc/JSValue.rs
  • src/jsc/bindings/BunCommonStrings.cpp
  • src/jsc/bindings/BunCommonStrings.h
  • src/jsc/bindings/JSBuffer.cpp
  • src/jsc/bindings/UtilInspect.cpp
  • src/jsc/bindings/bindings.cpp
  • src/runtime/api/BunObject.rs
  • test/aot/parallel/hidden-members/console-dir-reads-show-hidden-of-its-options.js
  • test/aot/parallel/hidden-members/formatter-of-host-shows-streams-console.js
  • test/aot/parallel/hidden-members/non-enumerable-keys-of-compiled-objects-are-listed-where-asked-for.js
  • test/aot/parallel/hidden-members/walks-over-console-pass-its-streams-by.js
  • test/js/bun/util/inspect.test.js
  • test/js/node/buffer.test.js
  • test/js/web/console/console-log.test.ts
  • 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

@github-actions github-actions Bot added the claude label Nov 8, 2026
@robobun

robobun commented Nov 8, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:04 AM PT - Nov 8th, 2026

❌ @robobun, your commit 4828f58 has 3 failures in Build #138695 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 48210

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

bun-48210 --bun

@robobun

robobun commented Nov 8, 2026

Copy link
Copy Markdown
Collaborator Author

Status

  • Reproduced on main (2596ac5), linux-x64: for v = Object.defineProperty({ shown: 1 }, "hidden", { value: 2, enumerable: false }), console.log("%o", v) and console.dir(v, { showHidden: true }) print { shown: 1 }. Node.js 26.3.0 prints { shown: 1, [hidden]: 2 }. Bun 1.4.4 prints { shown: 1, hidden: 2 } for every print.
  • With this branch, both calls print { shown: 1, [hidden]: 2 }, and a plain console.log(v) prints { shown: 1 }.
  • The new tests fail on main and pass on this branch: bun bd test test/js/web/console/console-log.test.ts.
  • This PR: console: list non-enumerable keys for %o and console.dir(v, { showHidden: true }) #48210

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

Beyond the inline findings, two more things were checked and ruled out: the ordered_properties / call_own_getters arms in print_object taking precedence over HiddenKeys::List is unreachable today, since show_hidden is set only by console.dir and the Buffer hook while ordered_properties comes from Bun.inspect({ sorted }) and call_own_getters from the test runner, so no caller sets both; and Bun.inspect(v, { showHidden: true }) still not listing hidden keys is a deliberate scope limit of this PR rather than a regression (its option parsing is untouched). The new test files were not run as part of this review.

Extended reasoning...

The change adds a showHidden switch to Bun's native console formatter across ConsoleObject.rs, JSValue.rs, bindings.cpp, UtilInspect.cpp and JSBuffer.cpp, with a new three-mode C++ property walk and a bitflags FFI parameter for the custom-inspect hook, plus tests and a docs line. It touches no auth, crypto or injection surface, but it does change a shared property-enumeration path and the DontEnum skip conditions in bindings.cpp. Four confirmed findings are already posted inline (Buffer cycle RangeError, stdio stream creation when walking console, constructor key omitted, URL context symbol not shown), so the inline comments already signal human review; this note only records the two candidate issues examined and ruled out.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread src/jsc/bindings/JSBuffer.cpp Resolved
Comment thread src/jsc/bindings/bindings.cpp Resolved
Comment thread src/jsc/ConsoleObject.rs Resolved
Comment thread src/jsc/bindings/UtilInspect.cpp Resolved
@robobun
robobun force-pushed the robobun/41eeb868/console-native-show-hidden branch from 8defc4c to f2fee93 Compare November 8, 2026 08:45
Comment thread src/jsc/ConsoleObject.rs Resolved
Comment thread src/jsc/JSValue.rs Resolved
Comment thread src/runtime/api/BunObject.rs Resolved
…Hidden: true })`

Since #44833 the native property walk skips own non-enumerable
properties. That is right for `console.log(v)`. The global console then
has no way to list them: `console.log("%o", v)` and
`console.dir(v, { showHidden: true })` print `{ shown: 1 }` where
Node.js prints `{ shown: 1, [hidden]: 2 }`.

The formatter gets a switch for these keys. `%o` in the format string
of the first argument and `showHidden` of `console.dir` turn it on. The
walk then passes each non-enumerable property to a second callback, in
its place among the other keys, and that callback prints the key in
brackets. The inspect method of `Buffer` is told too, so
`util.inspect(buffer, { showHidden: true })` lists the hidden keys of
the extra values of a Buffer again.
@robobun
robobun force-pushed the robobun/41eeb868/console-native-show-hidden branch from f2fee93 to 8cbb3cf Compare November 8, 2026 08: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.

Beyond the inline nit, I also checked: the Buffer inspect hook running a user getter on an extra own property under showHidden (Node's buffer.inspect reads this[key] the same way, so not a divergence); own enumerable __esModule now printing from the structure fast path at bindings.cpp:6152 (the slow path and Node already print it, so the paths now agree); and the docs line on bracketed symbol keys (Node prints them bracketed too, so the sentence describes no divergence, but it is a wording nit only). Not run here: the test files and BUN_JSC_validateExceptionChecks=1 over the new ownCustomPropertyCell / iterHidden paths.

Extended reasoning...

The new commit adds the s_bufferBeingInspected chain in JSBuffer.cpp and the ownCustomPropertyCell branch in bindings.cpp, which address the two earlier red inline findings (self-referencing Buffer RangeError and console._stdout creation); the earlier constructor and URL showHidden notes remain as they were. The change touches the shared JSC property walk, the FFI callback signature and a per-thread global, with no security-sensitive surface; its size and the native-walk refactor are why a human look stays worthwhile rather than an approval.

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

Comment thread src/jsc/bindings/JSBuffer.cpp Resolved
A getter of the Buffer that prints the Buffer starts a print of its own.
It is no way back to the Buffer, so it must not print as [Circular].

@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 re-reviewed the latest push and found no new bugs; the points raised on earlier versions (Buffer back-reference, console._stdout instantiation, re-entrant Buffer print, constructor divergence) now have code or docs behind them in the diff. Because this rewires the shared property walk in bindings.cpp that every console.log/Bun.inspect goes through, a human look would still be worthwhile.

What was reviewed:

  • Both walks in JSC__JSValue__forEachPropertyImpl: in Enumerable/CallingOwnGetters mode own DontEnum keys still return/continue before delivery, so plain prints are unchanged; ownCustomPropertyCell is only reachable for an own DontEnum key, i.e. WithHidden mode.
  • JSBuffer.cpp: the SetForScope on the thread-local chain is RAII, so it unwinds on the RETURN_IF_EXCEPTION path too; thisBuffer outlives every use. A human may still want to weigh a thread-local chain against keeping it on the VM, though it is scoped to one synchronous call.
  • UtilInspect.cpp: the Buffer options object is null-prototype, so Object.prototype.showHidden = true no longer leaks into plain console.log(buf) (covered by the new test).
  • Not run here: the new subprocess tests and BUN_JSC_validateExceptionChecks=1; the verdict rests on reading only.
Extended reasoning...

The change adds a showHidden switch to Bun's native console formatter: a new WithHidden mode in the shared C++ property walk (bindings.cpp), a HiddenKeys state and KeyKind in ConsoleObject.rs, a flags byte on the custom-inspect FFI call, a new Bun__inspect_singleline_show_hidden export used by Buffer's native inspect with a thread-local cycle guard, one common string, a docs sentence, and about 530 lines of tests. It touches no auth, crypto or injection surface; the sensitive part is memory/exception safety in the property walk and the Buffer re-entrancy guard, which read as correct (snapshot plus MarkedArgumentBuffer in the fast walk, RAII restore in JSBuffer.cpp). The bug hunt ran dry on this push and prior inline findings are addressed in the two commits, but the diff is roughly 850 lines across the hot shared enumeration path used by every console print, so it is not a candidate for approval without a human.

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