Skip to content

console: render the well-known exotics, and stop a proxy trap running inside console.log - #3326

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:fix/3316-console-well-known-objects
Aug 25, 2026
Merged

lahma merged 1 commit into
sebastienros:mainfrom
lahma:fix/3316-console-well-known-objects

Conversation

@lahma

@lahma lahma commented Aug 24, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #3316.

The title is not the defect

Promise.prototype carries [Symbol.toStringTag] = "Promise", so the specification's own conversion was never wrong and does not move:

String(Promise.resolve())                            // "[object Promise]"
Object.prototype.toString.call(Promise.resolve())    // "[object Promise]"

What was wrong is the rendering the Console Standard leaves to the implementation — Formatter hands %o to "optimally useful formatting", and console.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.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. So did every sibling:

before after
new Promise(() => {}) {} Promise { <pending> }
Promise.resolve(42) {} Promise { 42 }
a rejected promise {} 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/gi
new 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 {} the class source [class Foo]

The reported promise is one member of a family, and a Map holding five entries printing {} is the worse of them.

The rule is internal-slot reads, not 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, in its own <remarks>, that it never runs script. Two internal readers make the claim true rather than assumed:

  • DatePrototype.FormatIsoString is split out of toISOString, so the console reaches the same characters without calling the method.
  • Function.GetOwnFunctionNameForDisplay reads 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 cached JsName (so an ordinary function foo() {} reports foo without 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 JsProxy calls its ownKeys and getOwnPropertyDescriptor traps, and a trap is script. On main today:

console.log(new Proxy({ a: 1 }, { ownKeys() { throw new Error('ownKeys ran'); } }));

throws Jint.Runtime.JavaScriptException : 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], [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:

  • an ArrayBuffer carries no [Uint8Contents] hex dump, whose length would be the buffer's length;
  • own extra properties on an exotic are not appended, so a Map shows 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, so Math renders {} where Node renders Object [Math] {}, and a class instance renders { x: 1 } where Node renders Point { x: 1 }. It is reachable without running script — the prototype's own constructor slot read as a descriptor, then GetOwnFunctionNameForDisplay — but every step of that walk has to refuse an accessor, and the label is worth less than the refusals cost. Recorded in AGENTS.md so 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] past MaxDepth, and , ... 5 more items on a 105-entry set.

The reporter's missing last line was a truncation

The issue's Jint output ends at promise2 then where Node also prints This 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:

end of script, print
Promise { <pending> }
Promise { <pending> }
end of script, end of print
promise waited
[ Promise { <pending> } ]
end of promise waited
promise2 then
This is the value of promise2

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 against upstream/main in a separate worktree.
  • test262: 0 failed / 102,495 passed, which matters because toISOString was refactored.
  • Jint.Tests.PublicInterface: 0 failed, and the public API snapshot is unchanged — ConsoleFormatter is internal static and both new readers are internal.
  • Solution builds clean in Release across every target framework, which is what proves the whole-file #if NET8_0_OR_GREATER gate still holds on net472 / netstandard2.0 / netstandard2.1.

No benchmark: this is console formatting, not an interpreter path.

@lahma
lahma force-pushed the fix/3316-console-well-known-objects branch 2 times, most recently from bc6a607 to 77a90d8 Compare August 24, 2026 17:43
@lahma
lahma force-pushed the fix/3316-console-well-known-objects branch from 77a90d8 to 44023f9 Compare August 25, 2026 16:06
… 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
lahma force-pushed the fix/3316-console-well-known-objects branch from 44023f9 to 0cbacd0 Compare August 25, 2026 17:27
@lahma
lahma merged commit a5227e2 into sebastienros:main Aug 25, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The result of Promise converted to strings differs from other engines

1 participant