Skip to content

fix(runtime): large integers print V8 shortest round-trip, not exact (#6127) - #6176

Merged
proggeramlug merged 2 commits into
mainfrom
fix/6127-large-int-shortest
Jul 9, 2026
Merged

fix(runtime): large integers print V8 shortest round-trip, not exact (#6127)#6176
proggeramlug merged 2 commits into
mainfrom
fix/6127-large-int-shortest

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Whole-number f64s in [2^53, i64::MAX) printed the exact integer instead of V8's shortest round-trip decimal.

console.log(2 ** 58);            // node: 288230376151711740   perry(before): 288230376151711744
console.log(2 ** 60);            // node: 1152921504606847000  perry(before): 1152921504606846976
JSON.stringify(2 ** 58);         // node: "288230376151711740" perry(before): "288230376151711744"
(2 ** 60).toLocaleString();      // node: 1,152,921,504,606,847,000  perry(before): …846,976

Fixes #6127.

Root cause

Every duplicated number-to-string fast path used if n.fract() == 0.0 && n.abs() < (i64::MAX as f64) { n as i64 }. The i64::MAX threshold (~9.2e18) is far above 2^53, so integer-valued doubles between 2^53 and i64::MAX printed the exact integer. Below 2^53 every integer is exactly representable, so the cast equals the shortest decimal; at/above it the shortest form can carry trailing zeros the exact integer does not. .toString()/String()/template literals were already correct because they use js_format_f64 (Rust's shortest {}).

Fix

Add INT_EXACT_FASTPATH_LIMIT = 2^53 and route values at/above it through the existing shortest-round-trip formatter (positional up to 1e21). The small-integer fast path — the overwhelming majority — is unchanged, so hot paths keep the cheap cast and only rare large integers change (wrong → right). Surfaces:

  • console.log/dir (plain + inspect array/object), console.error, console.table
  • JSON.stringify (main + api + node:stream path — the stream path fell to ryu, which emits scientific notation, so large integers now use positional js_format_f64)
  • util.format('%i')
  • Number.prototype.toString (radix-less), boxed Number toString/valueOf
  • Number.prototype.toLocaleString — grouping built digits from abs.trunc() as u64 (which also overflowed past ~1.8e19); now from the f64's always-positional shortest {} form

Out of scope (documented): the two "Invalid typed array length: <n>" RangeError message sites (buffer/from.rs, typedarray/mod.rs) format a value for an error string, not as a program value — left unchanged.

Validation

Byte-for-byte vs node --experimental-strip-types:

  • All surfaces above → shortest round-trip; nested inspect, %i, toLocaleString grouping all match.
  • Unaffected: small integers, negatives, -0, non-integers, 1e21/1e-7 exponential thresholds, toString(radix), BigInt.
  • Regression: json / tofixed / object-key-order / bigint / console / inspect gap tests green; cargo fmt + GC store-site inventory clean.

Code-only per contributor convention (no version/CHANGELOG bump).

Summary by CodeRabbit

  • Bug Fixes
    • Improved formatting of whole-number f64 values across console logging, tables, JSON stringify, locale string rendering, and toString/toLocaleString, especially around the exact-integer precision boundary.
    • Fixed cases where very large integers could be rendered inconsistently (including less-friendly scientific or imprecise-looking output).
    • Standardized integer formatting using the precise “exact up to 2^53” cutoff, with large whole numbers routed to shortest round-trip formatting.

…6127)

Whole-number `f64`s in `[2^53, i64::MAX)` were printed as the exact integer via
an `as i64` cast, but at/above 2^53 several integers map to one double and the
exact value carries more significant digits than V8's shortest round-trip
decimal (`2**58` → `288230376151711740`, not `…744`; `2**60` → `…847000`, not
`…846976`). The cast threshold `i64::MAX` (~9.2e18) is far above 2^53, so every
duplicated number-to-string fast path across the runtime shared the bug.

Root: below 2^53 every integer is exactly representable, so `as i64` equals the
shortest decimal; at/above it the shortest form can have trailing zeros the
exact integer does not. Introduce `INT_EXACT_FASTPATH_LIMIT = 2^53` and route
values at/above it through the existing shortest-round-trip formatter (Rust's
`{}` / `js_format_f64`, which is positional up to 1e21). The small-integer fast
path (the overwhelming majority) is unchanged, so hot paths keep the cheap cast.

Surfaces fixed (all validated byte-for-byte vs `node --experimental-strip-types`):
- `console.log` / `console.dir` (plain + inspect array/object nesting)
- `console.error` (stderr) and `console.table`
- `JSON.stringify` (main path, api path, and the node:stream JSON path — the
  latter previously fell to `ryu`, which emits scientific notation; large
  integers now use positional `js_format_f64`)
- `util.format('%i')` (truncating integer specifier)
- `Number.prototype.toString` (radix-less), boxed `Number` `toString`/`valueOf`
- `Number.prototype.toLocaleString` — its grouping path built digits from an
  `abs.trunc() as u64` cast (which also overflowed past ~1.8e19); now derived
  from the f64's always-positional shortest `{}` form (`2**60` →
  `1,152,921,504,606,847,000`).

Out of scope (documented): the two "Invalid typed array length: <n>" RangeError
message sites (`buffer/from.rs`, `typedarray/mod.rs`) format a value for an error
string, not as a program value; left unchanged.

Small integers, negatives, `-0`, non-integers, `1e21`/`1e-7` exponential
thresholds, `toString(radix)`, and BigInt are unaffected; json/tofixed/
object-key-order/console regression tests stay green.
@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 563300d4-bb9b-46db-83c8-22ac931466d1

📥 Commits

Reviewing files that changed from the base of the PR and between d640967 and 7397b45.

📒 Files selected for processing (1)
  • crates/perry-runtime/src/builtins/console.rs

📝 Walkthrough

Walkthrough

Number-formatting paths now use a 2^53 exact-integer fast-path limit instead of i64::MAX as f64. Several console, JSON, table, locale, and native string-conversion branches were updated, and some large integral fallbacks now use shortest-round-trip formatting.

Changes

Integer fast-path limit unification

Layer / File(s) Summary
Fast-path constant and helper definition
crates/perry-runtime/src/builtins/formatting.rs, crates/perry-runtime/src/builtins/mod.rs
Adds INT_EXACT_FASTPATH_LIMIT, adds format_integral_f64, updates the shared formatting gates, and re-exports the constant.
util.format %i formatting
crates/perry-runtime/src/builtins/formatting/util_format.rs
Replaces integer-cast rendering with format_integral_f64 for truncated values.
Console logging entry points
crates/perry-runtime/src/builtins/console.rs
Console log, error, and warn entry points switch their integer fast-path checks to INT_EXACT_FASTPATH_LIMIT; js_console_log_dynamic also changes its non-integer fallback to format_finite_number_js(n).
JSON, stream, table, date, and native string output
crates/perry-runtime/src/json/stringify.rs, crates/perry-runtime/src/json/stringify_api.rs, crates/perry-runtime/src/node_stream_json.rs, crates/perry-runtime/src/builtins/table.rs, crates/perry-runtime/src/date.rs, crates/perry-runtime/src/object/native_call_method/common_methods.rs, crates/perry-runtime/src/object/native_call_method/primitive_methods.rs
Updates integer fast-path thresholds across JSON, stream, table, locale, and native toString/toLocaleString paths, with push_json_number and js_number_to_locale_string also changing integral rendering behavior.
Estimated code review effort: 2 (Simple) ~15 minutes

Possibly related PRs

  • PerryTS/perry#6030: Both PRs modify boxed-Number toString/toLocaleString handling in dispatch_primitive within the same file.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main runtime formatting fix for large integers.
Description check ✅ Passed The description covers the summary, root cause, fix, and validation, with only some template sections left informal or omitted.
Linked Issues check ✅ Passed The changes address #6127 by switching large integer-valued f64s to shortest-round-trip formatting across the affected runtime paths.
Out of Scope Changes check ✅ Passed The modified files stay aligned with the number-formatting fix and the noted RangeError sites were intentionally left unchanged.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/6127-large-int-shortest

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
crates/perry-runtime/src/builtins/formatting.rs (2)

1080-1084: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider using format_integral_f64 to avoid duplicating the threshold logic.

The inline n.abs() < INT_EXACT_FASTPATH_LIMIT gate duplicates the same logic already encapsulated in format_integral_f64. If the threshold or fallback ever changes, three sites must be updated in lockstep. Restructuring to call the helper keeps the decision in one place.

♻️ Proposed refactor
             } else if is_negative_zero(n) {
                 "-0".to_string()
-            } else if n.fract() == 0.0 && n.abs() < INT_EXACT_FASTPATH_LIMIT {
-                (n as i64).to_string()
+            } else if n.fract() == 0.0 {
+                format_integral_f64(n)
             } else {
                 format_finite_number_js(n)
             }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/builtins/formatting.rs` around lines 1080 - 1084,
The `format_finite_number_js` branch is duplicating the integral fast-path
threshold check with `n.abs() < INT_EXACT_FASTPATH_LIMIT`, which should be
centralized in `format_integral_f64`. Update this logic to route the
integer-like `f64` case through `format_integral_f64` instead of inlining the
gate, keeping the fast-path decision in one place and avoiding drift if the
threshold or fallback behavior changes.

1741-1745: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Same duplication as format_jsvalue — consider using format_integral_f64 here too.

This site mirrors the inline threshold check at line 1080. The same refactor applies for consistency and single-source-of-truth.

♻️ Proposed refactor
             } else if is_negative_zero(n) {
                 "-0".to_string()
-            } else if n.fract() == 0.0 && n.abs() < INT_EXACT_FASTPATH_LIMIT {
-                (n as i64).to_string()
+            } else if n.fract() == 0.0 {
+                format_integral_f64(n)
             } else {
                 format_finite_number_js(n)
             }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/builtins/formatting.rs` around lines 1741 - 1745,
The numeric formatting branch in `formatting.rs` duplicates the same integral
fast-path logic used by `format_jsvalue`; refactor this branch to reuse
`format_integral_f64` instead of repeating the `fract`/`abs` threshold check.
Update the `format_finite_number_js` path so the behavior stays identical while
centralizing the integer formatting decision in the shared helper.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-runtime/src/builtins/console.rs`:
- Around line 100-103: `js_console_log_dynamic` is using Rust’s `f64` Display in
its fallback path, which makes the new integer-valued double range format
differently from the other console helpers. Update the fallback branch in
`js_console_log_dynamic` to use `format_finite_number_js(n)` just like
`js_console_error_dynamic` and `js_console_warn_dynamic`, and keep the fast
integer path only for values that are safely representable as `i64`.

---

Nitpick comments:
In `@crates/perry-runtime/src/builtins/formatting.rs`:
- Around line 1080-1084: The `format_finite_number_js` branch is duplicating the
integral fast-path threshold check with `n.abs() < INT_EXACT_FASTPATH_LIMIT`,
which should be centralized in `format_integral_f64`. Update this logic to route
the integer-like `f64` case through `format_integral_f64` instead of inlining
the gate, keeping the fast-path decision in one place and avoiding drift if the
threshold or fallback behavior changes.
- Around line 1741-1745: The numeric formatting branch in `formatting.rs`
duplicates the same integral fast-path logic used by `format_jsvalue`; refactor
this branch to reuse `format_integral_f64` instead of repeating the
`fract`/`abs` threshold check. Update the `format_finite_number_js` path so the
behavior stays identical while centralizing the integer formatting decision in
the shared helper.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d9f01e42-44a5-4ba8-bda6-86067f0a6805

📥 Commits

Reviewing files that changed from the base of the PR and between 402dbcf and d640967.

📒 Files selected for processing (11)
  • crates/perry-runtime/src/builtins/console.rs
  • crates/perry-runtime/src/builtins/formatting.rs
  • crates/perry-runtime/src/builtins/formatting/util_format.rs
  • crates/perry-runtime/src/builtins/mod.rs
  • crates/perry-runtime/src/builtins/table.rs
  • crates/perry-runtime/src/date.rs
  • crates/perry-runtime/src/json/stringify.rs
  • crates/perry-runtime/src/json/stringify_api.rs
  • crates/perry-runtime/src/node_stream_json.rs
  • crates/perry-runtime/src/object/native_call_method/common_methods.rs
  • crates/perry-runtime/src/object/native_call_method/primitive_methods.rs

Comment thread crates/perry-runtime/src/builtins/console.rs Outdated
…er formatter (#6127)

Address CodeRabbit review: `js_console_log_dynamic`'s non-integer / large-integer
fallback printed the number via Rust's `f64` Display (`println!("{}{}", p, n)`)
while the sibling `js_console_error_dynamic` / `js_console_warn_dynamic` paths use
`format_finite_number_js`. Rust's Display never emits scientific notation, so a
single-argument `console.log(1e21)` printed `1000000000000000000000` instead of
Node's `1e+21` (and likewise for `1e-7`-scale magnitudes) — a pre-existing gap
this PR's lowered fast-path threshold routed more values through. Route the
fallback through `format_finite_number_js`, which applies the shortest
round-trip digits AND the 1e21/1e-6 exponential thresholds, matching the other
console dynamic printers and `String(n)`. The `[2^53, i64::MAX)` integers this PR
targets were already correct (positional either way); this fixes the ≥1e21 case
and removes the inconsistency.
@proggeramlug
proggeramlug merged commit 95059b8 into main Jul 9, 2026
25 checks passed
@proggeramlug
proggeramlug deleted the fix/6127-large-int-shortest branch July 9, 2026 14:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Large integers print exact value instead of V8 shortest round-trip (2**58 → …744 vs …740)

1 participant