Skip to content

fix(codegen): provenance-based transitive disqualification for integer locals (#4785 bug class)#4881

Merged
proggeramlug merged 1 commit into
mainfrom
harden-integer-locals-provenance
Jun 10, 2026
Merged

fix(codegen): provenance-based transitive disqualification for integer locals (#4785 bug class)#4881
proggeramlug merged 1 commit into
mainfrom
harden-integer-locals-provenance

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Summary

Hardens the integer-locals analysis (crates/perry-codegen/src/collectors/integer_locals.rs) so disqualification is provenance-based and transitive, and closes two codegen-side holes of the same bug class found while testing end-to-end. The bug class (regression #4785): a local wrongly treated as integer gets an i32 shadow slot / i32 lowering, the f64 value is actually a NaN-boxed pointer (or a fractional double), the fptosi read produces i32::MIN (or a truncated int), and user code crashes with (number).method is not a function or silently computes wrong values.

Two of the closed holes are live miscompiles on current main, demonstrated below.

1. Provenance model in collect_integer_locals

Admission is unchanged (optimistic syntactic seeds + forward propagation to a fixed point). Disqualification is rebuilt:

  • Every candidate is re-judged through all of its defining expressions: its Let init and every LocalSet rhs targeting it. The only init exemption is the optimistic let x = undefined; …writes… destructuring-scaffolding seed, whose real values are its writes (a write-free undefined init still fails, as before).
  • The judgment (int32_producing_deps) records provenance — the exact set of local ids whose candidate-ness each verdict relied on (LocalGet, Update targets, clamp-call arguments).
  • A failed judgment disqualifies the local; removal propagates transitively through reverse-dependency edges with a worklist — any number of hops, regardless of admission path. The judgment is monotone in the candidate set (relies only positively on membership), so one judging pass against the optimistic set plus transitive pruning is exact: no repeated full-body rescans per disqualification (the old loop rescanned the whole function once per pruned hop).
  • Locals written inside closure bodies stay unconditionally disqualified (historical behavior preserved).

The invariant is stated in the module-level comment: no admission path (seed, propagation, clamp_fn_ids, flat_const_ids) escapes transitive disqualification.

This subsumes the previous #4785 fix (collect_non_int_init_only_let_ids, now removed) and the old per-iteration collect_non_int_localset_ids_in_stmts rescan loop (also removed).

2. Bypass holes closed

(a) clamp_fn_ids admission — analysis layer. is_int32_producing_expr accepted any call to a clamp_fn_ids function unconditionally, but clamp3-shaped functions return one of their arguments verbatim, so const xx = clamp3(src, 0, 100) with src holding an object kept xx integer forever (the old init-only re-validation re-accepted the clamp call every iteration). The fact graph now passes the clamp3 set separately (arg_dependent_clamp_fn_ids, plumbed through collect_native_region_fact_graph and all six codegen entry points); for those calls every argument must be int-producing and the argument deps are recorded. clampU8-shaped / returns_integer functions coerce internally and stay argument-independent, so clampU8(doubleAccumulator) keeps its slot.

(b) Init bypass on written locals — analysis layer. Candidates with a LocalSet write were never re-validated through their init: in let b = a; use(b); b = 1, b stayed integer after a was disqualified, so the read between init and reassignment saw a truncated pointer. Inits are now obligations for every candidate. Live miscompile on main (see demo).

(c) Expr::Update bypass — analysis layer. const y = x++ was unconditionally int-producing even when x never was (or stopped being) an integer. The disqualification judgment now requires the updated local to be a candidate and records the dep (mirrors is_strictly_i32_bounded_expr).

(d) clamp3 call-site intrinsification — codegen layer (lower_call/func_ref.rs). Every FuncRef call to a clamp3 function was lowered to fptosi(args) + llvm.smax/smin + sitofp, violating lower_expr_as_i32's documented contract ("must be called only after can_lower_expr_as_i32 returned true"). This truncated NaN-boxed pointers to i32::MIN and plain fractional doubles — clamp3(2.5, 0, 5) returned 2 on main (node returns 2.5). The intrinsic is now gated on all three arguments passing can_lower_expr_as_i32 (which reads the hardened integer_locals); other calls fall through to the ordinary direct call. The clampU8 intrinsic stays unconditional: with the tightened detector (e) its fptosi + smax(0)/smin(255) agrees with the verified return v | 0 body for every f64 input.

(e) detect_clamp_u8 never checked its third statement. A body ending in bare return v; passes a fractional in-range v through unchanged, yet callers' results were treated as int-producing (and intrinsified). The matcher now requires the documented int-coercing return (v | 0 / bitwise / integer literal).

(f) i64 whole-function specialization of clamp-shaped functions — codegen layer (codegen/mod.rs). is_integer_specializable matched clamp3-shaped bodies and replaced the compiled function with an i64 variant behind an f64 wrapper that fptosis all arguments unconditionally. After (d), int-argument call sites use the smax/smin intrinsic and never call the symbol — so the specialized body only ever served exactly the calls it miscompiles. Clamp-shaped functions are now excluded from i64 specialization.

Pre-existing, out-of-scope issues found and not changed (flagged for follow-up):

  • The generic i64 specialization still miscompiles any "pure numeric recursive" function called with fractional/non-numeric args (i64s_expr even accepts Expr::Number(0.5) literals). Same bug class, orthogonal trigger.
  • flat_const_ids are module-init local ids; HIR local ids are scope-local, so a function-local id can in principle collide with a flat-const id. That exposure is shared with codegen's flat_const_arrays fast-path lowering (which keys on the same ids), so an analysis-only fix would help nothing; documented in the module comment.

3. clamp_fn_ids / flat_const_ids scoping audit

  • clamp_fn_ids contains function ids (clamp3 ∪ clamp_u8 ∪ returns_int), which are module-global — no per-function contamination. The unsoundness was semantic (argument-dependence), fixed in (a)/(d)/(e)/(f).
  • flat_const_ids facts (never-mutated module const int matrices) are immutable within a per-function analysis, so flat-const admissions correctly carry no local deps — they cannot be invalidated by anything this analysis disqualifies. Collision caveat documented (above).

Tests

Unit (collectors/hir_facts.rs, next to the existing #4785 tests, which still pass unchanged):

  • clamp_admitted_local_is_pruned_when_arg_source_is_disqualified — clamp-admitted chain pruned when an argument source is disqualified; positive int-arg case keeps the optimization; argument-independent (coercing) clamp keeps admitting double args.
  • written_local_is_still_revalidated_through_its_init
  • update_admitted_local_follows_its_target

E2E (crates/perry/tests/integer_locals_provenance.rs, compile with CARGO_BIN_EXE_perry + run + assert stdout):

  • the original fix(codegen): destructured-value copies no longer truncate to i32 (#4766 regression) #4785 destructure shape (const [k, v] = entry; const cb = v; cb.setName(...))
  • a 2+ hop init-copy chain off a disqualified undefined seed
  • a clamp3-admitted chain whose source is disqualified (+ fractional clamp values)
  • a written local read between its init copy and its later integer write
  • positive: a hot integer loop summing to 4,999,950,000 (past i32 range) and a clampIdx-fed index kernel still compute correct values. The kernel's clamp still lowers to the smax/smin intrinsic (verified via --trace llvm: int-arg call sites keep fptosi-free i32 chains).

Failure-mode demonstration (binary built from this branch's merge-base, i.e. current main, same sources as the e2e tests):

== destructure (main): exit=0: col:named-users       (pass — existing #4785 fix covers it; kept as regression guard)
== multihop    (main): exit=0: from-object           (pass — old loop handled init-chains one hop per rescan; guard)
== clamp       (main): exit=1: TypeError: (number).name is not a function   ← live miscompile, fixed here
== written     (main): exit=1: TypeError: (number).m is not a function      ← live miscompile, fixed here

clamp3(2.5, 0, 5) also prints 2 on main vs node's 2.5; this branch matches node byte-for-byte.

At the analysis level, the three new unit tests fail without the provenance logic (they encode exactly the holes the old code re-accepted each iteration).

Validation (rebased on current main)

Per contributor guidelines, no version bump / CHANGELOG / CLAUDE.md changes — maintainer adds those at merge.

…r locals (#4785 bug class)

The integer-locals analysis now records WHY each candidate is believed
integer (its init and every LocalSet rhs, with the local ids each verdict
relied on) and prunes transitively via a reverse-dependency worklist when
any source is disqualified — no admission path (seed, propagation,
clamp_fn_ids, flat_const_ids) can escape.

Holes closed (clamp + written-init are live miscompiles on main):
- clamp3-shaped calls admitted unconditionally even though clamp3 returns
  an ARGUMENT verbatim; now argument-dependent with recorded deps
- candidates with LocalSet writes were never re-validated through their
  init (let b = a; use(b); b = 1 kept a stale i32 slot)
- Expr::Update was unconditionally int-producing
- detect_clamp_u8 never verified its 'return v | 0' statement
- lower_call clamp3 intrinsification fptosi'd unproven args at every call
  site (clamp3(2.5, 0, 5) returned 2); now gated on can_lower_expr_as_i32
- i64 whole-function specialization replaced clamp-shaped bodies with an
  unconditional fptosi wrapper; clamp shapes are now excluded
@proggeramlug proggeramlug merged commit 96ec381 into main Jun 10, 2026
13 checks passed
@proggeramlug proggeramlug deleted the harden-integer-locals-provenance branch June 10, 2026 07:10
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.

1 participant