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>
avrabe
force-pushed
the
fix/642-call-indirect-guards
branch
from
July 8, 2026 12:45
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 was referenced Jul 8, 2026
avrabe
added a commit
that referenced
this pull request
Jul 10, 2026
…the type-id sidecar (#676) A HETEROGENEOUS funcref table (mixed signatures — falcon's fused 41-slot dispatch table) can never satisfy the closed-world type check, so every call_indirect through it loud-declined (20 falcon funcs). WASM Core §4.4.8 makes the mismatch a RUNTIME trap, so the sound lowering is the runtime check itself: the object now carries a type-id sidecar (.synth.table_type_ids — one LE u32 STRUCTURAL signature class id per slot, region order; structurally-equal types share one dense 1-based id, the meld 31-decls/25-distinct shape; id 0 reserved for null slots) which the extended R11 layout contract places at R11 + sum(all table sizes)*4, mirroring the pointer region slot for slot. The dispatch inserts, between the #642 bounds guard and the pointer load, on both Thumb-2 and A32: mov ip, idx, lsl #2 ; add ip, r11, ip ; ldr ip, [ip, #type_off] cmp ip, #expected_class_id ; beq ok ; udf The compare subsumes the #664 null trap (id 0 never equals an expected id >= 1), so heterogeneous dispatches emit null_check=false. Encoding ranges decline loudly (sidecar offset > LDR imm12, class id > 255). Homogeneous tables emit type_check=None + no sidecar section — bytes identical BY CONSTRUCTION (the #650 offset-0 / #664 null_check=false trick): whole-ELF cmp verified against origin/main on the #642/#650/#664 fixtures x cortex-m3/r5, frozen anchors 10/10, workspace green. The estimator is untouched (CallIndirect is direct-selector-only, excluded from the #511 agreement oracle). New CI-gated differential (call_indirect_676_differential.py, Thumb-2 + A32): mixed 5-slot table (two classes interleaved + structural-dup type + nulls) — matching-class calls equal wasmtime, wrong-class ("indirect call type mismatch"), null and OOB indices all stop at a UDF; wasmtime's trap REASONS are asserted per category. Non-vacuous red: a build without the check CALLS the wrong-typed function and returns a wrong value. Red at compile on origin/main (capability upgrade). Object-level contract locked in cargo CI (heterogeneous_table_676.rs: sidecar ids [1,2,1,0,0] + no-sidecar for the homogeneous fixtures). Lineage: #642 guards (#646), #650 multi-table (#653), #664 null slots (#669) — this closes the terminal layer of falcon's call_indirect story. Closes #676 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
avrabe
added a commit
that referenced
this pull request
Jul 10, 2026
…the type-id sidecar (#676) A HETEROGENEOUS funcref table (mixed signatures — falcon's fused 41-slot dispatch table) can never satisfy the closed-world type check, so every call_indirect through it loud-declined (20 falcon funcs). WASM Core §4.4.8 makes the mismatch a RUNTIME trap, so the sound lowering is the runtime check itself: the object now carries a type-id sidecar (.synth.table_type_ids — one LE u32 STRUCTURAL signature class id per slot, region order; structurally-equal types share one dense 1-based id, the meld 31-decls/25-distinct shape; id 0 reserved for null slots) which the extended R11 layout contract places at R11 + sum(all table sizes)*4, mirroring the pointer region slot for slot. The dispatch inserts, between the #642 bounds guard and the pointer load, on both Thumb-2 and A32: mov ip, idx, lsl #2 ; add ip, r11, ip ; ldr ip, [ip, #type_off] cmp ip, #expected_class_id ; beq ok ; udf The compare subsumes the #664 null trap (id 0 never equals an expected id >= 1), so heterogeneous dispatches emit null_check=false. Encoding ranges decline loudly (sidecar offset > LDR imm12, class id > 255). Homogeneous tables emit type_check=None + no sidecar section — bytes identical BY CONSTRUCTION (the #650 offset-0 / #664 null_check=false trick): whole-ELF cmp verified against origin/main on the #642/#650/#664 fixtures x cortex-m3/r5, frozen anchors 10/10, workspace green. The estimator is untouched (CallIndirect is direct-selector-only, excluded from the #511 agreement oracle). New CI-gated differential (call_indirect_676_differential.py, Thumb-2 + A32): mixed 5-slot table (two classes interleaved + structural-dup type + nulls) — matching-class calls equal wasmtime, wrong-class ("indirect call type mismatch"), null and OOB indices all stop at a UDF; wasmtime's trap REASONS are asserted per category. Non-vacuous red: a build without the check CALLS the wrong-typed function and returns a wrong value. Red at compile on origin/main (capability upgrade). Object-level contract locked in cargo CI (heterogeneous_table_676.rs: sidecar ids [1,2,1,0,0] + no-sidecar for the homogeneous fixtures). Lineage: #642 guards (#646), #650 multi-table (#653), #664 null slots (#669) — this closes the terminal layer of falcon's call_indirect story. Closes #676 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
avrabe
added a commit
that referenced
this pull request
Jul 10, 2026
…the type-id sidecar (#676) (#696) A HETEROGENEOUS funcref table (mixed signatures — falcon's fused 41-slot dispatch table) can never satisfy the closed-world type check, so every call_indirect through it loud-declined (20 falcon funcs). WASM Core §4.4.8 makes the mismatch a RUNTIME trap, so the sound lowering is the runtime check itself: the object now carries a type-id sidecar (.synth.table_type_ids — one LE u32 STRUCTURAL signature class id per slot, region order; structurally-equal types share one dense 1-based id, the meld 31-decls/25-distinct shape; id 0 reserved for null slots) which the extended R11 layout contract places at R11 + sum(all table sizes)*4, mirroring the pointer region slot for slot. The dispatch inserts, between the #642 bounds guard and the pointer load, on both Thumb-2 and A32: mov ip, idx, lsl #2 ; add ip, r11, ip ; ldr ip, [ip, #type_off] cmp ip, #expected_class_id ; beq ok ; udf The compare subsumes the #664 null trap (id 0 never equals an expected id >= 1), so heterogeneous dispatches emit null_check=false. Encoding ranges decline loudly (sidecar offset > LDR imm12, class id > 255). Homogeneous tables emit type_check=None + no sidecar section — bytes identical BY CONSTRUCTION (the #650 offset-0 / #664 null_check=false trick): whole-ELF cmp verified against origin/main on the #642/#650/#664 fixtures x cortex-m3/r5, frozen anchors 10/10, workspace green. The estimator is untouched (CallIndirect is direct-selector-only, excluded from the #511 agreement oracle). New CI-gated differential (call_indirect_676_differential.py, Thumb-2 + A32): mixed 5-slot table (two classes interleaved + structural-dup type + nulls) — matching-class calls equal wasmtime, wrong-class ("indirect call type mismatch"), null and OOB indices all stop at a UDF; wasmtime's trap REASONS are asserted per category. Non-vacuous red: a build without the check CALLS the wrong-typed function and returns a wrong value. Red at compile on origin/main (capability upgrade). Object-level contract locked in cargo CI (heterogeneous_table_676.rs: sidecar ids [1,2,1,0,0] + no-sidecar for the homogeneous fixtures). Lineage: #642 guards (#646), #650 multi-table (#653), #664 null slots (#669) — this closes the terminal layer of falcon's call_indirect story. Closes #676 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