Skip to content

fix(dce): comptime-guard GC/subtyping JIT helpers so sub-v3 builds DCE feature.gc#150

Merged
chaploud merged 1 commit into
mainfrom
develop/dce-jit-gc-fnptr-defaults
Jul 17, 2026
Merged

fix(dce): comptime-guard GC/subtyping JIT helpers so sub-v3 builds DCE feature.gc#150
chaploud merged 1 commit into
mainfrom
develop/dce-jit-gc-fnptr-defaults

Conversation

@chaploud

Copy link
Copy Markdown
Contributor

Problem

main CI is red: the push-to-main check_build_dce extended leg FAILs on
the Linux (x86_64) gate — all six -Dwasm=v1_0 / -Dwasm=v2_0 rows report
instruction.wasm_3_0.ref_test_ops.gcRefMatchesNonNullCore present in a
non-GC binary (run 29565595088).

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 gate's nm-grep, but the entire cohort
leaked
(this is the "同様なもの" audit — see the size drop below).

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.

The regression shipped 2026-07-09 (ADR-0203) but surfaced only now:
check_build_dce runs only in the push-to-main extended leg, and every
intervening main push (#143#148) was doc-only (heavy legs auto-skipped) or
concurrency-cancelled — so the gate never actually completed until #149.

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
→ 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).

Single file: src/engine/codegen/shared/jit_abi.zig (+ lesson).

Verification

  • Full check_build_dce --gate (all 9 wasm×wasi combos × arm64-native
    • x86_64-linux cross = 18 builds): [check_build_dce] OK, every row
      clean on both arches.
  • v1_0/v2_0 forbidden-symbol nm scan CLEAN on x86_64-linux (the failing
    CI leg's arch) and arm64-native.
  • The leak is reclaimed: v1_0:p1 .text 2,957,749 → 2,614,800 B (−343 KB).
  • zig build test + zig build lint green. gate_commit.sh green.

Notes

  • No ADR: this restores the ADR-0073 DCE invariant that ADR-0203 D1
    inadvertently broke; it is not a deviation.
  • Follow-up worth considering (not in this PR): the gate runs only on
    push-to-main extended, so a codegen regression can hide across a run of
    doc-only / cancelled pushes. Running check_build_dce once per PR (already
    flagged as a future refinement in the handover) would have caught this at
    PR time.

…lds DCE feature.gc

`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.
@chaploud
chaploud merged commit 0162e6c into main Jul 17, 2026
5 checks passed
@chaploud
chaploud deleted the develop/dce-jit-gc-fnptr-defaults branch July 17, 2026 20:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant