console: fill in a format specifier that directly follows `%%`

RO
21d ago
+31-6

Problem

  • console.log("%%%s", "a") prints %%s a. Node prints %a. A specifier directly after the %% escape is not filled in, and %%%% prints %%%.
  • The cause is i = 1 after the %% arm of write_with_formatting (src/jsc/ConsoleObject.rs:2395). The scan continues at the second byte after the escape and never sees a % in the first.

Fix

  • Set i = 0 after the escape. The scan then continues at the first byte.
  • Correct because Node's formatWithOptionsInternal continues at that byte too.
  • Verified: test/js/web/console/console-log.test.ts (one new test, fails on 1.4.3). Also the other console suites and util-format.test.js.

Background

  • The format string is the first argument of a console call with more arguments. Each specifier uses one argument. %% prints one % and uses none.
  • write_with_formatting scans with index i into slice, and cuts slice after each escape or specifier.
  • util.format is separate JS code and already matches Node. Nothing else scans the console format string.

Downsides

  • New throw: a specifier after %% now takes its argument, so it throws for the values it already throws for alone. console.log("%%%d", 12n) printed %%d 12n and now throws TypeError: Conversion from 'BigInt' to 'number' is not allowed. Notes lists six such calls and the open PR that removes each (#45935, #45337, #45325).
  • Output changes for %% directly before %. console.log("%%%%", 1) printed %%% 1 and now prints %% 1. Snapshots of the old text change.
  • Still wrong: %% after the last used argument stays %% (#34239 changes that). Cost: one more loop iteration per %%.
Notes

Related to #34239. #34239 collapses %% after the last used argument in a pass after the loop. It does not change the %% arm. The two PRs merge without a conflict (git merge-tree against 292bffd859). A comment on #34239 reports this same fault: console.log("%%%% %c", "") prints %%% . With this change it prints %% , as Node does.

Scope. console.error, console.warn, console.info and console.debug share the fault, for every specifier (%s %d %i %f %j %o %O %c).

Calls that throw with this change. Each line is from a debug build of this branch, from 1.4.3 and from Node v26.3.0. cyclic is an object that contains itself.

CallNode1.4.3This PROpen PR for the specifier
console.log("%%%d", 12n)%12n%%d 12nTypeError: Conversion from 'BigInt' to 'number' is not allowed.#45935
console.log("%%%i", 5n)%5n%%i 5nthe same TypeError#45935
console.log("%%%f", 5n)%5%%f 5nthe same TypeError#45935
console.log("%%%s", Symbol("x"))%Symbol(x)%%s Symbol(x)TypeError: Cannot convert a symbol to a string#45337
console.log("%%%s", Object.create(null))%[Object: null prototype] {}%%s [Object: null prototype] {}TypeError: No default value#45337
console.log("%%%j", cyclic)%[Circular]%%j and the inspected objectTypeError: JSON.stringify cannot serialize cyclic structures.#45325
console.log("load %d%%%d", 50, 2.75)load 50%2.75load 50%%d 2.75load 50%2#45951

Each throw is the one that the same specifier throws today with no %% in front: console.log("%d", 12n) throws on 1.4.3. The % is written before the throw, so a caught throw leaves a partial line. If #45935, #45337 and #45325 merge before this PR, none of these calls throws. The last row does not throw. %d drops the fraction, as it does today with no %% in front.

History. The arm comes from #10910 (Zig): i = 0; continue; inside while (i < len) : (i += 1). The continue ran the loop step, so the scan continued at index 1. The Rust port wrote that result as i = 1 with a comment that named it an off-by-one.

Probes. Each line is console.log(...) on a debug build of this branch. All 28 lines equal the output of Node v26.3.0. The column "1.4.3" has the old text where it differs.

CallThis PR and Node1.4.3
("%d%%%s", 50, "!")50%!50%%s !
("%%%s", "a")%a%%s a
("%s is %d%%%s", "disk", 93, " full")disk is 93% fulldisk is 93%%s full
("%%%%", "a")%% a%%% a
("%%%%%s", "a")%%a%%%s a
("%%%d", 7)%7%%d 7
("%%%j", { a: 1 })%{"a":1}%%j {, then a: 1,, then }, on three lines
("%%%c", "x")%%%c x
("%s%%%s", "a", "b")a%ba%%s b
("a%%%%b%%%sc", "X")a%%b%Xca%%%b%%sc X
("%%%x%s", "a")%%xathe same
("%%a%s", "x")%axthe same
("Hello %%i %i", 5, 6)Hello %i 5 6the same

Suites run on the debug build. test/js/web/console/ (12 pass), then test/js/bun/console/, test/js/node/console/ and test/js/node/util/node-inspect-tests/parallel/util-format.test.js (100 pass, 1 skip, 2 todo, 0 fail).

Other scanners. The test.each title formatter (format_label, src/runtime/test_runner/jest.rs) steps over both bytes of %% and does not have this fault.