console: render the well-known exotics, and stop a proxy trap running inside console.log - #3326
Merged
lahma merged 1 commit intoAug 25, 2026
Conversation
lahma
force-pushed
the
fix/3316-console-well-known-objects
branch
2 times, most recently
from
August 24, 2026 17:43
bc6a607 to
77a90d8
Compare
lahma
force-pushed
the
fix/3316-console-well-known-objects
branch
from
August 25, 2026 16:06
77a90d8 to
44023f9
Compare
… inside console.log (sebastienros#3316) `console.log(promise)` printed `{}` where Node, QuickJS and Bun print `Promise { <pending> }`. The title on sebastienros#3316 says "converted to strings", but the specification's conversion was never wrong: `Promise.prototype` carries `[Symbol.toStringTag] = "Promise"`, so `String(p)` answers `[object Promise]` exactly as it should. What was wrong is the rendering the Console Standard leaves to the implementation - `%o`, `%O`, `console.dir`, every non-string top-level argument and every `console.table` cell. `ConsoleFormatter.InspectObject` special-cased two kinds, `Function` and `ErrorInstance`, and sent everything else to a walk over own enumerable string keys. A promise owns none, so it rendered as the empty object - and so did `Map`, `Set`, `WeakMap`, `Date`, `RegExp`, every typed array, `ArrayBuffer`, `DataView` and every boxed primitive. The reported promise is one member of a family, and a `Map` holding five entries printing `{}` is the worse of them. Each now renders what the engines a script author already knows render: `Promise { <pending> }` / `{ 42 }` / `{ <rejected> TypeError: bad }`, `Map(1) { 'a' => 1 }`, `Set(2) { 1, 2 }`, `WeakMap { <items unknown> }`, `2020-01-01T00:00:00.000Z`, `/ab+c/gi`, `Uint8Array(3) [ 1, 2, 3 ]`, `ArrayBuffer { byteLength: 8 }`, `[String: 'x']`, `[Arguments] { '0': 1 }`, `[Object: null prototype] {}`. **Every one of those readings is an internal-slot read**, which is the rule rather than the table. `source`, `flags`, `byteLength`, `toISOString` and `name` are configurable on every one of these objects, so reading a value *through the property of that name* is calling whatever a script left there - and this class documents that it never runs script. `DatePrototype.FormatIsoString` is split out of `toISOString` so the console reaches the same characters without calling the method, and `Function.GetOwnFunctionNameForDisplay` reads a name the way sebastienros#3114 taught the error path to: the pending sentinel is answered from the definition's cached `JsName`, an accessor or a non-string answers null, and the function reports as anonymous rather than becoming a way to run either. **The proxy case was that promise being untrue in the code.** Walking a `JsProxy` calls its `ownKeys` and `getOwnPropertyDescriptor` traps, and a trap is script: on `main` today, `console.log(new Proxy({}, { ownKeys() { throw new Error('ownKeys ran') } }))` throws `ownKeys ran` out of the log statement. A proxy is now unwrapped to its target before anything else looks at it, a revoked one renders `<Revoked Proxy>`, and `ConsoleTests.NeverRunsScriptWhileInspecting` fails on the parent commit with exactly that exception. **A function is named, not printed.** `Function.prototype.toString` answers the whole source text once `Options.RetainFunctionSourceText` is on - which is what the issue's own host code sets - so one console record could carry an entire function body, in a class whose whole design is depth-capped and entry-capped. `[Function: foo]`, `[Function (anonymous)]`, `[class Foo]`, `[AsyncFunction: f]` and the two generator forms replace it. `console.table`'s fallback for a function argument follows. Two divergences from Node are deliberate and follow from the same bound: an `ArrayBuffer` carries no `[Uint8Contents]` hex dump, whose length would be the buffer's length, and own extra properties on an exotic are not appended, so a `Map` shows its entries and nothing else. The containers stay inside the existing caps - a self-referential `Map` renders `Map(1) { 'self' => [Circular] }`, one nested past `MaxDepth` collapses to `[Map]`, and a 105-entry `Set` ends `, ... 5 more items`. The reporter's paste is missing its final line; that was a truncation, not a second defect. The repro's ordering and every other line already matched Node, and after this change its output matches byte for byte. Nothing public moves: `ConsoleFormatter` is `internal static`, the two new readers are `internal`, and the public API snapshot is unchanged. Fixes sebastienros#3316 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0123vopKmTcfrYHFQn7qYYzk
lahma
force-pushed
the
fix/3316-console-well-known-objects
branch
from
August 25, 2026 17:27
44023f9 to
0cbacd0
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #3316.
The title is not the defect
Promise.prototypecarries[Symbol.toStringTag] = "Promise", so the specification's own conversion was never wrong and does not move:What was wrong is the rendering the Console Standard leaves to the implementation — Formatter hands
%oto "optimally useful formatting", andconsole.table's entire normative text is one sentence followed by "TODO: This will need a good algorithm." That rendering serves%o,%O,console.dir, every non-string top-level argument and every table cell.ConsoleFormatter.InspectObjectspecial-cased two kinds —FunctionandErrorInstance— and sent everything else to a walk over own enumerable string keys. A promise owns none, so it rendered as the empty object. So did every sibling:new Promise(() => {}){}Promise { <pending> }Promise.resolve(42){}Promise { 42 }{}Promise { <rejected> TypeError: bad }new Map([['a', 1]]){}Map(1) { 'a' => 1 }new Set([1, 'two']){}Set(2) { 1, 'two' }new WeakMap(){}WeakMap { <items unknown> }new Date(Date.UTC(2020, 0, 1)){}2020-01-01T00:00:00.000Z/ab+c/gi{}/ab+c/ginew Uint8Array([1, 2, 3]){ 0: 1, 1: 2, 2: 3 }Uint8Array(3) [ 1, 2, 3 ]new ArrayBuffer(8){}ArrayBuffer { byteLength: 8 }new DataView(new ArrayBuffer(8), 2, 4){}DataView { byteLength: 4, byteOffset: 2, buffer: ArrayBuffer { byteLength: 8 } }new String('x'){ 0: 'x' }[String: 'x']arguments{ '0': 1 }[Arguments] { '0': 1 }Object.create(null){}[Object: null prototype] {}function foo() {}function foo() { [native code] }[Function: foo]class Foo {}[class Foo]The reported promise is one member of a family, and a
Mapholding five entries printing{}is the worse of them.The rule is internal-slot reads, not the table
source,flags,byteLength,toISOStringandnameare configurable on every one of these objects, so reading a value through the property of that name is calling whatever a script left there — and this class documents, in its own<remarks>, that it never runs script. Two internal readers make the claim true rather than assumed:DatePrototype.FormatIsoStringis split out oftoISOString, so the console reaches the same characters without calling the method.Function.GetOwnFunctionNameForDisplayreads a name the way Never read a pending lazy name descriptor when rendering an error message #3114 taught the error path to: the pending sentinel is answered from the definition's cachedJsName(so an ordinaryfunction foo() {}reportsfoowithout its descriptor being allocated), while an accessor or a non-string answers null and the function reports as anonymous rather than becoming a way to run either.The proxy case was that claim being untrue in the code
Walking a
JsProxycalls itsownKeysandgetOwnPropertyDescriptortraps, and a trap is script. Onmaintoday:throws
Jint.Runtime.JavaScriptException : ownKeys ranout of the log statement. A proxy is now unwrapped to its target before anything else looks at it, a revoked one renders<Revoked Proxy>, andConsoleTests.NeverRunsScriptWhileInspectingfails on the parent commit with exactly that exception.A function is named, not printed
Function.prototype.toStringanswers the whole source text onceOptions.RetainFunctionSourceTextis on — which is what the issue's own host code sets — so one console record could carry an entire function body, in a class whose whole design is depth-capped and entry-capped.[Function: foo],[Function (anonymous)],[class Foo],[AsyncFunction: f],[GeneratorFunction: g]and[AsyncGeneratorFunction: g]replace it.console.table's fallback for a function argument follows, which is the one pre-existing assertion this changes.Two deliberate divergences from Node
Both follow from the bound rather than from omission, and both are written down in
Jint/WebApi/AGENTS.md:ArrayBuffercarries no[Uint8Contents]hex dump, whose length would be the buffer's length;Mapshows its entries and nothing else.A third divergence is a scope line rather than a bound, and it is the one you notice first: nothing is labelled by its constructor or its
Symbol.toStringTag, soMathrenders{}where Node rendersObject [Math] {}, and a class instance renders{ x: 1 }where Node rendersPoint { x: 1 }. It is reachable without running script — the prototype's ownconstructorslot read as a descriptor, thenGetOwnFunctionNameForDisplay— but every step of that walk has to refuse an accessor, and the label is worth less than the refusals cost. Recorded inAGENTS.mdso it is picked up deliberately or not at all.The containers stay inside the existing caps:
Map(1) { 'self' => [Circular] }for a self-referential map,[Map]/[Set]/[Promise]/[Uint8Array]pastMaxDepth, and, ... 5 more itemson a 105-entry set.The reporter's missing last line was a truncation
The issue's Jint output ends at
promise2 thenwhere Node also printsThis is the value of promise2. Running the repro verbatim shows that line does print, and the ordering already matched Node exactly — there is no second defect. After this change the whole output matches Node byte for byte:Verification
Jint.Tests: 0 failed / 10,650 passed on net10.0 and net8.0, 7,298 on net472. The six new or updated tests all fail on the parent commit — verified by running this test file againstupstream/mainin a separate worktree.toISOStringwas refactored.Jint.Tests.PublicInterface: 0 failed, and the public API snapshot is unchanged —ConsoleFormatterisinternal staticand both new readers areinternal.#if NET8_0_OR_GREATERgate still holds onnet472/netstandard2.0/netstandard2.1.No benchmark: this is console formatting, not an interpreter path.