fix(codegen): call_indirect bounds guard + compile-time type check — OOB/wrong-type traps per WASM §4.4.8 (#642)#646
Merged
Conversation
Codecov Report❌ Patch coverage is 📢 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>
27a2e81 to
2fc48b1
Compare
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #642.
The hole
The Thumb-2 (and A32, #596)
call_indirectexpansion was three unguarded instructions off a runtime index: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:
Where does the table size live at runtime? Nowhere — the table is a raw array of 4-byte code pointers linked at
r11by 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.setare 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 againstinitialon 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 typet: every slot in[0, table_size)is initialized by a const-offset active element segment with a plainref.func, and every entry's signature structurally equalstypes[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_guardsdefaults to decline (no module context ⇒ nocall_indirect),call_indirectexplicitly (it previously fell throughwasm_to_ir's_ => Opcode::Nop),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 jobcall-indirect-642-oracle): unicorn vs wasmtime on both ISAs, table atr11, 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:
this branch, 12/12 green:
Both-ISA status
test_encode_thumb_call_indirect_lsl2_597+ new_guard_shapes_642covering the high-reg CMP T2 form and the >16-bit MOVT arm).test_encode_arm32_call_indirect_is_real_call_594updated + new wide-size pin; the call_indirect on the A32 path (--target cortex-r5) compiles to a NOP — silently returns wrong result instead of calling the target #594 A32 differential stays green (now executes the guard in-bounds).call_indirectlowering exists (falls toUnsupported→ loud-skip) — no shared gap.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_codegen_bytes10/10 untouched (fixtures carry nocall_indirect)estimator_encoder_agreement(test(vcr-oracle): estimator↔encoder agreement oracle for the optimized path (#498, #242) #511): green —CallIndirectis a direct-selector-only op, already in the oracle's not-on-optimized-path exclusion list, and the direct path'sresolve_label_branchessizes by actual encoding, so the bigger expansion is measured, not estimated#594/#597differentials: green on the fixed buildcargo fmt --check+cargo clippy --workspace --all-targets -- -D warnings: clean🤖 Generated with Claude Code