Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions .dev/lessons/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
26 changes: 26 additions & 0 deletions src/engine/codegen/shared/jit_abi.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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));
Expand All @@ -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));
Expand All @@ -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));
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down