From 09482e8bf30cd21cd2da06e245c2c2247a5c60f2 Mon Sep 17 00:00:00 2001 From: "Shota Kudo (chaploud)" Date: Sat, 18 Jul 2026 04:27:17 +0900 Subject: [PATCH] fix(dce): comptime-guard GC/subtyping JIT helper bodies so sub-v3 builds DCE feature.gc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check_build_dce` FAILed on `main` (all six `-Dwasm=v1_0` / `-Dwasm=v2_0` rows): `instruction.wasm_3_0.ref_test_ops.gcRefMatchesNonNullCore` was present in every non-GC binary. Root cause: ADR-0203 D1 (D-516 position-independence) holds the GC / subtyping JIT helpers as `JitRuntime` fn-pointer FIELDS whose DEFAULT is the real helper (so a reloaded `.cwasm` resolves the address in the running process — no setup wiring). A field default `= jitGcRefTest` takes the helper's ADDRESS unconditionally, and `JitRuntime` is instantiated in every build, so the whole GC cohort (`jitGcAlloc` / `jitGcArray*` / `jitGcRefTest` / `jitGcRefCast` / `jitCallIndirectResolve`) — and its `feature/gc/*` reach — stayed live even in a v1_0 build. Only the one symbol whose namespace path contains `wasm_3_0` tripped the `nm`-grep gate, but the entire cohort leaked. The emit sites read the helper via `@offsetOf` (an offset, not an address) and are comptime-fenced behind the wasm_3_0 op dispatch, so they DCE fine — the struct field default is the only always-live address-take. Fix: guard each helper body with `if (comptime !wasm_v3_plus) @panic(...)` as the first statement. In a sub-v3 build the comptime-false remainder is un-analysed, so the default points at a bare `@panic` stub with no GC references and the GC code DCEs. v3 builds are unaffected (guard is `if (comptime false)` → real body runs; ADR-0203 D1 intact). A sub-v3 build can never emit a GC / subtyping op (the validator rejects them), so the stub is unreachable — `@panic` is the correct default (mirrors `defaultReifyExnref`). Verified: v1_0 + v2_0 forbidden-symbol scan CLEAN on both arm64-native and x86_64-linux cross (the CI leg's arch); v1_0:p1 `.text` also drops 2957749 → 2614800 B as the leaked GC code is reclaimed. `zig build test` + lint green. The regression shipped 2026-07-09 (ADR-0203) but surfaced only now: `check_build_dce` runs in the push-to-main extended leg and the intervening main pushes were doc-only (heavy legs skipped) or concurrency-cancelled, so the gate never completed until #149. Lesson: 2026-07-18-fnptr-field-default-defeats-build-option-dce. --- ...-field-default-defeats-build-option-dce.md | 59 +++++++++++++++++++ .dev/lessons/INDEX.md | 1 + src/engine/codegen/shared/jit_abi.zig | 26 ++++++++ 3 files changed, 86 insertions(+) create mode 100644 .dev/lessons/2026-07-18-fnptr-field-default-defeats-build-option-dce.md diff --git a/.dev/lessons/2026-07-18-fnptr-field-default-defeats-build-option-dce.md b/.dev/lessons/2026-07-18-fnptr-field-default-defeats-build-option-dce.md new file mode 100644 index 000000000..37dcf8a50 --- /dev/null +++ b/.dev/lessons/2026-07-18-fnptr-field-default-defeats-build-option-dce.md @@ -0,0 +1,59 @@ +# A fn-pointer struct-field DEFAULT takes the helper's address unconditionally — defeating build-option DCE + +- **Date**: 2026-07-18 +- **Area**: engine/codegen/shared/jit_abi.zig; build-option DCE (ADR-0073 / ADR-0203 D1) +- **Trigger**: `main` CI `check_build_dce` FAIL — `instruction.wasm_3_0.ref_test_ops.gcRefMatchesNonNullCore` + present in every `-Dwasm=v1_0` and `-Dwasm=v2_0` binary (all 6 rows). + +## Observation + +ADR-0203 D1 (D-516, position-independence) added GC / subtyping JIT +helpers as `JitRuntime` fn-pointer FIELDS with the real helper as the +field DEFAULT (so a reloaded `.cwasm` resolves the address in the +running process — no setup wiring): + +```zig +gc_ref_test_fn: *const fn (...) callconv(.c) u32 = jitGcRefTest, +``` + +A field default `= jitGcRefTest` takes the helper's ADDRESS, and +`JitRuntime` is instantiated in every build. So `jitGcRefTest` (and its +callee `gcRefMatchesNonNullCore`, plus the whole `feature/gc/*` reach of +`jitGcAlloc`/`jitGcArray*`/`jitCallIndirectResolve`) stayed live even in a +v1_0 build. Only the one symbol whose namespace path contains `wasm_3_0` +tripped `check_build_dce`'s `nm`-grep, but the entire GC cohort leaked. + +The regression shipped 2026-07-09 (ADR-0203) yet surfaced only 2026-07-17: +`check_build_dce` runs in the push-to-main EXTENDED leg, and every +intervening main push was doc-only (heavy legs auto-skipped) or +concurrency-cancelled — so the gate never actually completed until #149. + +## Why (re-derivable) + +The emit sites read the helper via `@offsetOf(JitRuntime, "..._fn")` +(`call_indirect_resolve_fn_off`) — an OFFSET, not an address — and are +themselves comptime-fenced behind the wasm_3_0 op dispatch, so they DCE +fine. The *only* always-live address-taker is the struct field default. +`if (comptime false) { ... }` blocks are never analysed for codegen, so a +comptime build-level guard as the first statement makes the helper a bare +`@panic` stub in sub-v3 builds — its GC references vanish and DCE reclaims +them. v3 builds are unaffected (`wasm_v3_plus` comptime-true → guard is +`if (comptime false)` → real body runs; ADR-0203 D1 intact). Sub-v3 builds +can never emit a GC / subtyping op (the validator rejects them), so the +stub is unreachable — `@panic` is the correct default (mirrors +`defaultReifyExnref`). + +## Rule + +- Before giving a `*const fn` struct FIELD a real-helper DEFAULT, check + whether that helper (transitively) reaches build-option-gated code + (`feature/gc/*`, `instruction/wasm_3_0/*`, SIMD, WASI-P2/P3). If so, the + default is an unconditional address-take that defeats DCE — guard the + helper body with `if (comptime !wasm_v3_plus) @panic(...)` (or the + matching feature axis) so the sub-level default is a reference-free stub. +- A pattern-based DCE gate (`nm | grep`) only flags the callee whose SYMBOL + NAME matches; the real leak can be a whole cohort. After a fix, `nm`-grep + the WHOLE `feature/*` reach, not just the flagged pattern. +- A gate that runs only on push-to-main extended can stay silently broken + across a run of doc-only / cancelled pushes. `[x]`-flip and doc PRs are + exactly when a codegen regression hides. diff --git a/.dev/lessons/INDEX.md b/.dev/lessons/INDEX.md index e58da3f79..d42b3b493 100644 --- a/.dev/lessons/INDEX.md +++ b/.dev/lessons/INDEX.md @@ -236,3 +236,4 @@ supersedes it). Update this INDEX accordingly. | 2026-06-21 | d489-static-exhausted-run1-select-region | D-489, fork disassembled IsNil(65) clean no spill alias globals-base reload correct gateway not miscompile, run$1(136) 8253 instr coroutine-transform not statically tractable divergence is taken-vs-not-taken branch not corrupted memory, exact bail region run$1 wasm 1539-1551 nested scalar select(global.get 1, local 1, cond) -> tee -> select -> if gates reflectValue + coroutine rewind br_if global1==1, global1=task state global2=shadow-stack-ptr, emitSelectCtx op_alu_int.zig:1093 inspected structurally correct cond/val1/result stage0 R10 val2 stage1 R11 cond dead after TEST flags survive loads no alias, select probably NOT bug, remaining suspects global.get 1 codegen OR br_if rewind-check condition under deep spill, static+profiling EXHAUSTED 3 synthetic fixtures + mv2 failed to reproduce bound to real deep frame, step7 instruction-level dynamic value trace func 136 only interp-vs-x86_64-jit dump per-ZIR-op operand-stack values first divergent=bug OR gdb ubuntu native x86_64 | D-489 static analysis exhausted. Fork: IsNil(65) clean (gateway not bug). run$1(136)=8253-instr coroutine fn, untractable by eyeball; divergence is a taken-vs-not-taken branch. Exact bail region pinned: run$1 wasm 1539-1551 (nested scalar select(global.get1,local1,cond)→if gating reflectValue + coroutine-rewind br_if global1==1). emitSelectCtx inspected structurally correct (no stage alias) → suspects narrow to global.get 1 codegen or br_if-rewind condition under deep spill. 3 synthetic fixtures failed (bug bound to real frame). Step 7 = instruction-level dynamic value trace for func 136 only (interp-vs-x86_64-jit), or gdb on ubuntu native x86_64. | | 2026-06-21 | v128-gc-class-swept | v128-in-GC correctness class comprehensively swept, select-v128 D-491 select-GC-reftype D-492 array.new_data/init_data-v128 D-493 fixed, array.get-v128 D-460 prior, array.set/copy/struct.new/get/set-v128 all work, ONLY array.fill/array.new-with-v128-VALUE gap D-495 guarded (fill helper takes value as u64 esz>8 panic→clean trap, proper 16-byte pointer-marshal deferred), grepped jit_abi callconv(.c) helpers value:u64 asBytes[0..esz] risk only jitGcArrayFill/AllocArrayFill v128-vulnerable table.grow=reftype≤8B atomics=≤8B not v128, GC slot model element.size=slot_size 8B scalar/ref 16B v128, dont re-probe class clean except D-495 | Comprehensive v128-in-GC sweep: select-v128/GC-reftype (D-491/492), array.new_data-v128 (D-493) FIXED; array.get/set/copy + struct.new/get/set v128 already work; only array.fill/array.new-with-v128-VALUE is a gap (D-495, crash-GUARDED — the fill helper takes value as u64, panicked for 16B v128; proper pointer-marshal deferred). Grep confirms no other u64-value-helper v128 gaps (table.grow/atomics are ≤8B). Don't re-probe — class clean except D-495. | | 2026-06-23 | arm64-reserved-reg-stale-after-table-grow | D-497 ADR-0201, arm64 JIT prologue caches table-0 invariants in callee-saved reserved regs X25=table_size X26=funcptr_base X24=typeidx_base, call_indirect table-0 fast path bounds-checks W25 not fresh TableSlice.len, table.grow bumps rt.table_size+TableSlice.len but X25 callee-saved survives table_grow_fn BLR with STALE pre-grow value, grown slot call_indirect SAME function trapped OOB latent until funcref-grow (non-funcref tables cant call_indirect), fix reload W25 from [runtime_ptr+table_size_off] in emitTableGrow arm64-only, x86_64 reads rt.table_size fresh from [R15+off] no staleness no x86_64 emit change ubuntu green, funcptr/typeidx base POINTERS valid across grow (ADR-0201 pre-allocs mirror arenas no realloc) only size reg needs reload, any future table-0-mutating op or reserved-reg scheme change must re-sync cached invariant reg | arm64 JIT caches table-0 invariants in callee-saved reserved regs (X25 table_size / X26 funcptr_base / X24 typeidx_base); call_indirect's table-0 fast path bounds-checks W25. table.grow bumps rt.table_size but X25 (callee-saved) survives the table_grow_fn BLR STALE → a grown-slot call_indirect in the SAME function trapped OOB. Latent until D-497 funcref-grow (non-funcref tables can't call_indirect). Fix: reload W25 after a table-0 grow in emitTableGrow (arm64-only; x86_64 reads table_size fresh from [R15+off], no staleness, no x86_64 emit change). funcptr/typeidx base pointers stay valid across grow (ADR-0201 pre-allocs the mirror arenas), so only the size reg needs reload. | +| 2026-07-18 | fnptr-field-default-defeats-build-option-dce | check_build_dce FAIL wasm_3_0 gcRefMatchesNonNullCore present in every v1_0+v2_0 binary, root ADR-0203 D1 D-516 position-independence JitRuntime fn-pointer FIELD defaults = real helper jitGcRefTest/jitGcAlloc/jitGcArray*/jitCallIndirectResolve, field default takes ADDRESS unconditionally JitRuntime instantiated every build so GC cohort + feature.gc/* stays live in v1_0, only wasm_3_0-namespaced symbol trips nm-grep but whole cohort leaked, emit reads @offsetOf not address + comptime-fenced so emit DCEs fine only struct default is the always-live address-taker, fix if(comptime !wasm_v3_plus) @panic first stmt makes helper reference-free stub in sub-v3 comptime-false block un-analysed GC refs vanish DCE reclaims, v3 unaffected guard is if(comptime false), sub-v3 validator rejects GC ops stub unreachable panic correct mirrors defaultReifyExnref, regression shipped 2026-07-09 ADR-0203 surfaced 2026-07-17 because check_build_dce runs push-to-main extended leg + intervening pushes doc-only skip or cancelled gate never completed | check_build_dce FAILed on `wasm_3_0` (gcRefMatchesNonNullCore) in every v1_0/v2_0 binary. Root: ADR-0203 D1 (D-516 position-independence) made GC/subtyping JIT helpers `JitRuntime` fn-pointer FIELDS with the real helper as the field DEFAULT — a default `= jitGcRefTest` takes the ADDRESS unconditionally, and `JitRuntime` is instantiated in every build, so the whole GC cohort (jitGcAlloc/Array*/RefTest/Cast/CallIndirectResolve → `feature/gc/*`) stayed live in v1_0. Emit sites use `@offsetOf` (offset, not address) + are comptime-fenced, so they DCE fine — the struct default is the ONLY always-live address-taker. Fix: `if (comptime !wasm_v3_plus) @panic(...)` first statement → sub-v3 helper is a reference-free stub (comptime-false block un-analysed → GC refs vanish → DCE reclaims); v3 unaffected; sub-v3 validator rejects GC ops so the stub is unreachable. Surfaced 3 weeks late because the gate runs only on push-to-main extended and intervening pushes were doc-only (skip) / cancelled. | diff --git a/src/engine/codegen/shared/jit_abi.zig b/src/engine/codegen/shared/jit_abi.zig index ba007890b..46a2b3d05 100644 --- a/src/engine/codegen/shared/jit_abi.zig +++ b/src/engine/codegen/shared/jit_abi.zig @@ -38,6 +38,20 @@ const mark_sweep = @import("../../../feature/gc/collector_mark_sweep.zig"); // ref.test / ref.cast share the interp's subtype-check core (one algorithm, // two runtimes) — see `gcRefMatchesNonNullCore`. const ref_test_ops = @import("../../../instruction/wasm_3_0/ref_test_ops.zig"); +const build_options = @import("build_options"); + +// Build-level guard (matches emit.zig): the GC / subtyping JIT helpers below +// are held as `JitRuntime` fn-pointer field defaults (ADR-0203 D1 / D-516 +// position-independence). A field default takes the helper's ADDRESS +// unconditionally, so without this guard the helpers — and everything they +// reach (`feature/gc/*`, `instruction/wasm_3_0/*`) — stay live even in a +// `-Dwasm=v1_0` build, tripping `check_build_dce` (a `wasm_3_0` symbol in a +// v1_0 binary). Wrapping each body in `if (comptime wasm_v3_plus)` lets the +// comptime-false block go un-analysed in sub-v3 builds, so the address points +// at a bare `@panic` stub and the GC code DCEs. Sub-v3 builds can never emit a +// GC / subtyping op (the validator rejects them), so the stub is unreachable. +const wasm_v3_plus = @intFromEnum(build_options.wasm_level) >= + @intFromEnum(@TypeOf(build_options.wasm_level).v3_0); /// `@sizeOf(FuncEntity)` — exposed for JIT emit's `ref.func` /// recipe (`ADD ptr, ptr, #(idx * func_entity_size)`). Comptime @@ -956,6 +970,7 @@ pub fn waitIdxOf(op: zir.ZirOp) u32 { /// typeidx / unmaterialised GC substrate / OOM — the JIT caller maps /// `0` to a trap. C-ABI; callee-saved regs preserved by the callee. pub fn jitGcAlloc(rt: *JitRuntime, typeidx: u32) callconv(.c) u32 { + if (comptime !wasm_v3_plus) @panic("jitGcAlloc in a sub-v3 build (unreachable — validator rejects GC ops)"); const heap_opaque = rt.gc_heap orelse return 0; const gti_opaque = rt.gc_type_infos_ptr orelse return 0; const heap: *heap_mod.Heap = @ptrCast(@alignCast(heap_opaque)); @@ -980,6 +995,7 @@ pub fn jitGcAlloc(rt: *JitRuntime, typeidx: u32) callconv(.c) u32 { /// trampoline. Returns `0` (null sentinel) on bad typeidx / unmaterialised /// GC substrate / OOM — the JIT caller maps `0` to a trap. pub fn jitGcAllocArray(rt: *JitRuntime, typeidx: u32, length: u32) callconv(.c) u32 { + if (comptime !wasm_v3_plus) @panic("jitGcAllocArray in a sub-v3 build (unreachable — validator rejects GC ops)"); const heap_opaque = rt.gc_heap orelse return 0; const gti_opaque = rt.gc_type_infos_ptr orelse return 0; const heap: *heap_mod.Heap = @ptrCast(@alignCast(heap_opaque)); @@ -1004,6 +1020,7 @@ pub fn jitGcAllocArray(rt: *JitRuntime, typeidx: u32, length: u32) callconv(.c) /// to marshal from an FP reg — currently GPR-only (see debt; matches the /// struct.new field-store simplification). pub fn jitGcAllocArrayFill(rt: *JitRuntime, typeidx: u32, length: u32, init: u64) callconv(.c) u32 { + if (comptime !wasm_v3_plus) @panic("jitGcAllocArrayFill in a sub-v3 build (unreachable — validator rejects GC ops)"); const heap_opaque = rt.gc_heap orelse return 0; const gti_opaque = rt.gc_type_infos_ptr orelse return 0; const heap: *heap_mod.Heap = @ptrCast(@alignCast(heap_opaque)); @@ -1046,6 +1063,7 @@ pub fn jitGcAllocArrayFill(rt: *JitRuntime, typeidx: u32, length: u32, init: u64 pub const ARRAY_NULL_SENTINEL: u32 = 2; pub fn jitGcArrayFill(rt: *JitRuntime, typeidx: u32, ref: u32, idx: u32, value: u64, count: u32) callconv(.c) u32 { + if (comptime !wasm_v3_plus) @panic("jitGcArrayFill in a sub-v3 build (unreachable — validator rejects GC ops)"); const heap_opaque = rt.gc_heap orelse return 0; const gti_opaque = rt.gc_type_infos_ptr orelse return 0; const heap: *heap_mod.Heap = @ptrCast(@alignCast(heap_opaque)); @@ -1087,6 +1105,7 @@ pub fn jitGcArrayFill(rt: *JitRuntime, typeidx: u32, ref: u32, idx: u32, value: /// into `rt.gc_type_infos_ptr` (the trampoline therefore needs `gc_heap` AND /// `gc_type_infos_ptr`, no per-call typeidx immediate). pub fn jitGcArrayCopy(rt: *JitRuntime, dst_ref: u32, dst_off: u32, src_ref: u32, src_off: u32, len: u32) callconv(.c) u32 { + if (comptime !wasm_v3_plus) @panic("jitGcArrayCopy in a sub-v3 build (unreachable — validator rejects GC ops)"); const heap_opaque = rt.gc_heap orelse return 0; const heap: *heap_mod.Heap = @ptrCast(@alignCast(heap_opaque)); if (dst_ref == 0 or src_ref == 0) return ARRAY_NULL_SENTINEL; // null-ref → distinct sentinel (D-293 array_oob) @@ -1136,6 +1155,7 @@ pub fn jitGcArrayCopy(rt: *JitRuntime, dst_ref: u32, dst_off: u32, src_ref: u32, /// segidx OOB, segment OOB, dropped segment, unmaterialised GC /// substrate / non-numeric element). The JIT caller maps `0` to a trap. pub fn jitGcArrayNewData(rt: *JitRuntime, typeidx: u32, segidx: u32, offset: u32, size: u32) callconv(.c) u32 { + if (comptime !wasm_v3_plus) @panic("jitGcArrayNewData in a sub-v3 build (unreachable — validator rejects GC ops)"); const heap_opaque = rt.gc_heap orelse return 0; const gti_opaque = rt.gc_type_infos_ptr orelse return 0; const heap: *heap_mod.Heap = @ptrCast(@alignCast(heap_opaque)); @@ -1187,6 +1207,7 @@ pub fn jitGcArrayNewData(rt: *JitRuntime, typeidx: u32, segidx: u32, offset: u32 /// or `0` on trap (negative/OOB operand, segidx OOB, segment OOB, dropped /// segment, unmaterialised GC substrate). The JIT caller maps `0` to a trap. pub fn jitGcArrayNewElem(rt: *JitRuntime, typeidx: u32, segidx: u32, offset: u32, size: u32) callconv(.c) u32 { + if (comptime !wasm_v3_plus) @panic("jitGcArrayNewElem in a sub-v3 build (unreachable — validator rejects GC ops)"); const heap_opaque = rt.gc_heap orelse return 0; const gti_opaque = rt.gc_type_infos_ptr orelse return 0; const heap: *heap_mod.Heap = @ptrCast(@alignCast(heap_opaque)); @@ -1227,6 +1248,7 @@ pub fn jitGcArrayNewElem(rt: *JitRuntime, typeidx: u32, segidx: u32, offset: u32 /// (null ref / OOB / segidx OOB / dropped segment / non-numeric element). The /// JIT caller maps `0` to a trap. pub fn jitGcArrayInitData(rt: *JitRuntime, segidx: u32, ref: u32, dst_off: u32, src_off: u32, len: u32) callconv(.c) u32 { + if (comptime !wasm_v3_plus) @panic("jitGcArrayInitData in a sub-v3 build (unreachable — validator rejects GC ops)"); const heap_opaque = rt.gc_heap orelse return 0; const gti_opaque = rt.gc_type_infos_ptr orelse return 0; const heap: *heap_mod.Heap = @ptrCast(@alignCast(heap_opaque)); @@ -1283,6 +1305,7 @@ pub fn jitGcArrayInitData(rt: *JitRuntime, segidx: u32, ref: u32, dst_off: u32, /// `array.new_elem` uses. Returns `1` on success, `0` on trap (null ref / OOB /// / segidx OOB / dropped segment). The JIT caller maps `0` to a trap. pub fn jitGcArrayInitElem(rt: *JitRuntime, segidx: u32, ref: u32, dst_off: u32, src_off: u32, len: u32) callconv(.c) u32 { + if (comptime !wasm_v3_plus) @panic("jitGcArrayInitElem in a sub-v3 build (unreachable — validator rejects GC ops)"); const heap_opaque = rt.gc_heap orelse return 0; const heap: *heap_mod.Heap = @ptrCast(@alignCast(heap_opaque)); if (ref == 0) return 0; // null-ref trap @@ -1317,6 +1340,7 @@ pub fn jitGcArrayInitElem(rt: *JitRuntime, segidx: u32, ref: u32, dst_off: u32, /// (no inline null branch). The non-null match reuses the SAME /// `gcRefMatchesNonNullCore` the interp uses (gti + heap read off JitRuntime). pub fn jitGcRefTest(rt: *JitRuntime, ref: u64, ht_nullbit: u32) callconv(.c) u32 { + if (comptime !wasm_v3_plus) @panic("jitGcRefTest in a sub-v3 build (unreachable — validator rejects GC ops)"); if (ref == Value.null_ref) return (ht_nullbit >> 30) & 1; // D-453 trampoline ABI bit layout: concrete-tag = bit 31, null-flag = // bit 30, concrete idx = bits 0..29 (a bare wire byte otherwise). The @@ -1339,6 +1363,7 @@ pub fn jitGcRefTest(rt: *JitRuntime, ref: u64, ht_nullbit: u32) callconv(.c) u32 /// interp + jitGcRefTest use. (ref.cast_null — which lets null pass — needs /// an inline null-skip branch in emit and is a separate chunk.) pub fn jitGcRefCast(rt: *JitRuntime, ref: u64, ht: u32) callconv(.c) u64 { + if (comptime !wasm_v3_plus) @panic("jitGcRefCast in a sub-v3 build (unreachable — validator rejects GC ops)"); if (ref == Value.null_ref) return 0; // null → trap (non-null target) const gti: ?*const gc_type_info.GcTypeInfos = if (rt.gc_type_infos_ptr) |p| @ptrCast(@alignCast(p)) else null; const heap: ?*const heap_mod.Heap = if (rt.gc_heap) |p| @ptrCast(@alignCast(p)) else null; @@ -1367,6 +1392,7 @@ pub fn jitGcRefCast(rt: *JitRuntime, ref: u64, ht: u32) callconv(.c) u64 { /// (its idx survives in the all-callee-saved regalloc pool). gti null is /// impossible here (the module uses subtyping) but is handled as 0 for safety. pub fn jitCallIndirectResolve(rt: *JitRuntime, table_idx: u32, idx: u64, expected_typeidx: u32) callconv(.c) u64 { + if (comptime !wasm_v3_plus) @panic("jitCallIndirectResolve in a sub-v3 build (unreachable — no subtyping modules below Wasm 3.0)"); const gti: *const gc_type_info.GcTypeInfos = if (rt.gc_type_infos_ptr) |p| @ptrCast(@alignCast(p)) else return 0; var funcptr_base: [*]const u64 = undefined; var typeidx_base: [*]const u32 = undefined;