Skip to content

fix(codegen): call_indirect bounds guard + compile-time type check — OOB/wrong-type traps per WASM §4.4.8 (#642)#646

Merged
avrabe merged 1 commit into
mainfrom
fix/642-call-indirect-guards
Jul 8, 2026
Merged

fix(codegen): call_indirect bounds guard + compile-time type check — OOB/wrong-type traps per WASM §4.4.8 (#642)#646
avrabe merged 1 commit into
mainfrom
fix/642-call-indirect-guards

Conversation

@avrabe

@avrabe avrabe commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Fixes #642.

The hole

The Thumb-2 (and A32, #596) call_indirect expansion was three unguarded instructions off a runtime index:

lsl.w  ip, idx, #2
ldr.w  ip, [r11, ip]
blx    ip

No cmp/bounds branch, no type comparison — an out-of-bounds or wrong-typed index performed an uncontrolled indirect branch where WASM Core §4.4.8 mandates a deterministic trap. In-bounds dispatch was correct (the #594/#597 arc only ever fixed indexing), which is exactly why index-valid probes never saw it.

Guard design

Bounds check — runtime, both ISAs. The expansion now prepends:

movw ip, #table_size   ; (+ movt when size > 16 bits)
cmp  idx, ip
blo  +1                ; skip the trap when in bounds
udf  #0                ; §4.4.8 OOB trap (same idiom as the div-by-zero guards)

Where does the table size live at runtime? Nowhere — the table is a raw array of 4-byte code pointers linked at r11 by the runtime/harness, with no size field. The size is therefore a compile-time immediate, and it is sound and exact for a defined table: table.grow/table.set are unsupported ops whose functions loud-skip at decode, so nothing synth compiles can resize (or retype) the table. An imported table only yields a bound when its limits pin the size (max == initial); otherwise the lowering declines loudly (a guard against initial on a growable import would trap spec-valid calls).

Type check — discharged at compile time (closed world). The raw code-pointer table stores no type ids, so a runtime tag compare is not implementable with the current layout. Instead DecodedModule::call_indirect_guards() verifies, per expected type t: every slot in [0, table_size) is initialized by a const-offset active element segment with a plain ref.func, and every entry's signature structurally equals types[t] (signature comparison, not index comparison — duplicate types stay interchangeable). Under that property no runtime mismatch is possible; the check compiles away. Any hole — an uninitialized (null-funcref) slot, a heterogeneous entry, a passive/computed segment, a non-zero table index — records a reason and the lowering declines loudly with it. The unchecked branch is never left behind:

  • CompileConfig::call_indirect_guards defaults to decline (no module context ⇒ no call_indirect),
  • the optimized path now declines call_indirect explicitly (it previously fell through wasm_to_ir's _ => Opcode::Nop),
  • both direct selectors (select_with_stack + select_default) gate on the verdicts.

This is the (b) strategy from the issue triage: dissolved images have homogeneous fully-covered tables (the repro verifies), and extending table emission with type ids is moot while synth doesn't emit the table at all.

Red → green evidence

scripts/repro/call_indirect_642_differential.py (new CI job call-indirect-642-oracle): unicorn vs wasmtime on both ISAs, table at r11, and — non-vacuity — the words past the 3-entry table seeded with a valid decoy function (movs r0, #0x5A; bx lr), so an unguarded build doesn't fault, it calls the decoy.

origin/main (a1541c0), both ISAs red:

[Thumb-2] f(5,3)  = 0x5a — the DECOY past the table was CALLED: uncontrolled indirect branch, no bounds guard (#642) MISMATCH
[Thumb-2] f(5,5)  = 0x5a — … MISMATCH
[Thumb-2] f(5,99) = 0x5a — … MISMATCH
[A32]     f(5,3)  = 0x5a — … MISMATCH   (+5, 99)
ORACLE: FAIL — 6 case(s) diverged from wasmtime (#642)

this branch, 12/12 green:

[Thumb-2] f(5,0)=0xf f(5,1)=0xfffffffb f(5,2)=0x32  (= wasmtime)
[Thumb-2] f(5,3)/f(5,5)/f(5,99) = udf trap (wasmtime: trap) OK
[A32]     same 6/6 OK
ORACLE: PASS

Both-ISA status

Gates

  • cargo test --workspace: 2012 passed, 0 failed (was 2004; +5 decoder closed-world tests, +3 selector decline/emit tests, +2 encoder pins, net of split assertions)
  • Frozen anchors: frozen_codegen_bytes 10/10 untouched (fixtures carry no call_indirect)
  • estimator_encoder_agreement (test(vcr-oracle): estimator↔encoder agreement oracle for the optimized path (#498, #242) #511): green — CallIndirect is a direct-selector-only op, already in the oracle's not-on-optimized-path exclusion list, and the direct path's resolve_label_branches sizes by actual encoding, so the bigger expansion is measured, not estimated
  • #594/#597 differentials: green on the fixed build
  • cargo fmt --check + cargo clippy --workspace --all-targets -- -D warnings: clean

🤖 Generated with Claude Code

@codecov

codecov Bot commented Jul 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.65323% with 91 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/synth-core/src/wasm_decoder.rs 79.42% 50 Missing ⚠️
crates/synth-synthesis/src/instruction_selector.rs 72.35% 34 Missing ⚠️
crates/synth-synthesis/src/optimizer_bridge.rs 44.44% 5 Missing ⚠️
crates/synth-backend/src/arm_encoder.rs 98.05% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

…eck — OOB/wrong-type traps per WASM §4.4.8 (#642)

The Thumb-2 (and #596-added A32) call_indirect expansion had NO table
bounds check and NO type check: `lsl.w ip, idx, #2; ldr.w ip, [r11, ip];
blx ip` on a runtime index — an out-of-bounds or wrong-typed index
performed an uncontrolled indirect branch instead of the WASM Core
§4.4.8 trap.

Bounds check (runtime, both ISAs): the expansion now prepends
`movw ip, #size [; movt] ; cmp idx, ip ; blo +1 ; udf #0` — the same
UDF trap idiom as the div-by-zero guards. The size is a compile-time
immediate because the raw code-pointer table (linked at r11) has no
runtime size field, and it is EXACT for a defined table: table.grow/
table.set are unsupported ops whose functions loud-skip at decode, so
nothing synth compiles can resize the table. An imported table only
yields a bound when its limits pin it (max == initial); otherwise the
lowering declines.

Type check (compile time): the table carries raw code pointers — no
runtime type ids to compare — so the §4.4.8 check is discharged by a
closed-world verification in the decoder (DecodedModule::
call_indirect_guards): expected type t is VERIFIED only when every
slot in [0, table_size) is initialized by a const-offset active elem
segment with a plain ref.func whose signature STRUCTURALLY equals
types[t] (duplicate types stay interchangeable). Any hole —
uninitialized (null) slot, heterogeneous entry, passive/computed
segment, non-zero table index — records a reason and the lowering
declines LOUDLY with it. An unchecked indirect branch is never
emitted: the CompileConfig default declines, the optimized path now
declines call_indirect explicitly (it previously fell through
wasm_to_ir's `_ => Opcode::Nop`), and both selectors gate on the
verdicts.

Oracle: scripts/repro/call_indirect_642_differential.py (CI job
call-indirect-642-oracle) — unicorn vs wasmtime on BOTH ISAs; in-bounds
indices match, OOB indices must stop AT A UDF. Non-vacuous red: words
past the table are seeded with a valid decoy function, so an unguarded
build "succeeds" at the OOB call and fails the harness on the decoy's
return value. Red on origin/main (6/6 OOB cases call the decoy on both
ISAs), green with the fix (12/12). #594/#597 differentials stay green.
Frozen anchors 10/10 untouched; estimator agreement oracle untouched
(CallIndirect is a direct-selector-only op, in the oracle's
not-on-optimized-path exclusion).

Closes #642

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@avrabe avrabe force-pushed the fix/642-call-indirect-guards branch from 27a2e81 to 2fc48b1 Compare July 8, 2026 12:45
@avrabe avrabe merged commit f3241c8 into main Jul 8, 2026
31 of 32 checks passed
@avrabe avrabe deleted the fix/642-call-indirect-guards branch July 8, 2026 13:26
avrabe added a commit that referenced this pull request Jul 8, 2026
…, #645/#646) (#647)

Pin sweep 0.33.0 -> 0.33.1 + CHANGELOG.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
avrabe added a commit that referenced this pull request Jul 8, 2026
#650) (#653)

Tables become ONE contiguous region of raw 4-byte code pointers at R11,
in declaration order: table 0 at R11+0 (unchanged), table N at
R11 + sum(size(0..N))*4 — a compile-time constant, since tables are
provably fixed-size (#642: table.grow/table.set loud-skip at decode).

- decoder: per-table sizes (table_sizes), per-segment table attribution
  (ElemSegmentInfo.table_index), CallIndirectGuards restructured to
  per-table TableGuards { table_size, base_byte_offset, type_reject } —
  the #646 closed-world type verification now runs per (table, type),
  and an unverifiable segment poisons only the table it targets
  (passive/declared/non-const-offset segments still poison all).
- selector: shared resolve_call_indirect_guards() feeds both arms; the
  bounds guard compares against the DISPATCHED table's own size; a
  table past the linked region, an unknown size, an unknown base
  (preceding growable import), or a base past LDR imm12 (4095) each
  loud-decline with a named reason.
- encoder (Thumb-2 + A32): non-zero base folds into the pointer load
  (add ip, r11, ip; ldr ip, [ip, #off]); offset 0 emits the exact
  pre-#650 bytes, so single-table modules are byte-identical BY
  CONSTRUCTION (verified: whole-ELF identity vs origin/main on the
  #642/#594/#597 fixtures, cortex-m3/-r5/-m7dp, ±relocatable; frozen
  anchors 10/10).
- oracle: call_indirect_650_differential.py (CI-gated) — two tables,
  overlapping indices, aliasing canary (table0[1] != table1[1]), OOB
  traps per-table, both ISAs; 26/26 green here, red on <= v0.33.1
  (compile-time decline — this is a capability upgrade).

Unblocks falcon's multi-table fused components (20 of 146 functions
dispatched through table 1).

Fixes #650. Builds on #646 (#642 guards) and the #275 R11 arc.

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