Number.prototype.toString(radix) for a non-power-of-two radix emits the double's exact digits above 2^53, where V8 emits the shortest round-trip form. Static and dynamic call sites are both affected, so this is the radix formatter itself, not a dispatch divergence.
const rows: [string, number, number][] = [
["255r36", 255, 36], ["1e15r36", 1e15, 36], ["2**53r36", 9007199254740992, 36],
["1e21r36", 1e21, 36], ["1e21r7", 1e21, 7], ["1e30r36", 1e30, 36],
];
for (const [l, n, r] of rows) console.log(l + "=" + n.toString(r));
|
node 26.5.1 |
perry |
(255).toString(36) |
73 |
73 |
(1e15).toString(36) |
9ugxnorjls |
9ugxnorjls |
(2**53).toString(36) |
2gosa7pa2gw |
2gosa7pa2gw |
(1e21).toString(36) |
5v1j4f4ds7c000 |
5v1j4f4ds7c4ks |
(1e21).toString(7) |
5135235413265003022600000 |
5135235413265003023621156 |
(1e30).toString(36) |
2oy99wnkl1a000000000 |
2oy99wnkl1a848gwks0g |
Everything at or below 2^53 agrees. Above it the two disagree in the trailing digits: node stops at the last digit the shortest round-trip decimal justifies and pads with zeros, while perry keeps emitting the exact binary value's digits.
Power-of-two radices are unaffected — (1e21).toString(16) is 3635c9adc5dea00000 on both — which is what you would expect if the culprit is the general-radix digit loop rather than the value.
The mechanism is the same one INT_EXACT_FASTPATH_LIMIT documents for base 10 (crates/perry-runtime/src/builtins/formatting.rs): at and above 2^53 several integers map to one double, and the exact integer carries more significant digits than the shortest decimal that round-trips. Base 10 already routes through the shortest-round-trip formatter above that bound; the general-radix path (double_to_radix_string / js_jsvalue_to_string_radix) appears not to apply the equivalent cut.
Low severity on its own, same as #9713 — it matters where the text is compared, hashed, or parsed. Found while building the regression fixture for #9713; the two are independent (this one reproduces with a plain static n.toString(36) and #9713's fix does not touch it), so the #9713 fixture deliberately keeps its radix checks at or below 2^53.
Number.prototype.toString(radix)for a non-power-of-two radix emits the double's exact digits above 2^53, where V8 emits the shortest round-trip form. Static and dynamic call sites are both affected, so this is the radix formatter itself, not a dispatch divergence.(255).toString(36)7373(1e15).toString(36)9ugxnorjls9ugxnorjls(2**53).toString(36)2gosa7pa2gw2gosa7pa2gw(1e21).toString(36)5v1j4f4ds7c0005v1j4f4ds7c4ks(1e21).toString(7)51352354132650030226000005135235413265003023621156(1e30).toString(36)2oy99wnkl1a0000000002oy99wnkl1a848gwks0gEverything at or below 2^53 agrees. Above it the two disagree in the trailing digits: node stops at the last digit the shortest round-trip decimal justifies and pads with zeros, while perry keeps emitting the exact binary value's digits.
Power-of-two radices are unaffected —
(1e21).toString(16)is3635c9adc5dea00000on both — which is what you would expect if the culprit is the general-radix digit loop rather than the value.The mechanism is the same one
INT_EXACT_FASTPATH_LIMITdocuments for base 10 (crates/perry-runtime/src/builtins/formatting.rs): at and above 2^53 several integers map to one double, and the exact integer carries more significant digits than the shortest decimal that round-trips. Base 10 already routes through the shortest-round-trip formatter above that bound; the general-radix path (double_to_radix_string/js_jsvalue_to_string_radix) appears not to apply the equivalent cut.Low severity on its own, same as #9713 — it matters where the text is compared, hashed, or parsed. Found while building the regression fixture for #9713; the two are independent (this one reproduces with a plain static
n.toString(36)and #9713's fix does not touch it), so the #9713 fixture deliberately keeps its radix checks at or below 2^53.