Skip to content

fix(codegen): an integer literal past 2^31 must not seed the i32 fast path (#6319)#6340

Merged
proggeramlug merged 1 commit into
mainfrom
fix/6319-i32-literal-magnitude-wrap
Jul 13, 2026
Merged

fix(codegen): an integer literal past 2^31 must not seed the i32 fast path (#6319)#6340
proggeramlug merged 1 commit into
mainfrom
fix/6319-i32-literal-magnitude-wrap

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Closes #6319 — the third face of #6072 (i32 fast-path locals silently wrap at 2^31), after #6258 (bare x++ accumulator) and #6318 (dynamic-bound loop counter).

let x = 3000000000;
let y = 0;
y = x;
console.log(y);   // node: 3000000000   perry (before): -1294967296

Root cause

Expr::Integer carries an i64 — every integral literal that fits in i64 lowers into it, including 3000000000, 4294967296 and Number.MAX_SAFE_INTEGER (perry-hir/src/lower_patterns.rs:143). But every judgment in the i32 fast-path chain accepted Expr::Integer(_) regardless of magnitude.

x itself stayed correct — its own Let-site slot is gated on init_in_i32_range — yet it was still admitted to integer_locals. is_strictly_i32_bounded_expr's LocalGet arm then answers known_int_locals.contains(id), so y = x counted as a strictly-i32-bounded write, y got an i32 shadow at its Let site, and the store truncated the 3e9 double. Reads prefer the shadow (issue #48), so the wrapped value is what console.log sees.

Denying the shadow is the only cure. The f64 fallback in LocalSet mirrors into the i32 slot with fptosi+trunc anyway (literals_vars.rs:634), so rejecting the value in can_lower_expr_as_i32 would not have helped — the truncation happens on both paths.

The fix

One shared predicate, integer_literal_fits_i32, applied at all four judgments that make up the seeding chain:

site role
collect_integer_let_ids the syntactic seed
is_int32_producing_expr the forward-closure admission
int32_producing_deps the disqualification judgment
is_strictly_i32_bounded_expr the write-is-i32-bounded proof

All four are load-bearing. Narrowing only the seed (the issue's sketch) does not hold: the forward-closure pass re-admits from the Let init, and the disqualification judgment re-admits from every LocalSet rhs. And is_strictly_i32_bounded_expr is an independent hole — let y = 0; y = 3000000000; wrapped with no copy at all.

Two latent poison fptosis go with it: the arr.length-hoist (loops.rs:2454) and the static i < n bound (loops.rs:2516) both install a counter's i32 shadow from integer_locals membership alone, seeding it with a bare fptosi of the counter's double — poison in LLVM for a counter past 2^31.

Why the imul32 / FNV chain is safe

The compiler-output-regression gate watches image_convolution's i32 chain, whose two out-of-i32-range constants are written

const SEED = 0x9E3779B9 >>> 0;   // Binary{UShr, .., Integer(0)}
let h = 0x811c9dc5 | 0;          // Binary{BitOr, .., Integer(0)}

— they reach integer_locals through the >>> 0 / | 0 arms, not the bare Expr::Integer arm this PR narrows. | 0, >>> 0, bitwise ops and Math.imul genuinely produce int32 by spec; a bare literal does not. A sweep of every source in benchmarks/compiler_output/workloads.toml found no other >i32 literal.

Magnitude matrix (vs node --experimental-strip-types)

Each value is copied through a fresh local whose own Let init is in range, so the init_in_i32_range gate cannot mask the bug.

case node before after
2^31 - 1 2147483647 2147483647 2147483647
2^31 2147483648 2147483648 2147483648
2^31 + 1 2147483649 2147483649 2147483649
2^32 4294967296 4294967296 4294967296
-2^31 -2147483648 -2147483648 -2147483648
-2^31 - 1 -2147483649 -2147483649 -2147483649
MAX_SAFE_INTEGER 9007199254740991 9007199254740991 9007199254740991
copy (y = x) 3000000000 -1294967296 3000000000
direct store (d = 3000000000) 3000000000 -1294967296 3000000000
copy chain (a → b → c) 5000000000 705032704 5000000000
mutable chain 6000000000 1705032704 6000000000
compare (n = lim; n > 2^31-1) true false true
FNV-1a checksum de92f531 de92f531 de92f531
array sum 150 150 150

The compare row is worth calling out: const lim = 3000000000; let n = 0; n = lim; n > 2147483647 evaluated to false on main.

Fast paths preserved (IR evidence)

--trace llvm on the new fixture, before vs after:

before after
js_typed_feedback_numeric_array_index_get_guard (array fast path, #6299/#6312) 5 5
mul i32 (FNV / imul32 chain) 1 1
alloca i32 (i32 shadows) 13 6
store i32 2147483647 (2^31-1 keeps its shadow) 1 1
store i32 -2147483648 (-2^31 keeps its shadow) 1 1

Exactly the intended precision: the boundary literals that do fit keep the i32 fast path; only the ones that don't drop off it.

Validation

  • compiler-output-regression: all five CI gate steps green locally (harness unit tests, native-region-proof ×11 workloads, native-abi-proof ×12, vectorized_buffer_transform, hir_fact_rewrite, fma_contract) — failed_workloads: [].
  • test_gap_6072_i32_update_overflow and test_gap_6072_dynamic_bound_counter stay byte-identical to node.
  • Gap suite A/B (297 tests) against a pristine origin/main build: zero regressions. The only output changes are the new fixture and test_gap_console_methods (console.time jitter, e.g. 0.001ms0.003ms).
  • New fixture test-files/test_gap_6319_i32_literal_magnitude.ts, byte-identical to node.
  • cargo fmt --all -- --check and scripts/check_file_size.sh clean.

Known adjacent face — not this PR

Copying a local whose value came from an arithmetic chain past 2^31 still wraps (let big2 = big1 + big1; let t = 0; t = big2;-294967296). That is a different root cause: is_strictly_i32_bounded_expr's LocalGet arm reads integer_locals, which by design admits overflowing Add/Sub/Mul — it is not a literal-magnitude problem, and closing it means turning the strict set into a greatest fixpoint. Filed separately as #6339 with the repro and a fix sketch.

Summary by CodeRabbit

  • Bug Fixes

    • Corrected handling of integer literals outside the signed 32-bit range.
    • Prevented certain optimization paths from incorrectly treating large values as 32-bit integers.
    • Preserved accurate results for large-number assignments, mutation chains, counters, hashing, unsigned values, and array indexing.
  • Tests

    • Added coverage for 32-bit boundary values and out-of-range integer scenarios.

… path (#6319)

Third face of #6072 (`i32 fast-path locals silently wrap at 2^31`), reached
through the *copy* path rather than `x++` (#6258) or a dynamic loop bound
(#6318):

    let x = 3000000000;
    let y = 0;
    y = x;
    console.log(y);   // node: 3000000000   perry: -1294967296

`Expr::Integer` carries an `i64`, but every judgment in the i32 chain accepted
`Expr::Integer(_)` regardless of magnitude. `x` itself stayed correct — its own
`Let`-site slot is gated on `init_in_i32_range` — yet it was still admitted to
`integer_locals`. `is_strictly_i32_bounded_expr`'s `LocalGet` arm then answered
`known_int_locals.contains(id)`, so `y = x` counted as a strictly-i32-bounded
write, `y` got an i32 shadow at its `Let` site, and the store truncated the 3e9
double. Reads prefer the shadow (issue #48), so that is what `console.log` saw.

Denying the shadow is the only cure: the f64 fallback in `LocalSet` mirrors
into the i32 slot with `fptosi`+`trunc` anyway (literals_vars.rs:634), so
rejecting the value in `can_lower_expr_as_i32` would not have helped.

Narrow all four judgments through one shared predicate,
`integer_literal_fits_i32`:

  * `collect_integer_let_ids`      — the syntactic seed
  * `is_int32_producing_expr`      — the forward-closure admission
  * `int32_producing_deps`         — the disqualification judgment
  * `is_strictly_i32_bounded_expr` — the write-is-i32-bounded proof

All four are load-bearing. Narrowing only the seed does not hold: the forward
closure re-admits from the `Let` init and the judgment re-admits from every
`LocalSet` rhs. And `is_strictly_i32_bounded_expr` is an independent hole —
`let y = 0; y = 3000000000;` wrapped without any copy at all.

Two latent poison `fptosi`s go with it: the `arr.length`-hoist (loops.rs:2454)
and the static `i < n` bound (loops.rs:2516) both install a counter's i32 shadow
from `integer_locals` membership alone, seeding it with a bare `fptosi` of the
counter's double — poison in LLVM for a counter past 2^31.

Bitwise-coerced constants are untouched, so the imul32/FNV i32 chain that
`compiler-output-regression` watches is unaffected: image_conv's two
out-of-range constants are written `0x9E3779B9 >>> 0` and `0x811c9dc5 | 0`,
which reach `integer_locals` through the `>>> 0` / `| 0` arms, not the bare
`Expr::Integer` arm. `| 0`, `>>> 0`, bitwise ops and `Math.imul` genuinely
produce int32 by spec; a bare literal does not.
@coderabbitai

coderabbitai Bot commented Jul 13, 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: 7c25fadd-1850-486d-8227-1e4a2b1ce98d

📥 Commits

Reviewing files that changed from the base of the PR and between 735e894 and e62c15e.

📒 Files selected for processing (3)
  • crates/perry-codegen/src/collectors/i32_locals.rs
  • crates/perry-codegen/src/collectors/integer_locals.rs
  • test-files/test_gap_6319_i32_literal_magnitude.ts

📝 Walkthrough

Walkthrough

The i32 fast path now admits integer literals only when they fit in signed 32-bit range. Integer-local classification, strict boundedness checks, and regression scenarios for large literal magnitudes were updated.

Changes

i32 literal range validation

Layer / File(s) Summary
Literal range predicates and admission checks
crates/perry-codegen/src/collectors/i32_locals.rs, crates/perry-codegen/src/collectors/integer_locals.rs
Adds a shared i32-fit predicate and applies it to integer-local seeding, strict boundedness, and int32-producing expression checks.
Literal magnitude regression coverage
test-files/test_gap_6319_i32_literal_magnitude.ts
Adds boundary, copy-chain, mutation-chain, loop, hashing, unsigned-seed, and array-indexing scenarios for large integer literals.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related issues

  • #6339 — Related i32 classification logic is modified, although this change targets out-of-range integer literals rather than arithmetic-chain overflow.
  • #6072 — Addresses another signed 32-bit fast-path misclassification that can produce incorrect wrapped values.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is detailed, but it does not follow the required template sections like Summary, Changes, Related issue, and Test plan. Restructure the PR description to match the template and add the missing Summary, Changes, Related issue, Test plan, and checklist sections.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main fix: preventing >2^31 integer literals from seeding the i32 fast path.
Linked Issues check ✅ Passed The code changes and regression test match #6319: only i32-fitting literals seed the fast path across all four judgments.
Out of Scope Changes check ✅ Passed The PR stays focused on the literal-magnitude fix and its regression test; no unrelated code changes are evident.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ 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/6319-i32-literal-magnitude-wrap

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.

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.

i32 fast-path: copying a local seeded with a >2^31 integer literal wraps (third face of #6072)

1 participant