Skip to content

fix(thumb2): i64.popcnt epilogue clobber + i64.div_s INT64_MIN/-1 overflow trap (#632, #633) - #634

Merged
avrabe merged 1 commit into
mainfrom
fix/632-633-i64-popcnt-divs
Jul 8, 2026
Merged

fix(thumb2): i64.popcnt epilogue clobber + i64.div_s INT64_MIN/-1 overflow trap (#632, #633)#634
avrabe merged 1 commit into
mainfrom
fix/632-633-i64-popcnt-divs

Conversation

@avrabe

@avrabe avrabe commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Fixes #632, fixes #633 — two shipped thumb-2 i64 miscompiles filed by gale, fixed structurally on both the Thumb-2 (cortex-m) and A32 (cortex-r5) paths.

#632 — i64.popcnt result clobbered by the expansion's own scratch-restore pop

Root cause (confirmed): it is neither the allocator choosing a "wrong" register nor the function epilogue — the I64Popcnt expansion itself saves scratch with PUSH {R3,R4,R5}, computes popcnt(lo)→R4, popcnt(hi)→R5, materializes the total with ADDS rd, R4, R5, and then restores scratch with POP {R3,R4,R5}. rd is allocator-assigned (any of R0–R8); whenever pressure lands it inside the expansion's own restore set {R3,R4,R5} (the issue's case: rd=R5), the restore destroys the result one instruction after it is produced.

Structural fix: the count is carried ACROSS the restore in R12 — encoder scratch, never allocatable (#212), never in any restore set — and moved into rd only after the pop (ADD.W R12,R4,R5; POP {R3,R4,R5}; MOV rd,R12). No choice of rd can collide, by construction. Two latent hazards in the same arm fixed alongside: the entry marshal now routes rnlo through R12 (an operand pair living at (R3,R4) previously read a clobbered R4), and the new MOV/MOV.W forms are total over rd/rnhi = R8 (the old ADDS T1 / MOVS T1 3-bit fields silently corrupted the encoding for R8 — #178/#180 class).

Expansion-family audit (pop-restore clobber class), per the #615-style sweep:

expansion Thumb-2 A32 verdict
I64Popcnt ADDS rd,R4,R5 before POP {R3,R4,R5} same AFFECTED — fixed (both ISAs)
I64DivS/DivU/RemS/RemU result staged in R0:R1 before POP {R4-R11}/{R4-R8}/{R4-R7}; #610 fixed-ABI exit skips restored regs same safe
I64Rotl/Rotr #610 fixed-ABI wrapper, no extra scratch pop same safe
I64Clz/I64Ctz no scratch push/pop same safe
i32 Popcnt no scratch push/pop (R11/R12 scratch) same safe (class-wise)

I64Popcnt was the only member of the class, on both ISAs; both are fixed here.

#633 — i64.div_s(INT64_MIN, -1) silently returned INT64_MIN instead of trapping

Root cause (confirmed): the I64DivS expansion emitted only the divide-by-zero guard. It negated the dividend (INT64_MIN wraps to itself), negated the divisor (−1→1), ran the unsigned core, and returned 0x8000000000000000 — no trap, violating WASM Core §4.3.2 idiv_s. The i32 path has the guard; the i64 path did not.

Fix: mirror the i32 overflow guard on the #610/#613 fixed-ABI wrapper path, right after the zero-divisor guard, where the dividend is in R0:R1 and the divisor in R2:R3: divisor == -1 && dividend == INT64_MIN → UDF #0 (22 bytes Thumb-2, register-independent; conditional-execution twin on A32). Emitted for div_s only.

Fix-guard twin: i64.rem_s(INT64_MIN, -1) keeps returning 0 without trapping — pinned by differential vectors (rem_s_m1(0, 0x80000000) = 0 OK, rem_s(IMIN, -1) = 0 OK) and by unit tests asserting I64RemS contains exactly one UDF (the zero-divisor trap) on both ISAs.

Oracles — red → green

scripts/repro/i64_popcnt_632_differential.py (unicorn-vs-wasmtime, symtab-based, -t cortex-m3 --relocatable), on origin/main:

popcnt64(0x7, 0x0) = 0x0 (oracle: 0x3) MISMATCH
popcnt64(0x1, 0x1) = 0x0 (oracle: 0x2) MISMATCH
popcnt64(0xffffffff, 0x0) = 0x0 (oracle: 0x20) MISMATCH
popcnt64(0xffffffff, 0xffffffff) = 0x0 (oracle: 0x40) MISMATCH
popcnt64(0x0, 0xffffffff) = 0x0 (oracle: 0x20) MISMATCH
popcnt64(0x12345678, 0x9abcdef0) = 0x0 (oracle: 0x20) MISMATCH
popcnt_direct(0xff) = 0x8 (oracle: 0x8) OK          <- issue's direct-form control
ORACLE: FAIL — 6 vector(s) wrong (#632)

post-fix: all 11 vectors OK, ORACLE: PASS (issue table (7,0)→3, (1,1)→2, (−1,0)→32, (−1,−1)→64 all exact).

scripts/repro/i64_divs_overflow_633_differential.py, on origin/main:

div_s_m1(0x0, 0x80000000) = 0x0 (oracle: TRAP) MISMATCH
div_s(0x8000000000000000, 0xffffffffffffffff) = 0x0 (oracle: TRAP) MISMATCH
rem_s_m1(0x0, 0x80000000) = 0x0 (oracle: 0x0) OK    <- twin already correct pre-fix
ORACLE: FAIL — 2 vector(s) wrong (#633)

post-fix: all 16 vectors OK, ORACLE: PASS — including 100/-1 = -100, INT64_MIN/1 and /2 (defined, no trap, both halves checked), rem_s(INT64_MIN,-1) = 0 no-trap, and div/rem-by-zero still trapping on both sides.

Gates

  • Unit tests: test_632_i64_popcnt_result_survives_scratch_restore (all rd incl. {R3,R4,R5}, R8), test_632_i64_popcnt_marshal_pair_at_r3_r4, test_632_a32_i64_popcnt_result_survives_scratch_restore, test_633_i64_divs_overflow_guard_emitted, test_633_i64_rems_has_no_overflow_guard, test_633_a32_i64_divs_overflow_guard
  • estimator_encoder_agreement (test(vcr-oracle): estimator↔encoder agreement oracle for the optimized path (#498, #242) #511 pin): green — I64Popcnt 172→180, I64DivS 172→194, register-independent; I64RemS unchanged at 170
  • cargo test -p synth-cli --test frozen_codegen_bytes: all anchors untouched (no i64 popcnt / div_s-overflow shapes in the frozen fixtures)
  • cargo test --workspace: 105 suites, 0 failures
  • cargo fmt --check clean, cargo clippy --workspace --all-targets -- -D warnings clean

🤖 Generated with Claude Code

…rflow trap (#632, #633)

#632 — the I64Popcnt expansion's own scratch restore (POP {R3,R4,R5})
clobbered the freshly computed count whenever the allocator-assigned rd
landed inside the restore set (ADDS rd,R4,R5 one instruction before the
pop). Structural fix on both the Thumb-2 and A32 arms: the total is
carried ACROSS the restore in R12 (encoder scratch, never allocatable
per #212, never in any restore set) and moved into rd only after the
pop — no choice of rd can collide. The entry marshal also routes rnlo
through R12 so an operand pair living at (R3,R4) can no longer read a
clobbered R4, and the new MOV/MOV.W forms are total over rd/rnhi = R8
where the old 3-bit T1 fields silently corrupted the encoding.

Expansion-family audit (pop-restore clobber class): I64Popcnt was the
only affected op on either ISA. I64Div{S,U}/I64Rem{S,U} and
I64Rotl/Rotr stage their result in R0:R1 before their scratch pop and
route it through the #610 fixed-ABI exit (which skips restored
registers); I64Clz/I64Ctz and i32 Popcnt push no scratch.

#633 — the i64 signed-division expansion emitted only the
divide-by-zero guard: INT64_MIN/-1 negated the dividend onto itself and
silently returned INT64_MIN instead of trapping (WASM Core 4.3.2
idiv_s). Mirror the i32 path's overflow guard on the #610/#613
fixed-ABI wrapper path (dividend R0:R1, divisor R2:R3):
dividend==INT64_MIN && divisor==-1 -> UDF #0, on both the Thumb-2 and
A32 I64DivS arms. I64RemS deliberately stays guard-free —
rem_s(INT64_MIN,-1) is defined as 0 and must not trap (pinned by the
fix-guard twin vectors and unit tests).

Oracles (red -> green):
- scripts/repro/i64_popcnt_632_differential.py — unicorn-vs-wasmtime,
  symtab-based: 6/11 vectors MISMATCH (0 for every input) on main,
  11/11 OK post-fix.
- scripts/repro/i64_divs_overflow_633_differential.py — INT64_MIN/-1
  returned 0 instead of TRAP on main (2 MISMATCH), 16/16 OK post-fix
  including rem_s(INT64_MIN,-1)=0 no-trap and div-by-zero still-traps.
- estimator_encoder_agreement (the #511 pin): I64Popcnt 172->180,
  I64DivS 172->194, register-independent.
- frozen_codegen_bytes: all anchors untouched (no i64 popcnt /
  div_s-overflow shapes in the frozen fixtures).

Closes #632
Closes #633

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Jul 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@avrabe
avrabe merged commit 93fd0d1 into main Jul 8, 2026
30 checks passed
@avrabe
avrabe deleted the fix/632-633-i64-popcnt-divs branch July 8, 2026 03:59
avrabe added a commit that referenced this pull request Jul 8, 2026
/#633, #634) (#635)

Pin sweep 0.32.0 -> 0.32.1 + CHANGELOG.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
avrabe added a commit that referenced this pull request Jul 8, 2026
…lision, two-guard obligation split (VCR-PERF-002) (#636)

Upgrades exactly the op class phase 2 deliberately left untracked: the
fact_spec symbolic walk now tracks i32/i64 div/rem — never deleting them
(they can trap; a div result carries no erasable producer slice) but
discharging up to TWO INDEPENDENT per-site guard obligations through the
ordeal-backed certificate-checked BvSolver BEFORE emission:

  divide-by-zero guard (div_u/div_s/rem_u/rem_s, i32+i64):
      UNSAT(P ∧ divisor == 0)
  INT_MIN/-1 overflow guard (div_s ONLY; rem_s(INT_MIN,-1)==0 never traps):
      UNSAT(P ∧ dividend == INT_MIN ∧ divisor == -1)

THE TWO-GUARD DISTINCTION (#633/#634 synergy): a divisor-nonzero fact
(kind 3) discharges the first but NOT the second — divisor ≠ 0 does not
exclude -1, so the overflow guard is RETAINED (loud decline) unless the
premises independently prove it (divisor ∈ [1,N] proves both; either fact
kind works). Sat/Unknown/no-premise ⇒ loud decline, general lowering.

Mechanics: discharged obligations become per-site marks
(FactSpecResult::elide_div_zero/elide_div_ovf, remapped through any
clamp-elision rewrite), threaded via CompileConfig::fact_div_zero_elide/
fact_div_ovf_elide to the DIRECT selector only — the optimized path's IR
passes renumber instructions, so marked functions route to
select_with_stack (#507/#509 honest-degradation pattern; never fires
without SYNTH_FACT_SPEC + facts + a discharged obligation). i32 guards are
selector-emitted and skipped; i64 guards live in the ArmOp::I64Div*/Rem*
encoder expansions, which gain per-guard elision flags (Thumb-2 + A32),
with estimator sizes tracking the flags (#511 agreement oracle extended
with the 5 elided variants).

Oracles: 9 new fact_spec unit gates (two-guard matrix, i64 width
discipline via current_func_params_i64, mark remapping); encoder splice
pins (elision removes EXACTLY the 8 B/12 B zero guard; overflow retained
under zero-only elision); fact_spec_div_494.rs byte evidence (guard UDFs
present without facts, absent with facts+flag; qs64 keeps exactly 1 UDF =
the retained INT64_MIN/-1 guard; Sat-decline byte-identity; debug-only
SYNTH_FACT_SPEC_FORCE_ADMIT red lever screams);
fact_spec_div_494_differential.py (1584-case in-bounds sweep specialized ≡
wasmtime ≡ unspecialized; qs64(INT64_MIN,-1) traps in BOTH wasmtime and
the specialized build; --expect-decline byte-identity; --force-admit RED
leg: wasmtime traps at divisor=0, the forced unsound build returns 0) —
all CI-gated by the extended fact-spec-oracle job.

Fixture bytes (cortex-m4): qu 16→12, qs 36→12 (both guards proven dead),
ru 20→16, qs64 214→206 (zero guard spliced, 22 B overflow guard retained);
.text 446→406. Frozen anchors bit-identical (no fixture carries facts);
flag default OFF.

Closes nothing yet — #494 phase 3 (gale measurement) remains. Refs #494,
#242, #633, #634.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
avrabe added a commit that referenced this pull request Jul 10, 2026
….0xFFF (#681) (#690)

encode_thumb32_add_imm packed the RAW immediate into the T3 ADD.W
i:imm3:imm8 field, which is a ThumbExpandImm MODIFIED immediate —
correct only for imm <= 0xFF. ThumbExpandImm(0x200) = 0 and
ThumbExpandImm(0x400) = 0x8000_0000, so every dynamic-address
load/store with a static offset in [256, 4095] silently computed a
WRONG address. In --safety-bounds software the guard (correct T4 ADDW)
checked the intended address while the access used the mis-encoded one
— a bounds-check bypass. #253/#255 ThumbExpandImm class, reached via
the #382 paths.

Fix: imm <= 0xFFF delegates to encode_thumb32_add, which already picks
T3 (<= 0xFF, raw == expanded, bit-identical) vs ADDW T4 plain imm12
(0x100..=0xFFF) per #253. The > 0xFFF MOVW/MOVT path is unchanged, so
byte sizes are unchanged (estimator agreement #511 stays green).

Class audit (no wildcard survives, #634 style):
- ArmOp::Rsb (Thumb T2): field is ThumbExpandImm-coded with NO plain
  imm12 form — now gated on try_thumb_expand_imm, Err on
  non-representable. All emitters use imm 32 (byte-identical).
- ArmOp::Rsb (A32): imm was silently masked & 0xFF (#378 class) — now
  Err for imm > 0xFF.
- encode_thumb32_and_imm_raw: raw-packed ThumbExpandImm field — now
  gated; only caller (POPCNT, #0x3F) byte-identical.
- encode_thumb32_sub/adds/subs/cmp already correct (T4 / expand-gated).

Oracles:
- test_encode_add_imm_thumb_expand_681: clang -target thumbv7m pinned
  bit-for-bit (0xFF/0x100/0x104/0x200/0x3FC/0x400/0xFFF + rd/rn perm).
- test_encode_add_imm_large_350's 0x123 assertion upgraded from
  length-only (which let the mis-encoding pass CI) to exact bytes.
- scripts/repro/addw_offset_681_differential.py: unicorn-vs-wasmtime,
  dynamic base + static offsets, i32/i8/i16/i64 load+store, bounds
  none+software incl. the bypass pin and OOB trap-to-trap. RED on
  pre-fix main (36 mismatches: clobber returns 4660 not 111; offset
  1024 faults 2 GiB past base), GREEN post-fix (49/49). CI-wired in
  the trap-semantics oracle job.
- Frozen anchors 10/10 byte-identical; estimator agreement green;
  workspace tests, fmt, clippy -D warnings clean.

Closes #681

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant