Skip to content

console: use Node's %s format semantics instead of engine ToString - #34603

Open
robobun wants to merge 10 commits into
mainfrom
claude/farm/b54fd54c/console-percent-s-node-semantics
Open

console: use Node's %s format semantics instead of engine ToString#34603
robobun wants to merge 10 commits into
mainfrom
claude/farm/b54fd54c/console-percent-s-node-semantics

Conversation

@robobun

@robobun robobun commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Reproduction

// TZ=UTC
try { console.log("%s", Symbol("q")); } catch (e) { console.log("THREW:" + e.constructor.name); }
console.log("%s", { a: 1 });
console.log("%s", [1, 2]);
console.log("%s", -0);
console.log("%s", new Date(1700000000000));
Node 26.3.0 Bun before Bun after
Symbol("q") Symbol(q) TypeError: Cannot convert a symbol to a string Symbol(q)
{ a: 1 } { a: 1 } [object Object] { a: 1 }
[1, 2] [ 1, 2 ] 1,2 [ 1, 2 ]
-0 -0 0 -0
new Date(...) 2023-11-14T22:13:20.000Z Tue Nov 14 2023 22:13:20 GMT+0000 ... 2023-11-14T22:13:20.000Z

The Symbol case is the worst: a debug console.log that happens to receive a Symbol throws out of console.log itself, which in a server takes the request down.

Cause

PercentTag::S in src/jsc/ConsoleObject.rs called print_as(Tag::String, ...), which reaches BunString::from_js (the engine ToString abstract operation). ToString(symbol) throws per spec, and for objects/arrays/-0/Date it produces the generic string coercion instead of Node's util.format output.

Fix

Add print_percent_s, mirroring Node's formatWithOptionsInternal handling of %s:

  • numbers: printed via dtoa_with_negative_zero (preserves -0, NaN, Infinity)
  • bigints: reuse print_bigint (adds the n suffix)
  • symbols: reuse print_symbol (Symbol(description) via get_description, no throw)
  • null / undefined / booleans: literal text
  • strings and callables: existing Tag::String path (equivalent to String(value))
  • objects with a user toString or Symbol.toPrimitive (percent_s_has_user_to_string): existing Tag::String path, so {toString(){...}}, user classes with toString/[Symbol.toPrimitive], URL, and the built-in prototype objects themselves keep printing their string form as before
  • remaining objects: the existing inspect formatter with single_line = true, quote_strings = true, max_depth = depth, indent restored, colors forced off

percent_s_has_user_to_string approximates Node's !hasBuiltInToString: unwrap proxy targets, treat own callable toString / Symbol.toPrimitive on the value itself as always user-provided, otherwise walk prototypes to the first owner of either and treat it as built-in only when its own constructor names a core ECMAScript type (Object, Array, Date, Map, Set, the Error hierarchy, typed arrays, etc.). To support the symbol-keyed lookup this adds BuiltinName::toPrimitive (mapped to vm.propertyNames->toPrimitiveSymbol in bindings.cpp) and a JSValue::fast_get_own wrapper around the existing JSC__JSValue__fastGetOwn.

All %s output is emitted with ENABLE_ANSI_COLORS = false, matching Node's colors: false.

Verification

$ USE_SYSTEM_BUN=1 bun test test/js/web/console/console-log.test.ts -t "Node's format semantics"
(fail) console.log %s uses Node's format semantics, not engine ToString
  TypeError: Cannot convert a symbol to a string

$ bun bd test test/js/web/console/console-log.test.ts
 5 pass  0 fail

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


[review] gate passed · iteration 1 · 5 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/console/console-log.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (f281f8a14)

test/js/web/console/console-log.test.ts:
(pass) should log to console correctly [1199.20ms]
(pass) long arrays get cutoff [3.47ms]
(pass) console.group [522.10ms]
211 |     "prim",
212 |     "own",
213 |     "/(?:)/",
214 |     "",
215 |   ].join("\n");
216 |   expect({ out: out.replaceAll("\r\n", "\n"), err, exitCode }).toEqual({ out: expected, err: "", exitCode: 0 });
                                                                     ^
error: expect(received).toEqual(expected)

  {
-   "err": "",
-   "exitCode": 0,
+   "err": 
+ "1 | 
+ 2 |     try { console.log("%s", Symbol("q")); } catch (e) { console.log("THREW:" + e.constructor.name); }
+ 3 |     console.log("%s", Symbol());
+                 ^
+ TypeError:
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (983c4ace2)

test/js/web/console/console-log.test.ts:
(pass) should log to console correctly [22.43ms]
(pass) long arrays get cutoff [0.13ms]
(pass) console.group [13.40ms]
(pass) console.log %s uses Node's format semantics, not engine ToString [12.35ms]
(pass) console.log with SharedArrayBuffer [0.09ms]

 5 pass
 0 fail
 2 snapshots, 10 expect() calls
Ran 5 tests across 1 file. [220.00ms]
__F:0:S:0
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/console/console-log.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (f281f8a14)

test/js/web/console/console-log.test.ts:
(pass) should log to console correctly [1144.83ms]
(pass) long arrays get cutoff [4.42ms]
(pass) console.group [708.94ms]
(pass) console.log %s uses Node's format semantics, not engine ToString [596.86ms]
(pass) console.log with SharedArrayBuffer [7.14ms]

 5 pass
 0 fail
 2 snapshots, 10 expect() calls
Ran 5 tests across 1 file. [5.12s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 989ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/22] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 244 extern-C blocks audited
[2/22] gen cpp.rs (cppbind)
[3/22] gen JS modules (bundle-modules)
Preprocess modules (8325ms)
Bundle modules (67ms)
Postprocesss modules (141ms)
Bundle Functions (621ms)
Generate Code (118ms)

[9.29s] Bundled "src/js" for production
  2036 kb
  165 internal modules
  13 native modules
  90 internal functions across 19 files
[3/9] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rus
... (truncated)
diff hotspot
src/jsc/ConsoleObject.rs                | 209 +++++++++++++++++++++++++++++++-
 src/jsc/JSValue.rs                      |  47 +++++++
 src/jsc/bindings/bindings.cpp           |  22 +++-
 src/jsc/lib.rs                          |   4 +
 test/js/web/console/console-log.test.ts |  73 +++++++++++
 5 files changed, 343 insertions(+), 12 deletions(-)

gate history · 1 passed · 1 rejected · iteration 1

evidence per changed file
file                                     reads  edits  tests
src/jsc/ConsoleObject.rs                    16     14      0
src/jsc/JSValue.rs                           2      4      0
src/jsc/bindings/bindings.cpp                3      6      0
src/jsc/lib.rs                               3      4      0
test/js/web/console/console-log.test.ts      3      7      0

The native console formatter's %s specifier routed every argument
through print_as(Tag::String), which calls BunString::from_js, i.e.
the engine ToString abstract operation. That throws a TypeError for
Symbol arguments and yields [object Object] / 1,2 / 0 / locale Date
text where Node's util.format prints Symbol(q) / { a: 1 } / [ 1, 2 ]
/ -0 / the ISO timestamp.

Add print_percent_s, mirroring Node's formatWithOptionsInternal case
for %s: numbers and bigints are printed with sign and n-suffix
preserved, symbols print their descriptive string, other primitives
write their literal form, functions go through String(), and
remaining objects are formatted with the inspect path at
{ depth: 0, compact, colors: false }.
@robobun

robobun commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on main, fixed in print_percent_s (src/jsc/ConsoleObject.rs). Test at test/js/web/console/console-log.test.ts fails on main (TypeError from Symbol()) and passes with this change. Review feedback addressed through 983c4ac.

The diff is green. CI on build 75436 is red on unrelated lanes only: the worker-terminate race that worker-transfer-terminate-stress.test.ts / test-worker-message-port-transfer-terminate.js amplify (pre-existing, intermittent x64-asan only, unrelated to console formatting), bun-install-registry hoisting on Windows aarch64, and a bun exec shell crash on Windows x64. Ready for a maintainer to merge.

@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The console formatter now implements Node-style %s handling for JavaScript primitives, builtins, objects, and custom coercion hooks, with JavaScriptCore lookup support and comprehensive console output tests.

Console percent-s formatting

Layer / File(s) Summary
Builtin coercion-property lookup
src/jsc/lib.rs, src/jsc/bindings/bindings.cpp, src/jsc/JSValue.rs
Adds toPrimitive builtin mapping and own-property lookup paths exposed to Rust.
Node-style percent-s rendering
src/jsc/ConsoleObject.rs
Routes %s through type-specific rendering, user-defined coercion detection, and object formatting.
Percent-s behavior coverage
test/js/web/console/console-log.test.ts
Tests %s output for primitives, builtins, objects, and custom coercion hooks.

Suggested reviewers: jarred-sumner, alii

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly matches the main change: updating console %s formatting to Node-like semantics instead of engine ToString.
Description check ✅ Passed The description covers the change, reproduction, cause, fix, and verification, though it does not use the template headings verbatim.

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

@robobun

robobun commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 9:09 AM PT - Jul 18th, 2026

@robobun, your commit f281f8a has 3 failures in Build #75436 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34603

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

bun-34603 --bun

Comment thread src/jsc/ConsoleObject.rs
Comment thread src/jsc/ConsoleObject.rs
Comment thread test/js/web/console/console-log.test.ts Outdated
The single_line print_object path increments indent unconditionally but
only decrements it on the always_newline branch of print_object_tail,
so each %s on a non-empty object leaked indent += 1 into later
arguments of the same console.log call. Add a defer_restore for indent
alongside the existing quote_strings/single_line/max_depth guards.

Also delegate the Symbol arm to print_symbol::<false> instead of
duplicating it, and switch the test to a combined {out, err, exitCode}
assertion with an extra case covering the indent leak.

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

All three items from the earlier pass are addressed in b6b3679 (indent restore + regression case, print_symbol delegation, combined {out, err, exitCode} assertion) and I found nothing new in the updated diff. Leaving this for a human to sign off since it's a user-visible console.log output change with the acknowledged hasBuiltInToString gap — objects with a custom toString now get inspected instead of stringified, which is the opposite direction from Node for that one case.

Extended reasoning...

Overview

Adds print_percent_s in src/jsc/ConsoleObject.rs (~95 lines) replacing the PercentTag::S arm's print_as(Tag::String) call, plus a subprocess test in test/js/web/console/console-log.test.ts covering 19 input shapes. The new function type-dispatches on the argument: strings/callables keep the old Tag::String path; number/bigint/symbol/null/undefined/boolean get literal handling; everything else falls through to format::<false> with single_line=true, quote_strings=true, max_depth=depth, and colors off, with defer_restore! guards on all four mutated formatter fields.

Prior feedback

I raised three items on the previous revision — the self.indent leak across %s object calls, hand-rolled Symbol formatting duplicating print_symbol, and the standalone expect(err).toBe(""). All three are fixed in b6b3679 and the indent leak has a dedicated test line (%s %s %O → asserts the %O object is at indent 0). Re-checked the object fallback: all four mutated fields (quote_strings, single_line, max_depth, indent) are now saved/restored.

Security risks

None. Pure output-formatting; no new coercions that call into user code (the Symbol path now avoids ToString, which is strictly less user-code execution than before).

Level of scrutiny

Medium. Not mechanical: it changes what console.log("%s", x) prints for every non-string argument, which users notice and file issues about. The PR description explicitly defers Node's hasBuiltInToString gate, meaning objects with a user-defined toString (e.g. {toString: () => "custom"}, or library types that override it) previously printed their custom string via engine ToString and will now be inspected — that specific case moves away from Node. Whether to ship the partial fix now (it does fix the Symbol crash and the common plain-object/array/Date/-0 cases) or hold for the toString gate is a maintainer call.

Other factors

Test coverage is good for the handled cases; the PR reports the node util-format parallel test suite still passes. No memory-safety surface — all new code is value-type dispatch and delegation to existing helpers.

The previous commit routed every object through the inspect path, which
regressed objects that define their own toString (or inherit one from a
non-core prototype): before this PR those printed their toString()
result via engine ToString, matching Node.

Add percent_s_has_user_to_string, an approximation of Node's
!hasBuiltInToString: walk the prototype chain to the first object that
owns a callable toString and check whether its own constructor names a
core ECMAScript built-in. Objects whose toString comes from Object /
Array / Date / Map / etc. go through inspect; anything else (literal
{toString(){...}}, user classes, URL) goes through String(value).

Extend the test to cover a literal toString, a class-provided toString,
and a user class without toString (inspected).
@robobun

robobun commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the custom toString regression in dad06cc: added a prototype-chain walk (percent_s_has_user_to_string) that keeps String(value) for objects whose toString is owned by a non-core prototype, and routes core built-ins (Object/Array/Date/Map/etc.) through inspect. {toString(){...}}, user classes with toString, and URL now print their string form again, matching Node and the pre-PR behavior for those inputs. Test coverage extended to include these cases.

Comment thread src/jsc/ConsoleObject.rs Outdated
… in OwnedString

.unwrap_or(false) discarded the Rust Err but left the pending exception
on the VM, so the following Tag::get re-entered JSC with a stale
exception. print_percent_s already returns JsResult and its caller uses
?, so propagate with ? instead.

Also wrap ctor.get_name in OwnedString so the +1 WTF ref is released at
scope exit.
Comment thread src/jsc/ConsoleObject.rs Outdated
robobun and others added 2 commits July 18, 2026 11:37
percent_s_has_user_to_string applied the built-in-constructor check even
when the toString lives on the value itself, and had no path for
Symbol.toPrimitive at all, so classes that define [Symbol.toPrimitive]
and values like {toString(){...}, constructor: Object} or the built-in
prototype objects themselves were routed to inspect instead of
String(value). All of those matched Node before this PR.

Add a BuiltinName::toPrimitive variant (mapped to
vm.propertyNames->toPrimitiveSymbol in bindings.cpp) and a fast_get_own
wrapper around the existing JSC__JSValue__fastGetOwn, then rework the
helper to mirror Node's hasBuiltInToString: own callable toString or
Symbol.toPrimitive on the (proxy-unwrapped) value itself is always
user-provided; otherwise walk prototypes to the first owner of either
and treat it as built-in only when its own constructor names a core
ECMAScript type.

Extend the test with a class [Symbol.toPrimitive], an own
Symbol.toPrimitive literal, {toString, constructor: Object}, and
RegExp.prototype.

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/jsc/ConsoleObject.rs`:
- Around line 3343-3373: Update the `%s` hook detection around `owns_callable`
so own `toString` and `Symbol.toPrimitive` properties count as shadowing
regardless of whether their values are callable, undefined, or non-callable;
only prototype traversal should continue when the property is absent. Replace
`pointer.get_own_truthy` with an own-property descriptor lookup for
`constructor`, avoiding getter execution while preserving the existing
builtin-constructor decision. Add tests covering both non-callable/undefined own
hooks and accessor-based constructors.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: de38a0af-3b9c-48f6-849a-6449db29998b

📥 Commits

Reviewing files that changed from the base of the PR and between 511caaa and 7b3c7d8.

📒 Files selected for processing (5)
  • src/jsc/ConsoleObject.rs
  • src/jsc/JSValue.rs
  • src/jsc/bindings/bindings.cpp
  • src/jsc/lib.rs
  • test/js/web/console/console-log.test.ts

Comment thread src/jsc/ConsoleObject.rs
…tters

Node's hasBuiltInToString reads the prototype's constructor via
Object.getOwnPropertyDescriptor and checks descriptor.value, so an
accessor constructor is not invoked and is treated as user-provided.
get_own_truthy went through getOwnPropertySlot + slot.getValue, which
ran the getter and could misclassify the prototype as a built-in.

Add BuiltinName::constructor (mapped to vm.propertyNames->constructor)
and a fast_get_direct wrapper around the existing
JSC__JSValue__fastGetDirect_ (JSObject::getDirect, no getter
invocation), and use it for the constructor lookup in
percent_s_has_user_to_string. An accessor slot comes back as a
non-callable GetterSetter cell, so the helper returns Ok(true) and the
value goes through String(value), matching Node and the pre-PR
behaviour.
Comment thread src/jsc/ConsoleObject.rs
Comment thread src/jsc/ConsoleObject.rs
Comment thread src/jsc/JSValue.rs
…scope to fastGetOwn

JSValue::get_prototype returns a bare JSValue with no JsResult, and the
underlying JSC__JSValue__getPrototype has no throw scope, so a proxy
getPrototypeOf trap that throws leaves a pending exception with an
empty return. percent_s_has_user_to_string saw the empty value and
returned Ok(false), after which print_percent_s re-entered JSC with the
exception still pending. Check global.has_exception() after each
get_prototype and return Err(JsError::Thrown) so the throw propagates
out of console.log, matching Node.

Also give JSC__JSValue__fastGetOwn the same ASSERT_NO_PENDING_EXCEPTION
/ DECLARE_THROW_SCOPE / RETURN_IF_EXCEPTION shape as its sibling
JSC__JSValue__getOwn now that this PR makes it live.
Comment thread src/jsc/ConsoleObject.rs
Comment thread src/jsc/lib.rs
robobun added 2 commits July 18, 2026 12:27
Keeps the string -> BuiltinName reverse map in lib.rs a 1:1 mirror of
the enum after adding the two new variants.
@robobun

robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

#36141 takes the shared-helper route for this: the native PercentTag::S calls the single JS formatPercentS from internal/util/inspect (the same body util.format uses) instead of reimplementing the decision tree in Rust. Same observable fix, one code path.

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.

1 participant