From 7d15bd9efcd241bc5c10a2f5eb04d92d7621f953 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Fri, 28 Aug 2026 05:42:30 +0200 Subject: [PATCH 1/2] RQ-61-DANGLE (#1102): a retained function relocating against a DECLINED function refuses loudly on every backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L --- crates/synth-cli/src/main.rs | 76 +++- .../tests/a64_dangling_reloc_decline_1013.rs | 25 +- .../tests/dangling_declined_callee_1102.rs | 334 ++++++++++++++++++ .../tests/skipped_export_exit_952.rs | 85 +++-- 4 files changed, 475 insertions(+), 45 deletions(-) create mode 100644 crates/synth-cli/tests/dangling_declined_callee_1102.rs diff --git a/crates/synth-cli/src/main.rs b/crates/synth-cli/src/main.rs index ac354d03..669710b8 100644 --- a/crates/synth-cli/src/main.rs +++ b/crates/synth-cli/src/main.rs @@ -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_` → position map, so the // module-level composer can resolve direct calls across the call graph AFTER @@ -4077,6 +4082,7 @@ fn compile_all_exports( name.clone(), format!("unsupported operator: {reason}"), func.export_name.is_some(), + func.index, )); continue; } @@ -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; } }; @@ -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::>() .join(", ") ); @@ -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 @@ -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 = 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()); diff --git a/crates/synth-cli/tests/a64_dangling_reloc_decline_1013.rs b/crates/synth-cli/tests/a64_dangling_reloc_decline_1013.rs index a1eb8b33..8f89b4df 100644 --- a/crates/synth-cli/tests/a64_dangling_reloc_decline_1013.rs +++ b/crates/synth-cli/tests/a64_dangling_reloc_decline_1013.rs @@ -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, @@ -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. diff --git a/crates/synth-cli/tests/dangling_declined_callee_1102.rs b/crates/synth-cli/tests/dangling_declined_callee_1102.rs new file mode 100644 index 00000000..4a8d489b --- /dev/null +++ b/crates/synth-cli/tests/dangling_declined_callee_1102.rs @@ -0,0 +1,334 @@ +//! #1102 (RQ-61-DANGLE) — a RETAINED function that relocates against a +//! function this compile DECLINED must fail the compile loudly, on EVERY +//! backend. Before the fix the object shipped with exit 0 and could never +//! link. +//! +//! Measured on v0.60.0 (main @ 23a0b546), minimal module — an INTERNAL +//! function declines on rv32, the exported caller is retained: +//! +//! ```text +//! $ synth compile dangle.wat -b riscv --target riscv32imac-unknown-none-elf \ +//! --all-exports --relocatable -o d.o +//! warning: skipping function 'func_0': ... immediate 1048588 too large ... +//! warning: 1 of 2 functions were skipped (not in output): func_0 +//! $ echo $? # -> 0 +//! $ ld.lld d.o # -> undefined symbol: synth_func_0 +//! ``` +//! +//! `synth_func_0` names a function the module itself DEFINES, so no linker +//! input can ever resolve it: the object is not partial, it is UNLINKABLE. +//! The #952 guard is keyed on declined REQUESTED EXPORTS and the #1013 +//! refusal lives only in the aarch64 ELF builder — an internal decline +//! referenced by a retained export slipped past both. Also measured on the +//! SAME baseline: ARM Thumb-2 and A32 ship the identical shape (dangling +//! `func_N` GLOBAL UNDEF in `.rel.text`/symtab, exit 0) — the "ARM has no +//! symtab" theory was a probe artifact: the ARM builder emits its symtab +//! section with an EMPTY name string, so a probe by section NAME misses it; +//! `readelf -sW` (by section type) shows it. +//! +//! The fix is one driver-level gate in `compile_all_exports` — the site where +//! the two facts (which functions were skipped, which relocations the +//! retained functions carry) already meet for all four backends — matching +//! the aarch64 #1013 policy. Deliberately NOT waived by +//! `--allow-skipped-exports`: that flag accepts a PARTIAL object, not an +//! unlinkable one. Deliberately NOT a stub/trap body for the declined callee +//! and NOT a dropped call — both would turn an unlinkable object into a +//! WRONG one. +//! +//! All refusal fixtures were verified RED (exit 0 + dangling UNDEF) against +//! the unfixed baseline binary before this test was written; the negative +//! controls were verified exit-0 on BOTH binaries (and the whole +//! `scripts/repro` corpus x 5 legs is byte-identical old-vs-new: 835 pairs, +//! 0 differing, 3 rv32 pairs newly-declined — each proven unlinkable-before +//! by an UNDEF `synth_func_N` in the old object). + +use std::path::PathBuf; +use std::process::{Command, Output}; + +fn synth() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_synth")) +} + +fn workdir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("synth-1102-{tag}")); + std::fs::create_dir_all(&d).expect("temp dir"); + d +} + +/// The minimal reported shape: `$big` is INTERNAL (not exported), declines on +/// rv32 (memory offset 1048588 exceeds the selector's immediate range), and +/// the retained export `entry` calls it. +const DANGLING_INTERNAL: &str = r#"(module + (memory 32) + (func $big (param i32) (result i32) + (i32.load offset=1048588 (local.get 0))) + (func (export "entry") (param i32) (result i32) + (call $big (local.get 0)))) +"#; + +/// gale's `gpio-thin` shape, minimized: TWO retained exports reference the +/// same declined internal function (`--gc-sections` could not save this one), +/// plus one export that never touches it. +const DANGLING_MULTI_EXPORT: &str = r#"(module + (memory 32) + (func $big (param i32) (result i32) + (i32.load offset=1048588 (local.get 0))) + (func (export "gpio_get") (param i32) (result i32) + (call $big (local.get 0))) + (func (export "gpio_set") (param i32) (result i32) + (call $big (i32.add (local.get 0) (i32.const 4)))) + (func (export "gpio_ok") (result i32) (i32.const 1))) +"#; + +/// The same decline with NO dangling reference: the offending function is +/// exported (so it is compiled and declines) but nothing retained calls it. +/// Under `--allow-skipped-exports` (the corpus-sweep shape) this must stay a +/// routine exit-0 partial object — the inverse failure of the fix would be +/// refusing every module that merely contains a decline. +const DECLINE_WITHOUT_REFERENCE: &str = r#"(module + (memory 32) + (func (export "big") (param i32) (result i32) + (i32.load offset=1048588 (local.get 0))) + (func (export "entry") (param i32) (result i32) + (i32.add (local.get 0) (i32.const 1)))) +"#; + +/// ARM/A32 decline shape (the rv32 fixture's large offset COMPILES on ARM): +/// an internal `$hard` returns f64, which a soft-float target refuses +/// (GI-FPU-002); its direct caller `$helper` declines too (f64 at the +/// AAPCS-VFP boundary), and the retained export `f` calls `$helper` — a +/// dangling `func_1` reloc. This is the exact fixture #952 used as its +/// "helper-only skip" negative control; measured on the baseline it ships +/// `f` with a GLOBAL UNDEF `func_1`, i.e. it was the #1102 defect all along. +const ARM_DANGLING_HELPER: &str = r#"(module + (func $hard (result f64) (f64.sqrt (f64.const 2.0))) + (func $helper (result i32) (call $hard) (drop) (i32.const 1)) + (func (export "f") (result i32) (call $helper))) +"#; + +fn compile(dir: &std::path::Path, wat: &str, out_name: &str, args: &[&str]) -> Output { + let src = dir.join("m.wat"); + std::fs::write(&src, wat).expect("write wat"); + let obj = dir.join(out_name); + // The temp workdir persists across runs — a stale object from an earlier + // run would make the "no object left behind" assertions vacuous. + let _ = std::fs::remove_file(&obj); + let mut c = Command::new(synth()); + c.arg("compile").arg(src.to_str().unwrap()); + c.args(args); + c.args(["-o", obj.to_str().unwrap()]); + c.output().expect("run synth compile") +} + +fn stderr(o: &Output) -> String { + String::from_utf8_lossy(&o.stderr).into_owned() +} + +/// The full refusal contract, shared by every leg: the decline anchor still +/// fired (so the assertions judge THIS defect, not some unrelated failure), +/// the exit is the clean-error 1 (not 0 = shipped, not 101 = panic), the +/// reason names the class and the dangling edge, and no object is left. +fn assert_refusal(out: &Output, dir: &std::path::Path, out_name: &str, edge: &str, anchor: &str) { + let err = stderr(out); + assert!( + err.contains("skipping function") && err.contains(anchor), + "fixture no longer trips the decline this test depends on (anchor \ + '{anchor}') — premise gone, revisit rather than pass on some other \ + error.\nstderr:\n{err}" + ); + assert_eq!( + out.status.code(), + Some(1), + "expected the clean refusal (exit 1); 0 means an unlinkable object \ + was shipped (#1102), 101 means a panic.\nstderr:\n{err}" + ); + assert!( + !err.contains("panicked at") && !err.contains("RUST_BACKTRACE"), + "refusal was delivered via panic, not a clean error.\nstderr:\n{err}" + ); + assert!( + err.contains("#1102") && err.contains(edge), + "refusal must name the #1102 class and the dangling edge {edge}.\nstderr:\n{err}" + ); + assert!( + !dir.join(out_name).exists(), + "refused compile still wrote an output object" + ); +} + +const RV32: &[&str] = &[ + "-b", + "riscv", + "--target", + "riscv32imac-unknown-none-elf", + "--all-exports", + "--relocatable", +]; + +/// RED on the unfixed binary (exited 0 with a dangling `synth_func_0`): the +/// reported minimal module refuses on rv32. +#[test] +fn rv32_dangling_internal_refuses() { + let dir = workdir("rv32-min"); + let out = compile(&dir, DANGLING_INTERNAL, "d.o", RV32); + assert_refusal( + &out, + &dir, + "d.o", + "'entry' -> 'func_0'", + "immediate 1048588", + ); +} + +/// RED on the unfixed binary: gale's multi-export shape — BOTH retained +/// callers are named in the refusal, so the diagnostic scales past the +/// minimal module. +#[test] +fn rv32_multi_export_names_every_dangling_caller() { + let dir = workdir("rv32-multi"); + let out = compile(&dir, DANGLING_MULTI_EXPORT, "m.o", RV32); + assert_refusal( + &out, + &dir, + "m.o", + "'gpio_get' -> 'func_0'", + "immediate 1048588", + ); + assert!( + stderr(&out).contains("'gpio_set' -> 'func_0'"), + "the second dangling caller must be named too.\nstderr:\n{}", + stderr(&out) + ); +} + +/// The #952 escape hatch does NOT waive the refusal: that flag accepts a +/// PARTIAL object (a requested export absent, counted downstream), which is +/// categorically different from an UNLINKABLE one. The aarch64 #1013 builder +/// refusal was likewise unconditional — this pins the same policy here. +#[test] +fn allow_skipped_exports_does_not_waive_the_refusal() { + let dir = workdir("rv32-flag"); + let mut args = RV32.to_vec(); + args.push("--allow-skipped-exports"); + let out = compile(&dir, DANGLING_INTERNAL, "d.o", &args); + assert_refusal( + &out, + &dir, + "d.o", + "'entry' -> 'func_0'", + "immediate 1048588", + ); +} + +/// RED on the unfixed binary (exit 0, GLOBAL UNDEF `func_1` in the object): +/// ARM Thumb-2 `--relocatable` refuses the same class. This is the fixture +/// #952 previously used as its "helper-only skips stay exit 0" negative +/// control — measured, that control was shipping an unlinkable object. +#[test] +fn arm_thumb2_relocatable_refuses() { + let dir = workdir("arm-reloc"); + let out = compile( + &dir, + ARM_DANGLING_HELPER, + "a.o", + &[ + "-b", + "arm", + "-t", + "cortex-m3", + "--all-exports", + "--relocatable", + ], + ); + assert_refusal(&out, &dir, "a.o", "'f' -> 'func_1'", "GI-FPU-002"); +} + +/// RED on the unfixed binary: WITHOUT `--relocatable` the dangling reloc +/// counted as an external reference and silently flipped the output to +/// ET_REL — same unlinkable object, one more surprise. Refuses now. +#[test] +fn arm_thumb2_default_refuses() { + let dir = workdir("arm-default"); + let out = compile( + &dir, + ARM_DANGLING_HELPER, + "a2.o", + &["-b", "arm", "-t", "cortex-m3", "--all-exports"], + ); + assert_refusal(&out, &dir, "a2.o", "'f' -> 'func_1'", "GI-FPU-002"); +} + +/// RED on the unfixed binary: A32 (cortex-r5) refuses too — the fix is one +/// backend-agnostic gate, not a per-backend patch. +#[test] +fn a32_cortex_r5_refuses() { + let dir = workdir("a32"); + let out = compile( + &dir, + ARM_DANGLING_HELPER, + "r.o", + &[ + "-b", + "arm", + "-t", + "cortex-r5", + "--all-exports", + "--relocatable", + ], + ); + assert_refusal(&out, &dir, "r.o", "'f' -> 'func_1'", "GI-FPU-002"); +} + +/// NEGATIVE CONTROL (exit 0 on BOTH the unfixed and fixed binary): the same +/// decline with NO retained reference stays a routine corpus-sweep skip — +/// object emitted. Protects against the inverse failure: a guard drawn so +/// wide it refuses every module containing a decline, converting working +/// compiles into refusals ("we got stricter" hiding "we broke reach"). +#[test] +fn decline_without_reference_still_exits_zero_rv32() { + let dir = workdir("ctrl-rv32"); + let mut args = RV32.to_vec(); + args.push("--allow-skipped-exports"); + let out = compile(&dir, DECLINE_WITHOUT_REFERENCE, "c.o", &args); + let err = stderr(&out); + assert!( + err.contains("skipping function 'big'"), + "control must actually exercise a decline to mean anything.\nstderr:\n{err}" + ); + assert_eq!( + out.status.code(), + Some(0), + "a decline with no dangling reference must stay a routine exit-0 \ + skip.\nstderr:\n{err}" + ); + assert!(dir.join("c.o").exists(), "control object was not emitted"); +} + +/// NEGATIVE CONTROL, ARM leg: same property on Thumb-2. +#[test] +fn decline_without_reference_still_exits_zero_arm() { + let dir = workdir("ctrl-arm"); + let out = compile( + &dir, + DECLINE_WITHOUT_REFERENCE, + "c.o", + &[ + "-b", + "arm", + "-t", + "cortex-m3", + "--all-exports", + "--relocatable", + "--allow-skipped-exports", + ], + ); + let err = stderr(&out); + // On ARM the big-offset function COMPILES (no decline) — so this control + // asserts the plain no-skip path is untouched instead. + assert_eq!( + out.status.code(), + Some(0), + "ARM control must stay exit 0.\nstderr:\n{err}" + ); + assert!(dir.join("c.o").exists(), "control object was not emitted"); +} diff --git a/crates/synth-cli/tests/skipped_export_exit_952.rs b/crates/synth-cli/tests/skipped_export_exit_952.rs index b5cf1a99..ec0edc1b 100644 --- a/crates/synth-cli/tests/skipped_export_exit_952.rs +++ b/crates/synth-cli/tests/skipped_export_exit_952.rs @@ -36,7 +36,9 @@ //! # The asymmetry this test protects //! //! Declining a non-exported internal helper (pulled in only for #235 -//! reachability) is routine and must keep exiting 0 — that is the +//! reachability) is routine and must keep exiting 0 — PROVIDED no retained +//! function still relocates against it (#1102 refuses that shape as +//! unlinkable; see `dangling_declined_callee_1102.rs`) — that is the //! NEGATIVE CONTROL below. Only a decline of a function the module actually //! `(export ...)`s must flip the exit code. Getting this backwards (failing //! the build on every skip) would break the `--all-exports` corpus-sweep @@ -90,20 +92,26 @@ const REQUESTED_EXPORT_DECLINED: &str = r#"(module (func (export "f") (result i32) (call $g (i32.const 7) (i64.const 9)))) "#; -/// `f` is exported and compiles fine; `$hard` is an internal, NON-exported -/// helper pulled in only because `f` calls it (#235 reachability). `$hard`'s -/// f64 result makes IT decline on a soft-float target — but nothing asked for -/// `$hard` by name, so its absence is routine, not a build failure. +/// Internal-helper skips with NO dangling reference stay routine. `$hard` +/// (internal, f64 result) declines on a soft-float target, and its decline +/// CASCADES into its direct caller `f` (f64 at the AAPCS-VFP boundary), so +/// nothing RETAINED references either of them; `ok` compiles and carries no +/// relocations. Under `--allow-skipped-exports` (needed because the cascade +/// reaches the export `f`) the partial object is emitted with exit 0. /// -/// Verified empirically before writing this test (against the unmodified -/// v0.56.1 binary) that this fixture actually PRODUCES a skip of a -/// non-exported function: `$hard` unreachable-but-uncalled produces no skip -/// at all (it is simply never compiled), so the helper MUST be called from -/// the export for `reachable_from_exports` to pull it in and then decline it. -const ONLY_HELPER_DECLINED: &str = r#"(module +/// HISTORY (#1102, RQ-61-DANGLE): the previous fixture here stopped the +/// cascade one level short — `f` compiled and kept a `bl func_1` against the +/// declined `$helper`, so the "routine exit-0 skip" this control asserted +/// was, measured, an UNLINKABLE object (GLOBAL UNDEF `func_1` no linker +/// input could resolve). That shape now REFUSES at the driver's #1102 gate +/// (see `dangling_declined_callee_1102.rs`, which pins it as a RED case). +/// The asymmetry this control protects is therefore restated precisely: +/// a decline that leaves no dangling reference is not a build failure — +/// a decline that does is not a "skip" at all. +const HELPER_DECLINE_NO_DANGLE: &str = r#"(module (func $hard (result f64) (f64.sqrt (f64.const 2.0))) - (func $helper (result i32) (call $hard) (drop) (i32.const 1)) - (func (export "f") (result i32) (call $helper))) + (func (export "f") (result i32) (call $hard) (drop) (i32.const 1)) + (func (export "ok") (result i32) (i32.const 1))) "#; /// RED (must pass only after the fix): a compile that declines a REQUESTED @@ -134,39 +142,46 @@ fn declined_requested_export_exits_nonzero() { ); } -/// NEGATIVE CONTROL, both before and after the fix: skipping only a -/// non-exported internal helper must still exit 0. This is the asymmetry -/// #952 explicitly preserves — routine helper skips are not build failures. -/// If this test ever starts failing, the fix over-broadened the gate to fail -/// the build on ANY skip, not just a skipped export. +/// NEGATIVE CONTROL: an internal-helper skip whose references all declined +/// with it (no retained function relocates against it) still exits 0 and +/// emits the partial object. If this test ever starts failing, a gate was +/// over-broadened to fail the build on ANY skip — not just a skipped export +/// (#952) or a dangling reference from retained code (#1102). #[test] -fn skipped_nonexported_helper_still_exits_zero() { +fn skipped_helper_without_dangling_reference_still_exits_zero() { let dir = workdir("negctrl"); - let out = compile(&dir, ONLY_HELPER_DECLINED, "ctrl.o", &[]); + let out = compile( + &dir, + HELPER_DECLINE_NO_DANGLE, + "ctrl.o", + &["--allow-skipped-exports"], + ); let err = stderr(&out); - // Non-vacuity: a control that never exercises a skip proves nothing (the - // #275/A32 lesson — see call_indirect_275_selfcontained.rs). Anchor on - // BOTH the per-function warning naming the skipped helper AND the - // aggregate count, so a future refactor that stops skipping `$hard` - // (e.g. broadens f64 support) fails this test loudly rather than leaving - // it passing for the wrong reason. + // Non-vacuity: a control that never exercises an INTERNAL-function skip + // proves nothing (the #275/A32 lesson — see + // call_indirect_275_selfcontained.rs). Anchor on the helper's own skip + // (`func_0` = the non-exported `$hard`) AND the aggregate count, so a + // future refactor that stops skipping it (e.g. broadens f64 support) + // fails this test loudly rather than leaving it passing for the wrong + // reason. assert!( - err.contains("skipping function") && err.contains("were skipped"), + err.contains("skipping function 'func_0'") && err.contains("were skipped"), "fixture must actually skip the non-exported helper for this control \ to mean anything — got:\n{err}" ); - assert!( - !err.contains("skipping function 'f'"), - "the EXPORT 'f' must not be the one skipped — this fixture is meant \ - to isolate a helper-only skip. stderr:\n{err}" - ); assert!( out.status.success(), - "#952 negative control: skipping only a non-exported internal helper \ - (never asked for by name) must still exit 0 — only a declined \ - REQUESTED export may fail the build. stderr:\n{err}" + "negative control: a decline that leaves NO dangling reference from \ + retained code must still exit 0 under --allow-skipped-exports — \ + only a declined REQUESTED export (#952) or a retained function \ + relocating against a declined one (#1102) may fail the build. \ + stderr:\n{err}" + ); + assert!( + dir.join("ctrl.o").exists(), + "control object was not emitted" ); } From 185306622d9ba67e3b3757c29513f9cc497f8691 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Fri, 28 Aug 2026 05:42:42 +0200 Subject: [PATCH 2/2] =?UTF-8?q?chore(rivet):=20RQ-61-DANGLE=20implemented?= =?UTF-8?q?=20=E2=80=94=20ARM=20was=20the=20same=20defect,=20and=20the=20#?= =?UTF-8?q?952=20negative=20control=20was=20shipping=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L --- artifacts/release-v0.61/RQ-61-DANGLE.yaml | 46 ++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/artifacts/release-v0.61/RQ-61-DANGLE.yaml b/artifacts/release-v0.61/RQ-61-DANGLE.yaml index c955991f..64295b17 100644 --- a/artifacts/release-v0.61/RQ-61-DANGLE.yaml +++ b/artifacts/release-v0.61/RQ-61-DANGLE.yaml @@ -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: @@ -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).