console: %i and %f convert with parseInt and parseFloat, and a BigInt prints

RO
21d ago
+512-143

Fixes #31777

Problem

  • console.log('took %d ns', 12n) prints took , then throws TypeError: Conversion from 'BigInt' to 'number' is not allowed. console.log('%i %i', [1, 2, 3, 4, 5]) prints NaN %i. Node prints took 12n ns and 1 %i.
  • Formatter::write_with_formatting (src/jsc/ConsoleObject.rs:2452, :2533) converts the argument with ToNumber, then imitates parseInt in an i64. parseInt and parseFloat convert with ToString first.

Fix

  • One arm handles the three specifiers. %i is parseInt(value) and %f is parseFloat(value), with JSC's parsers. A BigInt under %d or %i prints as 12n.
  • %d stays the integer part of Number(value). Its digits are now exact.
  • Verified: test/js/web/console/console-log.test.ts (main fails 7 tests). Also test/js/web/console/ with BUN_JSC_validateExceptionChecks=1.
  • Self-reviewed: 10 concerns raised, 9 addressed. Not taken: the integer fast path in FormatDouble::dtoa_with_negative_zero. It adds 22 instructions to console.log(1234.5).

Background

  • The console has a native format scanner. util.format has a second one in JS, which matches node.
  • The WHATWG formatter has parseInt(value, 10) for %d and %i. Node has Number(value) for %d. This PR does not choose for %d: #45951 holds that change.
  • Considered a call of the global parseInt function (#31778): one VM entry for each converted argument.

Downsides

  • Output changes on 91 of 336 table cells. %i of null printed 0 and now prints NaN, as node does.
  • %d still differs from node on 38 of 112 values: %d of 3.9 prints 3.
  • %d of undefined costs 82 more instructions (331 to 413). The release binary grows by 4,096 bytes.
Notes

Table. 112 values under %d, %i and %f, against node v26.3.0:

%d%i%fthrow
main 017b2439a749 differ423618
this PR38000
  • The 13 %d cells that change: 7 digit fixes (2 ** 60, 2 ** 63, 2 ** 64, 123456789012345680000, Number.MIN_VALUE, Number.MIN_VALUE * 1.5, '12345678901234567890') and 6 BigInt values that threw.
  • 6 %i and %f cells change only in the sign of zero, to node's -0: %i of -0.5, -0.9999999, '-0', '-0.0' and %f of '-0', '-0.0'.
  • %f of a Number whose shortest digits tie now prints what String(number) returns: %f of 999999999999998.25 printed 999999999999998.3 and now prints 999999999999998.2.
  • 5,000 generated strings and 17,644 generated Numbers (fixed seeds): 0 lines differ from util.format for %i and %f, and 0 from the integer part for %d. Main: 1,832 and 1,809 string lines, 936, 1,277 and 19 Number lines.
  • The fixture console-log.expected.txt changes 8 lines: 5 digit fixes and the 3 Hello NaN lines that recorded the reported bug.

Cost. Release builds of main 017b2439a7 and of this branch. Instructions of one write_with_formatting call, callees included (gdb stepi):

callmainthis PR
no specifier129131
%d of an int32457462
%f of 1234.51590698
%d of 1700000000000554432
%i of 1234.5513381
%i of '42'763477
%f of '42.5'1817889
%d of '42'763705
%i of undefined331329
%f of undefined278325
%d of undefined331413
%i of {}541508
  • The int32 rows make the same 11 calls in both builds. The difference is register moves.
  • No VM entry and no allocator call for a Number, a string, undefined or {}. A VM entry only runs a toString or valueOf of the argument.
  • console.log("abc"), console.log(42), console.log(1234.5) and console.log(1700000000000): 0 instructions of difference for the whole call.
  • size: text 106,738,870 to 106,743,054 bytes (+4,184). write_with_formatting shrinks from 3,156 to 2,621 bytes in each of its two copies. The helpers add 5,556 bytes.
  • Line layout: 6,915 swept calls with a trailing array or object print byte-identical to main.
  • Test file on debug with ASAN and exception validation: 5.3 to 6.6 s (main's 5 tests: about 3.1 s). 5 more subprocesses, 6 on release builds. Each new test takes 0.4 to 1.2 s alone.

Not run. scripts/jsc-exception-lint needs the full WebKit sources under vendor/WebKit. cargo mordant is not installed here. cargo clippy for bun_core and bun_jsc, cargo fmt --check and clang-format pass.

Left for later.

  • %d as Number(value), which node prints: #45951, stacked on this PR. It changes output that %d has had since 2022.
  • console.assert(false, ...) aborts under BUN_JSC_validateExceptionChecks=1 with or without a format string, so its test is a todo on debug and ASAN builds (#44950, oven-sh/WebKit#790).
  • The line-width estimate counts an integer under %d or %i as one column. This PR keeps that, so no line breaks move.
  • A format string in a later argument is still substituted (#45571). console.table with a string first argument takes this scanner too.
  • test.each titles (#41565) and the unreachable scanner in src/runtime/test_runner/pretty_format.rs. #41565 adds its own parseFloat copy in the same file. The one that lands second can call parseFloatCharacters.
  • #39592 moves the node:worker_threads console onto this scanner. Those workers print node's text today.

This PR revives the token split and the tests of #31778.