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

Closed
RO
2mo ago
+343-12

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.0Bun beforeBun after
Symbol("q")Symbol(q)TypeError: Cannot convert a symbol to a stringSymbol(q)
{ a: 1 }{ a: 1 }[object Object]{ a: 1 }
[1, 2][ 1, 2 ]1,2[ 1, 2 ]
-0-00-0
new Date(...)2023-11-14T22:13:20.000ZTue 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