Severity: P1 (silent data corruption)
Found while fixing: #6319
What
Copying a local whose value came from an arithmetic chain (rather than a literal) past 2^31 wraps. This survives #6258, #6318 and #6319 — it is a fourth, distinct face of #6072's title.
let big1 = 2000000000;
let big2 = big1 + big1; // 4e9 — correct in its own slot
let t = 0;
t = big2;
console.log(t); // node: 4000000000 perry: -294967296
let sum = 0;
for (let i = 0; i < 5; i++) sum = sum + 1000000000;
let sumCopy = 0;
sumCopy = sum;
console.log(sumCopy); // node: 5000000000 perry: 705032704
big2 / sum themselves stay correct — they never get an i32 shadow, because an Add write is not strictly-i32-bounded. Only the locals that copy them wrap.
Why
is_strictly_i32_bounded_expr (crates/perry-codegen/src/collectors/i32_locals.rs) answers its LocalGet arm from integer_locals:
Expr::LocalGet(id) => known_int_locals.contains(id),
That conflates two different properties. integer_locals means integer-valued (no fraction, no NaN) — it deliberately admits overflowing arithmetic; is_int32_producing_expr's own doc says so:
Add / Sub / Mul when both operands are int-producing. The sum/product may overflow i32, but the existing i32-slot machinery already accepts this risk […]
So t = big2 is judged a strictly-i32-bounded write, t gets an i32 shadow at its Let site, and the store truncates. Reads prefer the shadow (issue #48), so the wrapped value is what console.log sees.
#6319 closed the literal-magnitude hole (let x = 3000000000) by narrowing every Expr::Integer(_) judgment to literals that actually fit in i32. It does not close this one: the value here is produced by Add, not by a literal.
Fix sketch
Make the LocalGet / Update arms consult a set of locals proven i32-ranged, not merely integer-valued. is_strictly_i32_bounded_expr is a shallow match (it never recurses) and is monotone in its oracle, so a greatest fixpoint over collect_strictly_i32_bounded_locals is exact and terminating: start the oracle at integer_locals, disqualify any local with a non-strict write, and propagate through the copy edges until stable.
Locals that lose the shadow fall back to the f64 slot — correct, marginally slower. Index-used locals keep the shadow through index_used_locals, which is an independent term in needs_i32_slot, so the numeric array-index fast path (#6299/#6312) and the loop counter (#6318) are unaffected.
Wants its own PR with the compiler-output-regression gate run: strictly_i32_bounded_locals is what recovers image_convolution's FNV-1a h accumulator, and that suite carries runtime budgets.
Confidence: high (probe + source). Reproduced against origin/main and against the #6319 branch.
Severity: P1 (silent data corruption)
Found while fixing: #6319
What
Copying a local whose value came from an arithmetic chain (rather than a literal) past 2^31 wraps. This survives #6258, #6318 and #6319 — it is a fourth, distinct face of #6072's title.
big2/sumthemselves stay correct — they never get an i32 shadow, because anAddwrite is not strictly-i32-bounded. Only the locals that copy them wrap.Why
is_strictly_i32_bounded_expr(crates/perry-codegen/src/collectors/i32_locals.rs) answers itsLocalGetarm frominteger_locals:That conflates two different properties.
integer_localsmeans integer-valued (no fraction, no NaN) — it deliberately admits overflowing arithmetic;is_int32_producing_expr's own doc says so:So
t = big2is judged a strictly-i32-bounded write,tgets an i32 shadow at itsLetsite, and the store truncates. Reads prefer the shadow (issue #48), so the wrapped value is whatconsole.logsees.#6319 closed the literal-magnitude hole (
let x = 3000000000) by narrowing everyExpr::Integer(_)judgment to literals that actually fit in i32. It does not close this one: the value here is produced byAdd, not by a literal.Fix sketch
Make the
LocalGet/Updatearms consult a set of locals proven i32-ranged, not merely integer-valued.is_strictly_i32_bounded_expris a shallow match (it never recurses) and is monotone in its oracle, so a greatest fixpoint overcollect_strictly_i32_bounded_localsis exact and terminating: start the oracle atinteger_locals, disqualify any local with a non-strict write, and propagate through the copy edges until stable.Locals that lose the shadow fall back to the f64 slot — correct, marginally slower. Index-used locals keep the shadow through
index_used_locals, which is an independent term inneeds_i32_slot, so the numeric array-index fast path (#6299/#6312) and the loop counter (#6318) are unaffected.Wants its own PR with the
compiler-output-regressiongate run:strictly_i32_bounded_localsis what recovers image_convolution's FNV-1ahaccumulator, and that suite carries runtime budgets.Confidence: high (probe + source). Reproduced against
origin/mainand against the #6319 branch.