You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Found while root-causing #7232. Same invariant, different pass — and this one is
worse, because it also truncates fractional arguments.
emit_i64_specializations (crates/perry-codegen/src/codegen/i64_spec.rs)
re-emits a whole function body in i64 arithmetic whenever is_integer_specializable (crates/perry-codegen/src/collectors/clamp_detect.rs:154)
accepts it, and wraps it in an f64 shim that fptosis every argument and sitofps the result. The admission rule is:
return_type == Number and every param Number, and
the body is Add/Sub/Mul/Compare/Conditional over locals, params, integer
literals, exactly-representable number literals, and self-calls
(i64s_expr, same file, line 278).
Nothing in that rule proves the arguments are integers, and nothing bounds any
intermediate. So both halves of the contract are unchecked.
frac — frac(3, 0.5) is 0.5 * 2 * 2 * 2 = 4 in JS. The wrapper's fptosi double %arg to i64 truncates 0.5 to 0 on entry, so the whole
computation runs on the wrong value. A number param is a double; nothing
here proves otherwise.
The specialization is alwaysinline and non-recursive call sites get inlined at
HIR level before it matters — function mulAdd(x, y) { return x * y + 1 } called
straight-line emits an fmul and the _i64 body is dead-stripped. Self-recursion
is what makes the specialized body actually reachable, which is also the case the
pass exists for (the doc comment says "pure numeric recursive fn ... like
fibonacci"). Fibonacci's values are small and integral, so the motivating example
never exercises either hole.
Pre-dates #7232's fix — reproduced identically at c9cd73ba5 and on the #7232
branch, which touches a different pass (expr/i32_fast_path.rs).
Integer arithmetic may stand in for a JS number computation only while every
intermediate is exactly representable as a double (|v| <= 2^53) — and only
when every input is provably an integer to begin with.
Neither is currently proven. A fix has to either (a) prove both — integer-valued
params (there is already collectors/integer_locals.rs and #7122's canonical-i32
machinery to build on) plus a magnitude bound on the body, reusing i32_chain_magnitude_bits's composition at the i64/2^53 ceiling — or (b) delete
the pass, since per CLAUDE.md's kill-policy an unproven mode that nothing
exercises is a decision that has not been made. Worth measuring what it actually
buys on benchmarks/suite/05_fibonacci.ts before choosing.
Suggested acceptance
A parity case with a self-recursive accumulator that crosses 2^53, one that
crosses 2^63, and one called with a fractional argument — so a fix cannot pass
by handling only the overflow half.
Found while root-causing #7232. Same invariant, different pass — and this one is
worse, because it also truncates fractional arguments.
emit_i64_specializations(crates/perry-codegen/src/codegen/i64_spec.rs)re-emits a whole function body in i64 arithmetic whenever
is_integer_specializable(crates/perry-codegen/src/collectors/clamp_detect.rs:154)accepts it, and wraps it in an f64 shim that
fptosis every argument andsitofps the result. The admission rule is:return_type == Numberand every paramNumber, andliterals, exactly-representable number literals, and self-calls
(
i64s_expr, same file, line 278).Nothing in that rule proves the arguments are integers, and nothing bounds any
intermediate. So both halves of the contract are unchecked.
Repro
Two distinct failures:
grow— the accumulator passes 2^53 and then 2^63. JS rounds each*and
+to the nearest double; the i64 body computes exactly and then wrapsat 2^63, so the sign flips. (Below 2^63 but above 2^53 the answer is merely
wrong rather than negative — the number arithmetic in a local is evaluated past double precision —
(x * 1103515245 + 12345) & 0x7fffffffdiverges from Node #7232 divergence, in i64 form.)frac—frac(3, 0.5)is0.5 * 2 * 2 * 2 = 4in JS. The wrapper'sfptosi double %arg to i64truncates0.5to0on entry, so the wholecomputation runs on the wrong value. A
numberparam is a double; nothinghere proves otherwise.
The emitted IR shows both directly:
Why it has stayed quiet
The specialization is
alwaysinlineand non-recursive call sites get inlined atHIR level before it matters —
function mulAdd(x, y) { return x * y + 1 }calledstraight-line emits an
fmuland the_i64body is dead-stripped. Self-recursionis what makes the specialized body actually reachable, which is also the case the
pass exists for (the doc comment says "pure numeric recursive fn ... like
fibonacci"). Fibonacci's values are small and integral, so the motivating example
never exercises either hole.
Pre-dates #7232's fix — reproduced identically at
c9cd73ba5and on the #7232branch, which touches a different pass (
expr/i32_fast_path.rs).The invariant it needs
The same one #7232's fix states, one width up:
Neither is currently proven. A fix has to either (a) prove both — integer-valued
params (there is already
collectors/integer_locals.rsand #7122's canonical-i32machinery to build on) plus a magnitude bound on the body, reusing
i32_chain_magnitude_bits's composition at the i64/2^53 ceiling — or (b) deletethe pass, since per CLAUDE.md's kill-policy an unproven mode that nothing
exercises is a decision that has not been made. Worth measuring what it actually
buys on
benchmarks/suite/05_fibonacci.tsbefore choosing.Suggested acceptance
A parity case with a self-recursive accumulator that crosses 2^53, one that
crosses 2^63, and one called with a fractional argument — so a fix cannot pass
by handling only the overflow half.