Fix console.log %i/%d/%f number coercion to match Node

Closed
RO
2mo ago
+262-133

What

Fixes #31777. console.log('%i %i', [1, 2, 3, 4, 5]) printed NaN %i; Node and Deno print 1 %i.

$ bun -e "console.log('%i %i', [1, 2, 3, 4, 5])"
NaN %i          # before
1 %i            # after (matches node + deno)

Root cause

Bun has two format-specifier implementations. util.format (src/js/internal/util/inspect.js) was already correct, but the native console.log/console.error path in src/jsc/ConsoleObject.rs diverged:

  • %d and %i were merged into one tag, and both coerced the argument with Number() instead of Node's parseInt/parseFloat. parseInt/parseFloat convert the argument to a string first, so parseInt([1,2,3,4,5]) is parseInt("1,2,3,4,5") → 1, whereas Number([1,2,3,4,5]) is NaN.
  • Merging %d with %i also made %d truncate like parseInt — console.log('%d', 3.9) printed 3 instead of 3.9.
  • A bigint argument threw Conversion from 'BigInt' to 'number' is not allowed instead of printing 123n (%i/%d) or its numeric value (%f).

Node's documented semantics (and what inspect.js already does):

speccoercion
%iparseInt(value) (bigint → "<n>n", symbol → NaN)
%dNumber(value) (bigint → "<n>n", symbol → NaN)
%fparseFloat(value) (symbol → NaN)

Fix

  • Split %d from %i in ConsoleObject.rs.
  • Run the real global parseInt/parseFloat on the argument via two small FFI helpers (Bun__JSValue__parseInt/Bun__JSValue__parseFloat in bindings.cpp, wrapped as JSValue::parse_int/parse_float). These call globalObject->parseIntFunction()/parseFloatFunction(), so ToString coercion, leading-whitespace, 0x prefixes and partial parses all match Node exactly.
  • Handle bigint for %i/%d (print <digits>n, no color, matching formatBigIntNoColor).
  • Format the resulting number like String(value) — -0, ±Infinity and e-notation all render as Node does.

The native console.log path now agrees with util.format and with Node/Deno.

Verification

  • New describe("format specifier number coercion (#31777)") block in test/js/web/console/console-log.test.ts asserts Node-compatible output for %i/%d/%f with arrays, numeric-prefix strings, bigints, symbols, ±Infinity, -0 and large numbers.
  • Updated console-log.js fixture + console-log.expected.txt: several expected lines encoded the old buggy output (%d truncating, %i of an array printing NaN) and now match Node.
  • Confirmed the test fails on the unpatched binary and passes with the fix. Existing util.test.js, node console tests and util-format/internal-inspect suites still pass.

[review] gate passed · iteration 9 · 6 files touched

fails on main (without fix)
ASAN without fix: 12 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 (95e3b1eba)

test/js/web/console/console-log.test.ts:
uh oh
276 | console.log("%i", "0xff"); // 255
277 | console.log("%d", [3.9]); // 3.9 (Number, not parseInt-truncated)
278 | console.log("%d", "42abc"); // NaN
279 | console.log("%f", "3.14xyz"); // 3.14
280 | console.log("%f", [1.5, 2, 3]); // 1.5
281 | console.log("%i %d %f", 123n, 456n, 789n); // 123n 456n 789
              ^
TypeError: Conversion from 'BigInt' to 'number' is not allowed.
      at /workspace/bun/test/js/web/console/console-log.js:281:9

Bun v1.4.0-debug+95e3b1eba (Linux x64)

Length of output: 3777
Length of expected: 3904
Exit code: 1
30 |   console.error(err);
31 |   console.log("Length of output:", out.length);
32 |   console.log("Length of expected:", 
... (truncated)

release without fix: 12 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/js/web/console/console-log.test.ts:
uh oh
276 | console.log("%i", "0xff"); // 255
277 | console.log("%d", [3.9]); // 3.9 (Number, not parseInt-truncated)
278 | console.log("%d", "42abc"); // NaN
279 | console.log("%f", "3.14xyz"); // 3.14
280 | console.log("%f", [1.5, 2, 3]); // 1.5
281 | console.log("%i %d %f", 123n, 456n, 789n); // 123n 456n 789
              ^
TypeError: Conversion from 'BigInt' to 'number' is not allowed.
      at /workspace/bun/test/js/web/console/console-log.js:281:9

Bun v1.4.0-canary.1+1498d7b77 (Linux x64)

Length of output: 3777
Length of expected: 3904
Exit code: 1
30 |   console.error(err);
31 |   console.log("Length of output:", out.length);
32 |   console.log("Length of expected:", expected.length);
33 |   console.log("Exit code:", exitCode);
34 | 
35 |   expect(out).toBe(expected);
                   ^
error: expect(received).toBe(expected)

@@ -210,42 +210,41 @@
    ]
  ]
  custom inspect
  | 0 | 0 | 132 | -42 | 4 | -8 |
  | NaN | NaN | NaN | 0 | NaN | NaN |
- | 0 | 0.2 | 132.51 | -42.52 | -4.127888538432189e+22 | 8.5e-12 |
- | NaN | Infinity | -Infinity | -0 | NaN | NaN |
- 5e-12
+ | 0 | 0 
... (truncated)
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 (95e3b1eba)

test/js/web/console/console-log.test.ts:
(pass) should log to console correctly [1378.65ms]
(pass) long arrays get cutoff [5.50ms]
(pass) console.group [546.80ms]
(pass) console.log with SharedArrayBuffer [7.09ms]
(pass) format specifier number coercion (#31777) > console.log("%i %i", [1, 2, 3, 4, 5]) [470.55ms]
(pass) format specifier number coercion (#31777) > console.log("%i", []) [455.62ms]
(pass) format specifier number coercion (#31777) > console.log("%i", 3.9) [446.84ms]
(pass) format specifier number coercion (#31777) > console.log("%i", "42abc") [571.54ms]
(pass) format specifier number coercion (#31777) > console.log("%i", "0xff") [694.33ms]
(pass) format specifier number coercion (#31777) > console.log("
... (truncated)

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)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     95e3b1eba8
  features     (none)

22 deps, 105 codegen, 1168 objects in 838ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1231] gen ErrorCode+*.h
[2/1231] gen bindgenv2
[3/1231] fetch zlib
[zlib] up to date
[4/1231] fetch tinycc
[tinycc] up to date
[5/1230] fetch picohttpparser
[picohttpparser] up to date
[6/1230] gen .bind.ts → GeneratedBindings.cpp
[7/1230] subst deps/zlib/zlib.h
[8/1230] subst deps/zlib/zconf.h
[9/1181] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[10/1181] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[11/1181] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingCo
... (truncated)
diff hotspot
src/jsc/ConsoleObject.rs                     | 240 +++++++++++++--------------
 src/jsc/JSValue.rs                           |  17 ++
 src/jsc/bindings/bindings.cpp                |  40 +++++
 test/js/web/console/console-log.expected.txt |  32 ++--
 test/js/web/console/console-log.js           |  12 ++
 test/js/web/console/console-log.test.ts      |  54 +++++-
 6 files changed, 262 insertions(+), 133 deletions(-)

gate history · 1 passed · 0 rejected · iteration 9

evidence per changed file
file                                          reads  edits  tests
src/jsc/ConsoleObject.rs                         12     13     22
src/jsc/JSValue.rs                                7      4     22
src/jsc/bindings/bindings.cpp                     4      5     22
test/js/web/console/console-log.expected.txt      4      4     28
test/js/web/console/console-log.js                4      1     25
test/js/web/console/console-log.test.ts           2      3     22

root cause · written by the author bot

The bug was in how Bun's console formatter handled %i and %d specifiers when given a non-primitive argument such as an array: it coerced the whole object through a numeric conversion, yielding NaN, whereas Node.js applies parseInt semantics to the argument's string representation, so [1, 2, 3, 4, 5] stringifies to 1,2,3,4,5 and parses to 1. The fix routes these specifiers through dedicated parseInt and parseFloat bindings that replicate Node's coercion behavior, so %i and %d now produce 1 for that input and %f likewise matches Node's parseFloat handling.