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
46 changes: 45 additions & 1 deletion artifacts/release-v0.61/RQ-61-DANGLE.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ artifacts:
against a function the backend declined must fail the compile loudly,
as #952 does for exports. Do not fabricate a stub, and do not silently
drop the call: both convert an unlinkable object into a wrong one.
status: proposed
status: implemented
release: v0.61
tags: [riscv, elf, relocations, decline-honesty, silent-failure]
links:
Expand All @@ -58,3 +58,47 @@ artifacts:
verification-track: differential
issue: "#1102"
done-when: "manual: a retained function relocating against a declined internal function fails the compile loudly on rv32; ARM characterised either way with a probe that works on its object shape; aarch64's exit-1 behaviour unchanged"
verified-by: >-
One backend-agnostic driver gate in `compile_all_exports`
(crates/synth-cli/src/main.rs, after the #952 export gate, before any
ELF builder): a retained function's relocation whose symbol is a
skipped function's index label (`func_{idx}` / `synth_func_{idx}`)
bails with a #1102 error naming every dangling caller->callee edge.
The driver is the site where the two facts already meet — it owns
`skipped_funcs` and `compiled_funcs[].relocations` for all four
backends, whose ELF paths are three separate crates. NOT waived by
--allow-skipped-exports (partial != unlinkable; aarch64's #1013
refusal was likewise unconditional), pinned by a test.
ARM CHARACTERISED, WITH THE PROBE: `arm-none-eabi-readelf -sW -r` on
the baseline showed ARM Thumb-2 AND A32 relocatable objects with
`func_0` as a GLOBAL SHN_UNDEF and the R_ARM_THM_CALL retained, exit
0 — the SAME defect, not fine. The "no .symtab" report was a probe
artifact: the ARM builder emits the symtab SECTION with an empty
name string, so probing by section NAME misses what probing by
section TYPE finds. Without --relocatable the dangling reloc even
silently flipped the output to ET_REL. Both refuse now; aarch64's
refusal stays exit 1 (message now the driver's #1102, the builder's
#851 Err retained as defense-in-depth).
RED-FIRST BOTH DIRECTIONS: baseline exits 0 on the minimal module,
gale's multi-export shape, ARM/A32 (ld.lld: undefined synth_func_0 /
func_N); fixed binary exits 1 on all, no partial object left. Silent
direction: 835 (fixture,leg) pairs — scripts/repro/*.wat + in-tree
.wasm x 5 legs — 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; none is compiled on rv32 by
any CI job. Gates: dangling_declined_callee_1102.rs (8 tests, all
four backend legs + flag-no-waiver + two negative controls), #1013
test updated to the earlier refusal site, #952's negative control
restated (its old fixture was measured to BE this defect — retained
`f` carried a dangling `func_1`); cargo test --workspace green,
clippy -D green, claim_check 52/52, status-evidence 0 failures.
HONEST RESIDUAL: the gate matches direct-call index labels only — a
declined function referenced solely from a funcref TABLE
(call_indirect elem entry) is not covered by this gate and keeps its
pre-existing behaviour; and #952's old asymmetry ("an internal-helper
decline is routine exit 0") now holds only when nothing retained
references the helper — with --all-exports every reachable internal
decline either dangles (refused) or cascades into a declined export
(#952's territory).
76 changes: 71 additions & 5 deletions crates/synth-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3972,7 +3972,12 @@ fn compile_all_exports(
// exit-code gate below keys on — a build gating on `$?` must fail when a
// named public entry point silently vanished, but not when an unexported
// implementation detail did.
let mut skipped_funcs: Vec<(String, String, bool)> = Vec::new();
// #1102: the fourth field is the skipped function's FULL wasm index — the
// index space direct-call relocation labels are stated in (`func_{idx}` on
// ARM/A32/aarch64, `synth_func_{idx}` on RV32) — so the dangling-reference
// gate below can tell whether any RETAINED function still relocates
// against a function this compile declined.
let mut skipped_funcs: Vec<(String, String, bool, u32)> = Vec::new();
// #778 phase 3: collect per-function WCET intermediates (own-body cycles +
// direct call sites, or a decline) and a `func_<idx>` → position map, so the
// module-level composer can resolve direct calls across the call graph AFTER
Expand Down Expand Up @@ -4077,6 +4082,7 @@ fn compile_all_exports(
name.clone(),
format!("unsupported operator: {reason}"),
func.export_name.is_some(),
func.index,
));
continue;
}
Expand Down Expand Up @@ -4186,7 +4192,12 @@ fn compile_all_exports(
backend.name(),
e
);
skipped_funcs.push((name.clone(), e.to_string(), func.export_name.is_some()));
skipped_funcs.push((
name.clone(),
e.to_string(),
func.export_name.is_some(),
func.index,
));
continue;
}
};
Expand Down Expand Up @@ -4394,7 +4405,7 @@ fn compile_all_exports(
all_exports.len(),
skipped_funcs
.iter()
.map(|(n, _, _)| n.as_str())
.map(|(n, _, _, _)| n.as_str())
.collect::<Vec<_>>()
.join(", ")
);
Expand All @@ -4420,8 +4431,8 @@ fn compile_all_exports(
if !allow_skipped_exports {
let skipped_exports: Vec<&str> = skipped_funcs
.iter()
.filter(|(_, _, is_export)| *is_export)
.map(|(n, _, _)| n.as_str())
.filter(|(_, _, is_export, _)| *is_export)
.map(|(n, _, _, _)| n.as_str())
.collect();
if !skipped_exports.is_empty() {
let total_exports = all_exports
Expand All @@ -4443,6 +4454,61 @@ fn compile_all_exports(
}
}

// #1102 (RQ-61-DANGLE): a RETAINED function that relocates against a
// function this compile DECLINED must fail the compile loudly — the
// object would carry an undefined symbol for a function the module itself
// DEFINES, so no linker input can ever resolve it: the object is not
// partial, it is UNLINKABLE (measured: `ld.lld` "undefined symbol:
// synth_func_0" on RV32; ARM/A32 ship the same dangling `func_N` GLOBAL
// UNDEF, and a `--cortex-m`-less compile even silently flips to ET_REL
// because the dangling reloc counts as external). #952 and the aarch64
// #1013 builder refusal are keyed one level too shallow for this class:
// #952 fires only on a declined REQUESTED EXPORT, and #1013 lives only in
// the aarch64 ELF builder — an INTERNAL decline referenced by a retained
// export slipped past both with exit 0. This gate sits where the two
// facts already meet: the driver knows which functions were skipped
// (`skipped_funcs`, with wasm indices) and which relocations the retained
// functions carry (`compiled_funcs`), across all four backends' otherwise
// separate ELF paths. Direct-call relocations are always index-labelled
// (`func_{idx}` from the ARM/A32/aarch64 selectors, `synth_func_{idx}`
// from RV32) — never export-named — so index labels are the complete
// match set.
//
// Deliberately NOT waived by `--allow-skipped-exports`: that flag means
// "I accept a PARTIAL object" (a requested export absent — the corpus-
// sweep shape), which is categorically different from "I accept an object
// that cannot link". The aarch64 #1013 refusal is likewise unconditional;
// this is the same policy applied to every backend. Also deliberately not
// a fabricated stub or a dropped call — both would turn an unlinkable
// object into a WRONG one.
{
let mut dangling: Vec<String> = Vec::new();
for (sname, _reason, _, sidx) in &skipped_funcs {
let labels = [format!("func_{sidx}"), format!("synth_func_{sidx}")];
for f in &compiled_funcs {
if f.relocations.iter().any(|r| labels.contains(&r.symbol)) {
dangling.push(format!("'{}' -> '{}'", f.name, sname));
}
}
}
if !dangling.is_empty() {
anyhow::bail!(
"#1102: {} retained function(s) relocate against function(s) \
this compile DECLINED: {}. The object would carry an \
undefined symbol for a function the module itself DEFINES, \
so it can never link — refusing to emit it rather than \
shipping an unlinkable object with exit 0 (the aarch64 #1013 \
refusal applied to every backend). See the preceding \
'skipping function' warning(s) for each decline reason. \
--allow-skipped-exports does not cover this: that flag \
accepts a PARTIAL object (a requested export absent), not an \
UNLINKABLE one.",
dangling.len(),
dangling.join(", ")
);
}
}

// Check if any function has relocations (import calls)
let has_relocations = compiled_funcs.iter().any(|f| !f.relocations.is_empty());

Expand Down
25 changes: 20 additions & 5 deletions crates/synth-cli/tests/a64_dangling_reloc_decline_1013.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@
//! -dispatch capability push (v0.60). This fix only converts the existing
//! refusal from a panic into the #952-style clean error.
//!
//! UPDATE (#1102, RQ-61-DANGLE): that rv32 "loud at link time" behaviour was
//! judged the defect, not the mitigation — an object carrying an undefined
//! symbol for a function the module itself DEFINES can never link, and the
//! compile exited 0. The refusal this test pins now fires for EVERY backend
//! at a driver-level gate BEFORE the ELF builders run, so the asserted
//! message is the #1102 one; the aarch64 builder's #851 `Err` stays as
//! defense-in-depth. See `dangling_declined_callee_1102.rs` for the
//! rv32/ARM/A32 legs.
//!
//! Fixtures are generated WAT (not the loom-repo corpus file, which is not
//! vendored here); both shapes were verified against the UNFIXED v0.58.0
//! binary before this test was written: repro exits 101 with the panic,
Expand Down Expand Up @@ -168,12 +177,18 @@ fn dangling_reloc_against_declined_function_refuses_cleanly() {
);

// Machine-readable reason: names the declined symbol and the class.
// #1102 (RQ-61-DANGLE): the refusal now fires one level EARLIER — the
// backend-agnostic driver gate in `compile_all_exports`, which refuses a
// retained-function relocation against ANY declined function before an
// ELF builder runs (the same class on rv32/ARM/A32 previously shipped
// with exit 0). The aarch64 builder's own #851/#1013 `Err` remains as
// defense-in-depth for un-placed symbols that are not skip-related. If
// this assertion ever sees the #851 message again, the driver gate was
// removed or narrowed — that is a real signal, not a text drift.
assert!(
err.contains("targets symbol 'func_0'")
&& err.contains("does not place")
&& err.contains("#851"),
"refusal reason must name the declined symbol (func_0) and the #851 \
unrelocated-placeholder class.\nstderr:\n{err}"
err.contains("#1102") && err.contains("-> 'func_0'"),
"refusal reason must name the #1102 dangling-declined-callee class \
and the declined symbol (func_0).\nstderr:\n{err}"
);

// A refused compile must not leave a partial object behind.
Expand Down
Loading
Loading