Skip to content

Dynamic (n as any).toString() ignores the exponential threshold: 2.2e-308 prints ~308 decimal digits (static lowering is correct) #9713

Description

@proggeramlug

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_numbercall_primitive_builtin_prototype_method(Number, …)) 1e-310correct
2.2e-308 the ordinary tower 0.000…00022wrong

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions