Skip to content

RQ-60-A64IMPORT (VCR-REACH-002 inc. 1): aarch64 import dispatch — SHN_UNDEF externals, the ARM #197 contract ported. Refs #1017, Refs #242 - #1071

Merged
avrabe merged 5 commits into
mainfrom
feat/aarch64-import-dispatch-1017
Aug 26, 2026
Merged

RQ-60-A64IMPORT (VCR-REACH-002 inc. 1): aarch64 import dispatch — SHN_UNDEF externals, the ARM #197 contract ported. Refs #1017, Refs #242#1071
avrabe merged 5 commits into
mainfrom
feat/aarch64-import-dispatch-1017

Conversation

@avrabe

@avrabe avrabe commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

What

The top-ranked real-world aarch64 blocker (#1017: ~121 of 805 modules, 88 of 101 components): a module that calls an imported function, or whose funcref table holds one, loud-declined wholesale. This ports synth's own ARM --relocatable design (#173/#197 — the wasm2c/Wasker undefined-symbol pattern), inventing no new policy:

  • selector: an import call lowers through the SAME bl+CALL26 path as a local call; every marshalling guard (≤8 args, ≤1 result, no float result/args, exact-height stack) applies to imports identically. The now-meaningless num_imports parameter is deleted, not kept vestigially.
  • substrate: a table slot holding an import emits its trampoline b <field> (JUMP26) against the import's wasm field name; an unnamed import still declines — never fabricate a symbol.
  • elf: build_relocatable_object_full gains an undefined-externals allowlist (the module's import field names). A reloc against a listed name emits a GLOBAL STT_FUNC SHN_UNDEF symbol. Not a policy change: a symbol neither placed nor listed keeps the aarch64 ELF builder PANICS (exit 101) when a retained function relocates against a declined one — arm and riscv exit 1 cleanly on the identical module #1013 clean refusal — httparse still exits 1 naming func_0 (verified), so a loud-declined local callee can never silently become a link-time external.
  • backend + CLI: import func_N relocs rewritten to field names via ONE shared derivation (substrate::import_func_symbols).
  • CLI single-function path: ported the RISC-V riscv backend: 'external call without relocation table' blocks every seam-importing function (--relocatable) #871 guard — that wrapper ships no .rela.text, so a function carrying relocations now declines loudly instead of shipping dead bl #0 sites (latent for local calls before this PR).

Red-first

On main, both repros exit 1:

warning: skipping function 'run': ... call to imported function 0 — import dispatch is not yet supported for aarch64; loud-declining (#851)
Error: no functions compiled successfully (1 skipped) — nothing to emit          # exit 1
Error: aarch64: table slot 0 holds imported function 0; import dispatch is not supported on aarch64 ... # exit 1

After: both exit 0, and the pyelftools symtab (never disasm text) shows:

sym[3] name='host_add' shndx=SHN_UNDEF STT_FUNC/STB_GLOBAL
rela offset=0x1c type=283 (R_AARCH64_CALL26) sym='host_add'
sym[5] name='ext_inc'  shndx=SHN_UNDEF STT_FUNC/STB_GLOBAL
rela offset=0x70 type=282 (R_AARCH64_JUMP26) sym='ext_inc'   # table slot 0 trampoline

Linked and executed, not merely produced

ld.lld links both objects against a clang-assembled definition of the imports; unicorn executes the linked images: run(37)=42 through the import call; table dispatch slot 0 (import) 41→42, slot 1 (local) 21→42; out-of-range indices still TRAP exactly where wasmtime traps. The CI-wired differential (scripts/repro/aarch64_import_dispatch_1017_differential.py, emulations >= 6) repeats all of this on every run with wasmtime-first expected values plus a decline-honesty probe; oracle_wiring_check green.

The measured number

The 805-module gale corpus is not available locally (1.5 GB, gale-side). Measured instead on the org-repo corpus: 307 unique modules / 489 MB / 124 components — the same corpus RQ-59-PARTIALCENSUS (#1051) used — with a main-built baseline binary vs this branch, same harness, same command (-b aarch64 --all-exports --relocatable):

accept rate crash
origin/main 60/307 19.5% 0
this branch 67/307 21.8% 0

+7, 0 regressions. The flips include real component-shaped modules (meld consumer*.wasm composites, wasi-testsuite AssemblyScript proc_exit-*, loom repro-219, synth i2c_thin_882).

Honest framing of the delta: this corpus is NOT the gale corpus — its baseline is 19.5%, not 1.6%, and its top blockers are active data segments (89) and multi-memory (64), not import dispatch, because org-repo modules are toolchain/meld artifacts rather than wasm.directory components. The 13/805 → N/805 number can only be measured by gale. What this corpus does show is the overlap finding the artifact asked for: of the 89 data-segment declines, 80 also carry function imports; of the 64 multi-memory declines, 60 do. First-blocker attribution therefore understates this increment — import dispatch is a necessary co-requisite that pays out again when the data-segment and multi-memory increments land.

Found by the census: a latent release-mode miscompile

Re-measuring exposed 6 rc=101 panics — not in the import code: the prologue's sub sp, sp, #imm12 encoder only debug_asserted its 12-bit range, so a ≥512-slot local frame panicked in debug and would encode overflow bits into neighbouring instruction fields in release — a wrong SP adjustment, the silent-miscompile class (#180/#185's A64 sibling). Latent since the prologue landed; unreachable until this PR removed the module-level import declines that had always aborted those modules first. Fixed as a loud selector decline with a two-direction regression test; all six modules now exit 1 cleanly, and the final census has 0 crashes.

Deliberately out of scope

  • Multi-memory (64 local / 124 gale modules) — the second reach increment, explicitly not this lane.
  • Active data segments on aarch64 (89 local / 44 gale) — the v0.59 refusal is untouched; this PR does not interact with it.
  • Imported globals/tables/memories — still decline with the existing reasons.
  • WASI-Virt pre-composition (named in VCR-REACH-002) — composition-layer work, not backend.

What did not port cleanly

Nothing structural. One deviation from ARM worth naming: ARM turns any unresolved reloc into an undefined external; aarch64 deliberately keeps the stricter #1013 refusal for non-import symbols (declined local callees), using an imports-only allowlist — the sibling behaviour would have erased a v0.59 guarantee.

Gates

cargo fmt --check · cargo clippy --workspace --all-targets -- -D warnings · cargo test --workspace (150 suites) · python3 scripts/claim_check.py claims.yaml (51/51) · scripts/oracle_wiring_check.py · rivet validate (errors unchanged at main's 40; warnings 233→232) — all green, gated on exit codes. ARM/RV32 frozen anchors untouched (import-free aarch64 output byte-identical, unit-tested).

Refs #1017, Refs #242 (VCR-REACH-002 increment 1)

🤖 Generated with Claude Code

https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L

avrabe and others added 4 commits August 26, 2026 18:41
…_UNDEF externals, the ARM #173/#197 contract ported

The top-ranked real-world blocker for aarch64 (~121 of 805 modules, 88 of
101 components): a module whose code calls an imported function, or whose
funcref table holds one, loud-declined wholesale. This is synth's own ARM
--relocatable design ported, not new policy:

- selector: a call to an import lowers through the SAME bl+CALL26 path as
  a local call (all marshalling guards apply identically); the now-unused
  num_imports parameter is DELETED, not kept vestigially.
- substrate: a table slot holding an import emits its trampoline
  `b <field>` against the import's wasm field name (JUMP26) instead of
  declining; an unnamed import still declines (never fabricate a symbol).
- elf: build_relocatable_object_full gains a driver-supplied undefined-
  externals ALLOWLIST — a reloc against a listed name emits a GLOBAL
  STT_FUNC SHN_UNDEF symbol (ARM add_undefined_symbol shape). NOT a
  policy change: a symbol neither placed nor listed keeps the #1013
  clean refusal (httparse still exits 1 naming func_0), so a declined
  local callee can never silently become a link-time external.
- backend + CLI: import func_N relocs are rewritten to the import FIELD
  name (ARM build_relocatable_elf #173 rewrite, ported); field names come
  from ONE shared derivation (substrate::import_func_symbols).
- CLI single-function path: ported the #871 sibling guard — this wrapper
  ships no .rela.text, so a function carrying relocations now declines
  loudly instead of shipping dead `bl #0` sites (latent for local calls
  before this change).

Oracle evidence (red -> green on scripts/repro/aarch64_import_{call,table}_1017.wat):
- pre-change both exit 1 (import dispatch declines); post-change exit 0.
- pyelftools symtab: host_add / ext_inc are STB_GLOBAL STT_FUNC SHN_UNDEF;
  relocs 283 (R_AARCH64_CALL26) at the bl site and 282 (R_AARCH64_JUMP26)
  at the table trampoline bind to them.
- ld.lld links both objects against an assembled definition of the
  imports; unicorn executes the linked images: run(37)=42 via the import,
  table dispatch slot0 (import) 41->42 and slot1 (local) 21->42.
- import-free modules byte-identical (unit-tested: empty allowlist and
  unreferenced externals are invisible); 148 aarch64 tests + full
  workspace green; claim_check 51/51.

Refs #1017, Refs #242 (VCR-REACH-002 increment 1)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
…m12 range — a latent release-mode miscompile the census reached

Found by re-running the #1017 acceptance census after import dispatch
landed: six kiln modules (wasip3 filesystem tests, yolo_inference) went
rc=101. Root cause is NOT the import work — the prologue's
`sub sp, sp, #frame` encoder (`sub_imm64`) only debug_asserts its
unsigned 12-bit immediate, so a function with >= 512 slot-resident
locals panics in a debug build and, in RELEASE, encodes the overflow
bits into neighbouring instruction fields — a WRONG SP adjustment, the
silent-miscompile class (#180/#185's A64 sibling). Latent since the
prologue landed; unreachable until #1017 removed the module-level
import declines that had always aborted those modules first.

Fix: the selector loud-declines a frame > 4095 bytes before emitting
anything, naming the limit. Regression test pins both directions
(4096-byte frame declines, 4080-byte frame still lowers); all six
census crashers now exit 1 with the reason.

Refs #1017, Refs #242

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
…to the aarch64 CI job

scripts/repro/aarch64_import_dispatch_1017_differential.py (ci-status:
wired, emulations >= 6): symtab contract via pyelftools (field name is
GLOBAL/STT_FUNC/SHN_UNDEF; CALL26 resp. JUMP26 binds to it), then the
harness acts as the host linker — places .text, appends its own A64
definitions of the imports, resolves every relocation — and executes
under unicorn against wasmtime-FIRST expected values, including the
still-must-TRAP out-of-range table cases (import dispatch must not
loosen the §4.4.8 guards). Decline-honesty leg: a loud-declined LOCAL
callee still refuses exit-1 naming the symbol — the externals
allowlist is imports-only. CI step asserts non-zero counts in BOTH
directions plus the verdict line, set -euo pipefail (#890).
oracle_wiring_check green; claim_check 51/51.

Refs #1017, Refs #242

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
… — the measured census delta and the oracle

Verifies VCR-REACH-002 increment 1 (aarch64 import dispatch): symtab
contract + linked-execution differential (CI-wired), decline-honesty in
both directions, frozen-safety, the found-by-census latent frame
defect, and the MEASURED acceptance delta on the org-repo corpus (307
unique modules, the same corpus as #1051's census; the 805-module gale
corpus is not local): 60/307 (19.5%) -> 67/307 (21.8%), 0 regressions.
Also records the overlap finding: 80/89 data-segment and 60/64
multi-memory declines ALSO carry function imports, so first-blocker
attribution understates this increment — it pays out again when those
two land. Strict-duplicate-key YAML parse + rivet validate (errors
unchanged at main's 40, warnings 233 -> 232) + verification-evidence
oracle green.

Refs #1017, Refs #242

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
@codecov

codecov Bot commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.96429% with 18 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/synth-backend-aarch64/src/backend.rs 0.00% 11 Missing ⚠️
crates/synth-cli/src/main.rs 74.07% 7 Missing ⚠️

📢 Thoughts on this report? Let us know!

… claim — the capability that closed it is verified working

The decline-matrix honesty oracle (aarch64_m2_decline_538.py) went red
on this branch EXACTLY as designed: its 'table slot holding an IMPORTED
function' entry asserted a loud decline that this PR's increment 1
closed (commit b81aa8e: the slot's trampoline emits `b <field>`
against the import's GLOBAL/STT_FUNC/SHN_UNDEF field-name symbol,
R_AARCH64_JUMP26 — the ARM #173/#197 contract ported). Per the
oracle's own header, an entry whose gap has closed is a stale claim
and must be deleted in the same change that closes it — v0.58's
"delete the thing you replaced", applied to a decline claim.

Deleted because the replacing capability is VERIFIED WORKING, not
merely because the decline stopped happening:
- aarch64_import_dispatch_1017_differential.py (CI-wired in the SAME
  aarch64-oracle job) executes the import-slot dispatch under unicorn
  against wasmtime-first values — slot 0 (import) 41 -> 42, slot 1
  (local) 21 -> 42, OOB indices still TRAP where wasmtime traps — after
  asserting the symtab contract via pyelftools; at authoring the object
  was also linked with ld.lld against a real definition and executed.
- The narrower residual stays pinned: an import the module does not
  NAME still declines (substrate.rs
  table_slot_holding_an_unnamed_import_still_declines_1017).

The declared non-vacuity floor moves 14 -> 13 in the same commit —
deliberate, this oracle is DESIGNED to shrink as capability lands
(same shape as the v0.54/v0.55 entries that moved off this list).
Sibling gate checked, not assumed: a64_extended_surface (VCR-SEL-005)
scopes itself OP-LEVEL ONLY (module/shape-level refusals incl. import
calls explicitly out of scope) and is green on this branch.

Re-run through the CI runner:
  13/13 declined ops loud-declined (10 with their machine reason asserted)
  RESULT: PASS — decline matrix honest
  ORACLE-EVIDENCE ... mode=stdout floor=13 measured=13 compiles=13 exit=0
oracle_wiring_check green (stdout floor total 460 -> 459);
claim_check 51/51.

Refs #1017, Refs #242

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
@avrabe
avrabe merged commit f8036ec into main Aug 26, 2026
63 checks passed
@avrabe
avrabe deleted the feat/aarch64-import-dispatch-1017 branch August 26, 2026 21:19
avrabe added a commit that referenced this pull request Aug 26, 2026
…d 5th orphaned flips

RQ-60-FLIPCOUPLE is now demonstrating itself. That artifact was filed BECAUSE
three status flips were orphaned in v0.59; here are two more, in the release
that scopes the fix:

  RQ-60-CANARY     its work SHIPPED IN v0.59.0 (#1061, tag-verified) — the
                   artifact was written describing already-delivered work and
                   landed as `proposed` anyway
  RQ-60-A64IMPORT  #1071 merged minutes ago and did not flip its own status

Verified ON MAIN rather than inferred from the merge list:
  expansion_canary_gate_1021.py present, 1 CI invocation, `git tag --contains`
    reports v0.59.0
  aarch64_import_dispatch_1017_differential.py present, 1 CI invocation

ALSO TIGHTENED THE FLOOR 472 -> 473. #1071 added SWVER-025 without raising it.
A floor only fails BELOW itself, so 473-measured against 472-pinned would have
passed QUIETLY — slack, not red. The gate's own rule is that a PR adding
artifacts raises the floor in that PR, and leaving it loose is the first crack
in the discipline the gate exists to create.

That is the same failure shape twice in one commit: a number that drifts in the
direction that still looks green. Both are exactly what RQ-60-FLIPCOUPLE is
scoped to make mechanical instead of vigilant.

Verified BY ID: both resolve `[implemented]`; CI-filter OURS=0; measured 473 =
floor 473; claim_check exit 0.

Refs #1064, Refs #1017, Refs #1021
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
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