Skip to content

fix(trap-semantics): unreachable traps on every backend (#665); rv32 rem_s drops the spurious INT_MIN/-1 guard (#666) - #668

Merged
avrabe merged 1 commit into
mainfrom
fix/665-666-trap-semantics
Jul 8, 2026
Merged

fix(trap-semantics): unreachable traps on every backend (#665); rv32 rem_s drops the spurious INT_MIN/-1 guard (#666)#668
avrabe merged 1 commit into
mainfrom
fix/665-666-trap-semantics

Conversation

@avrabe

@avrabe avrabe commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Summary

One lane, two trap-semantics bugs — same theme, disjoint files.

#665unreachable compiled to a NO-OP on thumb-2 AND rv32

Root cause was ONE decode drop, not per-backend misses: convert_operator (synth-core wasm_decoder.rs) returned None for Unreachable and is_intentionally_ignored whitelisted it alongside Nop, so no backend ever received the op. The selector trap arms (ARM UDF #0 in both select_with_stack and select_default, RV32 ebreak) already existed as dead code.

Per-path status after the fix:

path before after
decoder dropped as "intentional no-op" decodes to WasmOp::Unreachable
ARM direct (select_with_stack) never received op existing UDF #0 arm fires
ARM select_default never received op existing UDF #0 arm fires
ARM optimized (bridge) lumped with Nop → IR placeholder typed loud-DECLINE → direct selector emits UDF #0 (same pattern as #120 floats / #500 non-tail return; no new Opcode through the #513 mirror-pinned reg_effect/rewrite_op machinery, no new ArmOp so the #511 estimator oracle is untouched — Udf was already covered by the div-zero guards)
RV32 never received op existing ebreak arm fires
aarch64 loud-decline (unknown op) new brk #0 encoder + selector arm

Known gap kept visible (contract-compliant, never falls through): rv32 loud-declines the if/else-with-result shape whose else arm is pure unreachable (#343 arity check) — noted in the oracle.

#666 — rv32 i32.rem_s(INT_MIN,-1) spuriously trapped

The selector shared div_s's INT_MIN/-1 overflow ebreak guard with rem_s via bin_with_signed_div_traps. WASM §4.3.2: irem_s(INT_MIN,-1) = 0, NO trap — only idiv_s traps there. RISC-V M-ext rem already returns 0 for the overflow case (unprivileged spec §7.2), so rem_s now takes plain bin_with_zero_trap: zero-divisor guard KEPT, bare rem is exactly wasm-correct. Disasm-verified: rems = 1 ebreak, divs = 2 ebreaks.

The pre-existing test rv32_signed_rem_also_gets_overflow_guard pinned the bug; rewritten as the #633-twin fix-guard pins (rv32_signed_rem_carries_only_zero_guard_666 + rv32_signed_div_still_carries_both_guards_666), mirroring ARM's test_633_i64_rems_has_no_overflow_guard.

Oracles — red on origin/main, green here (new CI job trap-semantics-oracle)

scripts/repro/unreachable_665_differential.py (thumb2 + rv32 under unicorn vs wasmtime):

  • red on main: boom(7,9) "returned" 7 (arg fall-through) on BOTH ISAs; guarded(-5) returned 0; guarded_br(-5) fell through — 5 FAILs
  • green here: all trap cases fault like wasmtime; non-vacuity guarded(5)/guarded_br(5) return 1 normally

scripts/repro/rem_s_666_differential.py (rv32 trap table):

  • red on main: rems(INT_MIN,-1) spurious TRAP (wasmtime: 0)
  • green here: rems(INT_MIN,-1)→0, rems(INT_MIN,1)→0, rems(7,3)→1, rems(-7,3)→-1, rems(7,0) traps, divs(INT_MIN,-1) traps, divs(7,0) traps, divs(7,3)→2 — 8/8

Gates

  • Frozen anchors 10/10 bit-identical (verified no frozen fixture — control_step / flight_seam / flight_seam_flat / signed_div_const — contains unreachable)
  • cargo test --workspace: 108/108 suites green
  • cargo fmt --check + cargo clippy --workspace --all-targets -- -D warnings clean

Fixes #665
Fixes #666

🤖 Generated with Claude Code

…rem_s drops the spurious INT_MIN/-1 guard (#666)

#665 — wasm `unreachable` compiled to a NO-OP on thumb-2 AND rv32 (falls
through instead of trapping, WASM Core §4.4.5). Root cause was ONE decode
drop: `convert_operator` returned `None` for `Unreachable` and
`is_intentionally_ignored` whitelisted it alongside `Nop`, so no backend
ever received the op — the selector trap arms (ARM `UDF #0`, RV32 `ebreak`)
already existed but were dead code. Fix per path:

- decoder (synth-core): `Unreachable` now decodes to `WasmOp::Unreachable`;
  only `Nop` stays intentionally ignorable.
- ARM direct (`select_with_stack`) + `select_default`: existing `UDF #0`
  arms now fire (no change needed).
- ARM optimized path (optimizer_bridge): previously lumped `Unreachable`
  in with Nop as an IR placeholder — now a typed loud-DECLINE to the direct
  selector (the bridge `Opcode` enum has no trap opcode; adding one would
  ripple through the #513 mirror-pinned reg_effect/rewrite_op machinery).
  Same decline-don't-drop pattern as #120 floats / #500 non-tail return.
  No new ArmOp on the optimized path, so the #511 estimator oracle is
  untouched (Udf was already covered by the div-zero guards).
- RV32: existing `ebreak` arm now fires (no change needed).
- aarch64: new `brk #0` encoder + selector arm (was a loud-decline).

#666 — rv32 `i32.rem_s(INT_MIN,-1)` spuriously trapped: the selector shared
div_s's INT_MIN/-1 overflow `ebreak` guard with rem_s via
`bin_with_signed_div_traps`. WASM §4.3.2 defines irem_s(INT_MIN,-1) = 0
with NO trap, and RISC-V M-ext `rem` already returns 0 for the overflow
case (unprivileged spec §7.2), so rem_s now takes plain
`bin_with_zero_trap` — zero-divisor guard KEPT, bare `rem` is exactly
wasm-correct. The pre-existing test `rv32_signed_rem_also_gets_overflow_guard`
pinned the BUG; it is rewritten as the #633-twin fix-guard pins
(`rv32_signed_rem_carries_only_zero_guard_666` +
`rv32_signed_div_still_carries_both_guards_666`), mirroring ARM's
`test_633_i64_rems_has_no_overflow_guard` and the existing i64 RV32 pin.

Oracles (red on origin/main, green here; CI job trap-semantics-oracle):
- scripts/repro/unreachable_665_differential.py — thumb2 + rv32 under
  unicorn vs wasmtime: bare `unreachable` traps, guarded `unreachable`
  taken traps, NOT taken returns normally (non-vacuity). Red on main:
  boom(7,9) "returned" 7 (arg fall-through) on both ISAs. Known gap kept
  visible: rv32 loud-declines the if/else-result-with-unreachable shape
  (#343 arity check) — contract-compliant (never falls through).
- scripts/repro/rem_s_666_differential.py — rv32 trap table:
  rems(INT_MIN,-1)→0 no-trap (red on main: spurious ebreak),
  rems(INT_MIN,1)→0, rems(7,3)→1, rems(-7,3)→-1, rems(7,0) traps,
  divs(INT_MIN,-1) traps, divs(7,0) traps, divs(7,3)→2.

Frozen anchors 10/10 bit-identical (no fixture contains `unreachable` —
verified control_step/flight_seam/flight_seam_flat/signed_div_const).
Workspace tests 108/108 suites green; fmt + clippy -D warnings clean.

Fixes #665
Fixes #666

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

codecov Bot commented Jul 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.50000% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/synth-backend-aarch64/src/encoder.rs 0.00% 3 Missing ⚠️
crates/synth-backend-aarch64/src/selector.rs 0.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@avrabe
avrabe merged commit e4ceef6 into main Jul 8, 2026
33 checks passed
@avrabe
avrabe deleted the fix/665-666-trap-semantics branch July 8, 2026 21:40
avrabe added a commit that referenced this pull request Jul 8, 2026
…ed rules, 81 bridge Qed (#673)

* chore(release): v0.36.0 — unreachable traps + sparse tables + 40 rules + 81 bridge Qed (#668/#669/#670/#671)

Pin sweep 0.35.0 -> 0.36.0 + CHANGELOG.

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

* chore: fold #672 (post-exhaustion quality) into the v0.36.0 changelog — merged ahead of the tag

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

---------

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