Found while validating #9698. Not caused by it — see "Why this is not #9698" below.
Repro
const a: number = 2.2e-308;
// Static lowering — all correct.
console.log(a.toString()); // 2.2e-308
console.log(String(a)); // 2.2e-308
console.log(`${a}`); // 2.2e-308
console.log(a); // 2.2e-308
// Dynamic dispatch — wrong.
function dyn(x: any, m: string): any { return x[m](); }
console.log(dyn(a, "toString"));
|
output |
| node 26 |
2.2e-308 for all five |
| perry |
2.2e-308 for the four static forms; the dynamic one prints 0.000…00022 — the full ~308-digit decimal expansion |
The four static forms and the dynamic one disagree in the same program, so
this is a dispatch-path divergence, not a formatter that is simply wrong.
Where it is not
string::format::js_format_f64 implements the ECMA-262 NumberToString
threshold correctly — scientific notation when |n| >= 1e21 or |n| < 1e-6,
with a comment explaining exactly why (Number.EPSILON otherwise prints a
16-digit 0.000…0002…). js_number_to_string calls it, and the radix-10 arm
of value::to_string::number_to_radix_string returns js_number_to_string(n)
directly. So whatever the dynamic tower does for this receiver, it is not
reaching that formatter — the divergence is upstream of it.
I did not localise it further, and I am deliberately not guessing: the
primitive_methods toString arms I read are all jsval.is_pointer()-gated,
so a plain-number receiver does not take them, and I ran out of verified ground
before finding the arm that answers.
A measured localisation clue
Two subnormals, same zero-argument dynamic .toString(), different results —
and they reach Number.prototype by different routes:
| value |
route through js_native_call_method |
result |
1e-310 |
the #9698 entry arm (dispatch_unvouched_bare_as_number → call_primitive_builtin_prototype_method(Number, …)) |
1e-310 — correct |
2.2e-308 |
the ordinary tower |
0.000…00022 — wrong |
1e-310 is bare-shaped (bits >> 48 == 0), 2.2e-308 is not
(bits >> 48 == 15), which is the only reason they diverge in routing. So the
route that goes straight to call_primitive_builtin_prototype_method formats
correctly, and the ordinary tower's route does not. That should narrow the
search considerably.
Scope — not determined
I verified 2.2e-308 only. Untested, and worth checking as part of a fix,
is whether the same dynamic path also mishandles the other side of the
threshold and the ordinary-magnitude exponential cases:
1e21, 1e-7, Number.MAX_VALUE, Number.MIN_VALUE, Number.EPSILON
- the same values via
String(x) / `${x}` / + "" reached dynamically
- non-10 radices (
dyn(x, "toString", 16) on the same values)
dyn(255, "toString", 16) → "ff" and dyn(3.14159, "toFixed", 2) → "3.14"
are correct, so the argument-carrying dynamic forms are at least not
uniformly broken.
Reproducing
The environment I measured in has been torn down. To rebuild:
cargo build --release -p perry
cargo build --release -p perry-runtime-static -p perry-stdlib-static
./target/release/perry compile repro.ts -o repro && ./repro
node --experimental-strip-types repro.ts
Severity: low on its own — a number stringifies to a mathematically correct but
non-conforming form. It matters where the text is compared or parsed (JSON-ish
output, cache keys, snapshot tests, log diffing), and it is a parity divergence
that only appears under type erasure, which is exactly the shape minified
bundles produce.
Found while validating #9698. Not caused by it — see "Why this is not #9698" below.
Repro
2.2e-308for all five2.2e-308for the four static forms; the dynamic one prints0.000…00022— the full ~308-digit decimal expansionThe four static forms and the dynamic one disagree in the same program, so
this is a dispatch-path divergence, not a formatter that is simply wrong.
Where it is not
string::format::js_format_f64implements the ECMA-262NumberToStringthreshold correctly — scientific notation when
|n| >= 1e21or|n| < 1e-6,with a comment explaining exactly why (
Number.EPSILONotherwise prints a16-digit
0.000…0002…).js_number_to_stringcalls it, and the radix-10 armof
value::to_string::number_to_radix_stringreturnsjs_number_to_string(n)directly. So whatever the dynamic tower does for this receiver, it is not
reaching that formatter — the divergence is upstream of it.
I did not localise it further, and I am deliberately not guessing: the
primitive_methodstoStringarms I read are alljsval.is_pointer()-gated,so a plain-number receiver does not take them, and I ran out of verified ground
before finding the arm that answers.
A measured localisation clue
Two subnormals, same zero-argument dynamic
.toString(), different results —and they reach
Number.prototypeby different routes:js_native_call_method1e-310dispatch_unvouched_bare_as_number→call_primitive_builtin_prototype_method(Number, …))1e-310— correct2.2e-3080.000…00022— wrong1e-310is bare-shaped (bits >> 48 == 0),2.2e-308is not(
bits >> 48 == 15), which is the only reason they diverge in routing. So theroute that goes straight to
call_primitive_builtin_prototype_methodformatscorrectly, and the ordinary tower's route does not. That should narrow the
search considerably.
Scope — not determined
I verified
2.2e-308only. Untested, and worth checking as part of a fix,is whether the same dynamic path also mishandles the other side of the
threshold and the ordinary-magnitude exponential cases:
1e21,1e-7,Number.MAX_VALUE,Number.MIN_VALUE,Number.EPSILONString(x)/`${x}`/+ ""reached dynamicallydyn(x, "toString", 16)on the same values)dyn(255, "toString", 16)→"ff"anddyn(3.14159, "toFixed", 2)→"3.14"are correct, so the argument-carrying dynamic forms are at least not
uniformly broken.
Reproducing
The environment I measured in has been torn down. To rebuild:
Severity: low on its own — a number stringifies to a mathematically correct but
non-conforming form. It matters where the text is compared or parsed (JSON-ish
output, cache keys, snapshot tests, log diffing), and it is a parity divergence
that only appears under type erasure, which is exactly the shape minified
bundles produce.