From 55d4caa6b2f53b36d2345b49016993d229963fe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 4 Sep 2026 14:41:08 +0200 Subject: [PATCH] fix(runtime): dynamic Number toString uses NumberToString, not Rust Display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dynamically dispatched `x["toString"]()` on a number reached three arms of the native-method tower that formatted with a bare `f64::to_string()`. That is Rust's Display: it never switches to scientific notation and spells the infinities `inf`, so `2.2e-308` printed ~308 decimal digits and `Infinity` printed `inf` — while the same value's four static renderings were correct in the same program. The three arms are the plain-number and boxed-`Number` `toString` in `dispatch_common` and the boxed-`Number` `toString`/`toLocaleString` in `dispatch_primitive`. All now call `js_number_to_string`, which carries the spec's `|n| >= 1e21 || |n| < 1e-6` switch and its own integer fast path. This is the same mistake #3987 fixed in the string-concat fast paths; these arms were not part of that sweep. A neighbouring defect in the same arms rides along: a boxed receiver dropped an explicit radix, so `new Number(255).toString(16)` answered "255". Both boxed arms now route an explicit radix through `js_jsvalue_to_string_radix`, as the unboxed arm already did. `toLocaleString`'s argument is a locale, not a radix, so it keeps ignoring it. Closes #9713 --- changelog.d/9728-dynamic-number-tostring.md | 52 ++++++++++++++ .../native_call_method/common_methods.rs | 41 +++++++---- .../native_call_method/primitive_methods.rs | 33 +++++++-- .../test_gap_9713_dynamic_number_tostring.ts | 68 +++++++++++++++++++ 4 files changed, 172 insertions(+), 22 deletions(-) create mode 100644 changelog.d/9728-dynamic-number-tostring.md create mode 100644 test-files/test_gap_9713_dynamic_number_tostring.ts diff --git a/changelog.d/9728-dynamic-number-tostring.md b/changelog.d/9728-dynamic-number-tostring.md new file mode 100644 index 0000000000..143bbbebac --- /dev/null +++ b/changelog.d/9728-dynamic-number-tostring.md @@ -0,0 +1,52 @@ +**A dynamically dispatched `x["toString"]()` on a number now produces +`NumberToString`, not Rust's `f64` Display** (#9713). It printed `inf` for +`Infinity` and the full decimal expansion past the exponential thresholds, so +the same value stringified four static ways and once dynamically disagreed +inside one program: + +```ts +const a = 2.2e-308; +a.toString(); // 2.2e-308 (all four static forms) +((x: any, m: string) => x[m]())(a, "toString"); // 0.000…00022 — ~308 digits +``` + +Three arms of the native-method tower — the plain-number and boxed-`Number` +`toString` in `dispatch_common`, and the boxed-`Number` +`toString`/`toLocaleString` in `dispatch_primitive` — formatted with + +```rust +if n.fract() == 0.0 && n.abs() < INT_EXACT_FASTPATH_LIMIT { (n as i64).to_string() } else { n.to_string() } +``` + +`f64::to_string()` is Rust's shortest-round-trip Display, which never switches +to scientific notation and renders the infinities as `inf`. It is the exact +mistake `js_format_f64`'s doc comment already warns about — #3987 replaced the +same `format!("{}", n)` in the string-concat fast paths and these three arms +were not part of that sweep. They now call `js_number_to_string`, which carries +the spec's `|n| >= 1e21 || |n| < 1e-6` switch, the `Infinity` / `NaN` / `-0` +spellings, and its own (safer) integer fast path — `js_format_f64` cuts over to +the shortest-round-trip formatter at 1e15 rather than 2^53, so it also avoids +the `2**58` → `…744` vs `…740` divergence the local fast path could reach. + +Measured against node 26.5.1, previously wrong and now correct: `1e21`, +`1e-7`, `-2.5e-9`, `2.2e-308`, `Number.MAX_VALUE`, `Number.MIN_VALUE`, +`Number.EPSILON`, `±Infinity`, and every one of those again through +`new Number(x).toString()`. + +One neighbouring defect in the same arms rides along: a boxed receiver dropped +an explicit radix entirely, so `new Number(255).toString(16)` answered `"255"` +instead of `"ff"`. Both boxed arms now route an explicit radix through +`js_jsvalue_to_string_radix` the way the unboxed arm already did (which also +means an out-of-range radix throws `RangeError` there, as the spec requires). +`toLocaleString` keeps ignoring its argument — that one is a locale, not a +radix. + +`test-files/test_gap_9713_dynamic_number_tostring.ts` pins 18 values across the +thresholds in all seven renderings plus the radix and `toFixed` / +`toPrecision` / `toExponential` forms. Unpatched it differs from node on 12 +lines; patched it is byte-identical. + +Not fixed here, and filed separately: `toString(radix)` above 2^53 for a +non-power-of-two radix still emits exact digits rather than V8's shortest +round-trip (#9725) — that one reproduces from a plain static call and is a +different formatter. diff --git a/crates/perry-runtime/src/object/native_call_method/common_methods.rs b/crates/perry-runtime/src/object/native_call_method/common_methods.rs index c7782d7d60..12a9e001eb 100644 --- a/crates/perry-runtime/src/object/native_call_method/common_methods.rs +++ b/crates/perry-runtime/src/object/native_call_method/common_methods.rs @@ -802,15 +802,28 @@ pub(super) unsafe fn dispatch_common( } else { payload }; - let s = if n.fract() == 0.0 - && n.abs() < crate::builtins::INT_EXACT_FASTPATH_LIMIT - { - (n as i64).to_string() - } else { - n.to_string() - }; - let str_ptr = - crate::string::js_string_from_bytes(s.as_ptr(), s.len() as u32); + // #9713: `NumberToString`, not Rust's `{}`. A bare + // `f64::to_string()` prints `inf` for Infinity and the + // full decimal expansion past the exponential + // thresholds (`1e21` → `1000000000000000000000`, + // `Number.EPSILON` → `0.000…0002220446049250313`). + // `js_number_to_string` carries the spec's + // `|n| >= 1e21 || |n| < 1e-6` switch and its own + // integer fast path, so the local one is redundant too. + // + // A boxed receiver takes a radix like an unboxed one + // (`new Number(255).toString(16)` is "ff"); this arm + // dropped the argument entirely and answered "255". + // `js_jsvalue_to_string_radix` already accepts a boxed + // Number receiver, so hand it the box, not the payload. + let radix_arg = refreshed_args().first().copied(); + if let Some(r) = radix_arg { + if !JSValue::from_bits(r.to_bits()).is_undefined() { + let str_ptr = crate::value::js_jsvalue_to_string_radix(object, r); + return Some(f64::from_bits(JSValue::string_ptr(str_ptr).bits())); + } + } + let str_ptr = crate::string::js_number_to_string(n); return Some(f64::from_bits(JSValue::string_ptr(str_ptr).bits())); } Some("Boolean") => { @@ -861,12 +874,10 @@ pub(super) unsafe fn dispatch_common( crate::value::js_jsvalue_to_string_radix(object, radix_arg.unwrap()); return Some(f64::from_bits(JSValue::string_ptr(str_ptr).bits())); } - let s = if n.fract() == 0.0 && n.abs() < crate::builtins::INT_EXACT_FASTPATH_LIMIT { - (n as i64).to_string() - } else { - n.to_string() - }; - let str_ptr = crate::string::js_string_from_bytes(s.as_ptr(), s.len() as u32); + // #9713: same as the boxed-Number arm above — `NumberToString`, + // not Rust's `f64` Display. This is the arm a dynamic + // `x["toString"]()` on a plain number reaches. + let str_ptr = crate::string::js_number_to_string(n); return Some(f64::from_bits(JSValue::string_ptr(str_ptr).bits())); } else if jsval.is_bool() { let s = if jsval.as_bool() { "true" } else { "false" }; diff --git a/crates/perry-runtime/src/object/native_call_method/primitive_methods.rs b/crates/perry-runtime/src/object/native_call_method/primitive_methods.rs index 0a082e013d..39f7e6d8bb 100644 --- a/crates/perry-runtime/src/object/native_call_method/primitive_methods.rs +++ b/crates/perry-runtime/src/object/native_call_method/primitive_methods.rs @@ -222,15 +222,34 @@ pub(super) unsafe fn dispatch_primitive( } else { payload }; - let s = if n.fract() == 0.0 - && n.abs() < crate::builtins::INT_EXACT_FASTPATH_LIMIT - { - (n as i64).to_string() + // #9713: `NumberToString`, not Rust's `{}`. A bare + // `f64::to_string()` prints `inf` for Infinity and the + // full decimal expansion past the exponential + // thresholds (`1e21` → `1000000000000000000000`, + // `Number.EPSILON` → `0.000…0002220446049250313`). + // `js_number_to_string` carries the spec's + // `|n| >= 1e21 || |n| < 1e-6` switch and its own + // integer fast path, so the local one is redundant too. + // + // A boxed receiver takes a radix like an unboxed one + // (`new Number(255).toString(16)` is "ff"); this arm + // dropped the argument entirely and answered "255". + // `js_jsvalue_to_string_radix` already accepts a boxed + // Number receiver, so hand it the box, not the payload. + // `toLocaleString`'s argument is a locale, not a + // radix, so only `toString` consumes it here. + let radix_arg = if method_name == "toString" { + refreshed_args().first().copied() } else { - n.to_string() + None }; - let str_ptr = - crate::string::js_string_from_bytes(s.as_ptr(), s.len() as u32); + if let Some(r) = radix_arg { + if !JSValue::from_bits(r.to_bits()).is_undefined() { + let str_ptr = crate::value::js_jsvalue_to_string_radix(object, r); + return Some(f64::from_bits(JSValue::string_ptr(str_ptr).bits())); + } + } + let str_ptr = crate::string::js_number_to_string(n); return Some(f64::from_bits(JSValue::string_ptr(str_ptr).bits())); } Some("Boolean") => { diff --git a/test-files/test_gap_9713_dynamic_number_tostring.ts b/test-files/test_gap_9713_dynamic_number_tostring.ts new file mode 100644 index 0000000000..89bce343d5 --- /dev/null +++ b/test-files/test_gap_9713_dynamic_number_tostring.ts @@ -0,0 +1,68 @@ +// #9713: a dynamically dispatched `x["toString"]()` on a number reached the +// native-method tower's own formatter — a bare Rust `f64::to_string()` — instead +// of ECMA-262 NumberToString. That prints `inf` for Infinity and the full +// decimal expansion past the exponential thresholds, so the same value +// stringified four static ways and once dynamically disagreed inside one +// program. Boxed `new Number(x)` receivers took the same wrong arm. +// +// Every row prints all the renderings so a future divergence shows which path +// moved, not just that something changed. `toLocaleString` is deliberately +// absent: node applies locale grouping there (`1,000,000,000,000,000,000,000`, +// `\u221e`) and perry does not, which is a separate gap this fixture should not +// be entangled with. + +function dynCall(x: any, m: string): any { return x[m](); } +function dynCall1(x: any, m: string, a: any): any { return x[m](a); } + +const values: [string, number][] = [ + ["1e21", 1e21], + ["1e20", 1e20], + ["1e-6", 1e-6], + ["1e-7", 1e-7], + ["-2.5e-9", -2.5e-9], + ["2.2e-308", 2.2e-308], + ["1e-310", 1e-310], + ["MAX_VALUE", Number.MAX_VALUE], + ["MIN_VALUE", Number.MIN_VALUE], + ["EPSILON", Number.EPSILON], + ["Infinity", Infinity], + ["-Infinity", -Infinity], + ["NaN", NaN], + ["-0", -0], + ["0.1", 0.1], + ["255", 255], + ["2**53", 9007199254740992], + ["2**58", 288230376151711744], +]; + +for (const [label, n] of values) { + const parts = [ + "static=" + n.toString(), + "String=" + String(n), + "tpl=" + `${n}`, + "concat=" + (n + ""), + "dyn=" + dynCall(n, "toString"), + "boxed=" + dynCall(new Number(n), "toString"), + "boxedValueOf=" + String(dynCall(new Number(n), "valueOf")), + ]; + console.log(label + " :: " + parts.join(" | ")); +} + +// An explicit radix must still reach the radix formatter, and an explicit +// `undefined` radix must behave like no argument at all. +console.log("radix16=" + dynCall1(255, "toString", 16)); +console.log("radix2=" + dynCall1(5, "toString", 2)); +// Radix values stay at or below 2^53: above it perry's non-power-of-two radix +// formatter emits exact digits where V8 emits the shortest round-trip form +// (`(1e21).toString(36)` → `5v1j4f4ds7c4ks` vs `5v1j4f4ds7c000`), statically as +// well as dynamically. That is a separate defect and not what this pins. +console.log("radix36=" + dynCall1(9007199254740992, "toString", 36)); +console.log("radix7=" + dynCall1(255, "toString", 7)); +console.log("radixUndef=" + dynCall1(1e21, "toString", undefined)); +console.log("boxedRadix16=" + dynCall1(new Number(255), "toString", 16)); + +// Sibling numeric methods on the same dynamic route, so a shared regression in +// the tower's number handling is visible here too. +console.log("toFixed=" + dynCall1(3.14159, "toFixed", 2)); +console.log("toPrecision=" + dynCall1(1234.5678, "toPrecision", 6)); +console.log("toExponential=" + dynCall1(1e21, "toExponential", 3));