console: use Node's %s format semantics instead of engine ToString
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 thensuffix) - symbols: reuse
print_symbol(Symbol(description)viaget_description, no throw) null/undefined/ booleans: literal text- strings and callables: existing
Tag::Stringpath (equivalent toString(value)) - objects with a user
toStringorSymbol.toPrimitive(percent_s_has_user_to_string): existingTag::Stringpath, so{toString(){...}}, user classes withtoString/[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,indentrestored, 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