Skip to content

RQ-61-DANGLE (#1102): a retained function relocating against a DECLINED function refuses loudly on every backend - #1104

Merged
avrabe merged 2 commits into
mainfrom
fix/dangling-declined-callee-1102
Aug 28, 2026
Merged

RQ-61-DANGLE (#1102): a retained function relocating against a DECLINED function refuses loudly on every backend#1104
avrabe merged 2 commits into
mainfrom
fix/dangling-declined-callee-1102

Conversation

@avrabe

@avrabe avrabe commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

RQ-61-DANGLE (#1102) — an unlinkable object shipped with exit 0

A retained function calling a function this compile declined shipped an object with exit 0 that could never link — the undefined symbol names a function the module itself defines, so no linker input can resolve it. #952 keys on declined requested exports; #1013 lives only in the aarch64 ELF builder. An internal decline referenced by a retained export slipped past both.

The fix: one backend-agnostic driver gate

In compile_all_exports, after the #952 export gate and before any ELF builder runs: a retained function's relocation whose symbol is a skipped function's index label (func_{idx} on ARM/A32/aarch64, synth_func_{idx} on RV32 — direct calls are always index-labelled) bails with a #1102 error naming every dangling caller→callee edge. No object is written.

Why the driver, not the ELF builders: the two facts already meet there — the driver owns skipped_funcs (now carrying wasm indices) and compiled_funcs[].relocations for all four backends, whose ELF paths are three separate crates (rv32 in main.rs, ARM in synth-backend, aarch64 in its own crate; only aarch64's had a symbol-placement view). One gate, one message, all backends; aarch64's builder Err (#851) stays as defense-in-depth for un-placed symbols that are not skip-related.

--allow-skipped-exports does NOT waive it (pinned by a test): that flag accepts a partial object (a requested export absent — the corpus-sweep shape), which is categorically different from an unlinkable one. aarch64's #1013 refusal was already unconditional; this is the same policy applied where it was missing. No stub, no trap body, no dropped call — both would turn an unlinkable object into a wrong one.

ARM characterised — same defect, and the probe that settled it

arm-none-eabi-readelf -sW -r on the unfixed binary, module every backend declines (#1093 block-type):

leg before after
rv32 --relocatable exit 0, synth_func_0 GLOBAL UNDEF, ld.lld refuses exit 1, #1102
ARM Thumb-2 --relocatable exit 0, func_0 GLOBAL UNDEF + retained R_ARM_THM_CALL exit 1, #1102
ARM Thumb-2 default exit 0, silently flips to ET_REL with the same dangle exit 1, #1102
A32 cortex-r5 exit 0, same shape as Thumb-2 exit 1, #1102
aarch64 exit 1 (#1013 builder guard) exit 1 (driver #1102, builder guard retained)

The "ARM relocatable objects have no .symtab" report was a probe artifact: the ARM builder emits its symtab section with an empty name string, so a probe by section NAME misses what a probe by section TYPE (readelf -sW) finds. ARM was not fine; it was the same defect.

Red-first, both directions

Loud: baseline exits 0 on the minimal module, gale's multi-export shape (both callers named in the refusal), and the ARM/A32 f64-helper shape; fixed binary exits 1 on all, leaves no partial object. New dangling_declined_callee_1102.rs: 8 tests — four backend legs, flag-no-waiver, two negative controls.

Silent (the one that matters more): 835 (fixture,leg) pairs — scripts/repro/*.wat + in-tree .wasm × 5 legs — baseline vs fixed binary:

identical bytes:        666
fail identically:       166
DIFFERING BYTES:          0
newly-declined (#1102):   3   (all rv32)

Each of the 3 newly-declined pairs (aarch64_f32_unsupported_554, popcnt_r11_clobber_1021, recursive_shadow_stack — rv32 leg only) was proven unlinkable before the change: the baseline object carries an UNDEF synth_func_N for a module-defined function. No CI job compiles any of the three on rv32 (the popcnt differential is Thumb-2-only, the shadow-stack fixture is consumed by scry analysis, the f32 fixture by an aarch64 test).

Deliberate test fallout

  • a64_dangling_reloc_decline_1013.rs: the refusal now fires at the driver, so the message assertion moved to #1102; the exit-1/no-panic/no-object contract is unchanged, and the header documents that seeing #851 again means the driver gate was removed — a real signal.
  • skipped_export_exit_952.rs: its "helper-only skips stay exit 0" negative control was, measured, this defect — the fixture's retained f carried a dangling func_1 GLOBAL UNDEF. Restated: a decline that leaves no dangling reference (full cascade + --allow-skipped-exports) stays exit 0 with the object emitted; the old fixture is now a RED case in the new test file.

Honest residual

The gate matches direct-call index labels only. A declined function referenced solely from a funcref table entry (call_indirect elem segment) is outside this gate and keeps its pre-existing behaviour — same class, different reference kind, left for its own increment rather than widened here without a repro.

Gates

cargo fmt --check / clippy --workspace --all-targets -D warnings / cargo test --workspace (156 suites green, incl. the three touched test files) / claim_check 52/52 / status_evidence 0 failures. BRANCH_POPULATION untouched (main.rs is not on the MC/DC scored surface). Local rivet validate (0.32.0, newer than the CI pin): 40 errors with and without this change — pre-existing version drift, none mine. Status record RQ-61-DANGLEimplemented with verified-by, riding on this branch (R4 first-parent rule).

Refs #1102.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L

avrabe and others added 2 commits August 28, 2026 05:42
…ED function refuses loudly on every backend

A retained function calling a function this compile declined shipped an
object with exit 0 that could NEVER link: the undefined symbol names a
function the module itself DEFINES, so no linker input can resolve it
(measured: `ld.lld: undefined symbol: synth_func_0` on the minimal rv32
repro and on gale's multi-export gpio shape). #952 keys on declined
REQUESTED EXPORTS and #1013 lives only in the aarch64 ELF builder — an
INTERNAL decline referenced by a retained export slipped past both.

ARM CHARACTERISED, NOT ASSUMED FINE: `arm-none-eabi-readelf -sW -r` on
the unfixed binary shows ARM Thumb-2 AND A32 relocatable objects carrying
`func_N` as a GLOBAL SHN_UNDEF with the R_ARM_THM_CALL retained, exit 0 —
the SAME defect. The "ARM has no .symtab" report was a probe artifact:
the ARM builder emits the symtab section with an EMPTY name string, so a
probe by section NAME misses what a probe by section TYPE finds. Without
--relocatable the dangling reloc even counted as an external reference
and silently flipped the output to ET_REL.

THE FIX: one backend-agnostic gate in `compile_all_exports`, after the
#952 export gate and before any ELF builder runs — the driver is the one
site where the two facts already meet (`skipped_funcs`, now carrying wasm
indices, and `compiled_funcs[].relocations`), where the four backends'
ELF paths are three separate crates. A retained relocation whose symbol
is a skipped function's index label (`func_{idx}` from the ARM/A32/
aarch64 selectors, `synth_func_{idx}` from RV32 — direct calls are always
index-labelled) bails with an error naming EVERY dangling caller->callee
edge. No object is written.

Deliberately NOT waived by --allow-skipped-exports: that flag accepts a
PARTIAL object (a requested export absent, the corpus-sweep shape), not
an UNLINKABLE one — and aarch64's #1013 refusal was already
unconditional; this is the same policy applied where it was missing.
Deliberately NOT a stub/trap body and NOT a dropped call: both would
turn an unlinkable object into a WRONG one.

Fallout, each deliberate:
- a64_dangling_reloc_decline_1013.rs: the refusal now fires at the
  driver, so the asserted message is #1102's; the builder's #851 Err
  stays as defense-in-depth. Exit-1/no-panic/no-object contract
  unchanged.
- skipped_export_exit_952.rs: its "helper-only skips stay exit 0"
  negative control was, measured, THIS defect — the fixture's retained
  `f` carried a dangling `func_1` (GLOBAL UNDEF). Restated: a decline
  that leaves NO dangling reference stays exit 0 (new cascade fixture,
  under --allow-skipped-exports); the old fixture is now a RED case in
  dangling_declined_callee_1102.rs.

Red-first both directions:
- Loud: baseline exits 0 on the minimal module, the multi-export shape,
  ARM/A32 f64-helper shape; fixed binary exits 1 naming the class and
  every edge, leaves no partial object (8 tests, all four backend legs +
  flag-no-waiver + two negative controls).
- Silent: 835 (fixture,leg) pairs — scripts/repro/*.wat + in-tree .wasm
  x 5 legs (arm-m3-reloc, a32-r5-reloc, rv32-reloc, aarch64-reloc,
  arm-m4f-image) — baseline vs fixed: 666 byte-identical, 166
  fail-identically, 0 DIFFERING, 3 rv32 pairs newly-declined
  (aarch64_f32_unsupported_554, popcnt_r11_clobber_1021,
  recursive_shadow_stack), each PROVEN unlinkable-before by an UNDEF
  `synth_func_N` in the baseline object; no CI job compiles any of the
  three on rv32.

HONEST RESIDUAL: the gate matches direct-call index labels only — a
declined function referenced solely from a funcref TABLE entry
(call_indirect) is outside this gate and keeps its pre-existing
behaviour.

Refs #1102, refs #952, refs #1013.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
… the #952 negative control was shipping it

The status flip rides ON THIS BRANCH (R4 is first-parent-evaluated: an
id-naming delivery commit with no acknowledgement reddens main the moment
it merges). `verified-by` records the probe that settled the ARM open
question — the baseline's ARM/A32 objects carry the dangling GLOBAL
SHN_UNDEF `func_N` (the 'no .symtab' report was a probe-by-section-NAME
artifact; the ARM builder names its symtab section with an empty string),
the 835-pair / 0-differing byte-identity sweep, the 3 rv32 pairs
newly-declined and proven unlinkable-before, and the honest residual
(funcref-table references to a declined function are outside the gate).

Gate after this commit: status-evidence 0 failures, claim_check 52/52.

Refs #1102.

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

codecov Bot commented Aug 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@avrabe
avrabe merged commit 1d9188b into main Aug 28, 2026
62 of 63 checks passed
@avrabe
avrabe deleted the fix/dangling-declined-callee-1102 branch August 28, 2026 04:19
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