test(ail-embed): RED — build.rs links a stale libailang_rt.a (missing rerun-if-changed on runtime sources)
bugfix-swarm-rc-alloc-undercount, RED stage (audit trail; GREEN follows separately via implement mini-mode). Root cause of the m5.2-resume-attempt residual (the M5 swarm leak-proof still non-deterministic after the atomic-counter fix7bfa11e): ail-embed/build.rs declares cargo:rerun-if-changed only for the kernel .ail + build.rs itself — NOT runtime/rc.c. So Cargo never re-ran build.rs after 7bfa11e; the swarm keeps linking a stale pre-7bfa11e libailang_rt.a whose g_rc_*count++ is the old non-atomic racing increment.7bfa11ewas correct and is unchanged; it just never reached the ail-embed binary. This explains why the isolated crates/ail RED (rebuilds the staticlib fresh per run) is green while the ail-embed swarm is not, and the frees-stable/allocs-jitter asymmetry (few TLS-NULL frees = no contention; millions of TLS-NULL host allocs = race on the stale non-atomic counter). Framing: a genuine defect (build-dependency completeness gap), NOT test-methodology unsoundness — with a fresh archive the swarm Σ-over-stat-lines balances deterministically (Σallocs==Σfrees== 12000003, 5/5 per the debugger). runtime/rc.c at HEAD is correct; the GREEN fix is in the build script, not the runtime. RED pins the CAUSE deterministically (the symptom is non-deterministic by construction): objdump the libailang_rt.a that build.rs emitted for this crate; assert >=2 lock-prefixed instructions (the atomic lock incq for g_rc_alloc_count + g_rc_free_count from current rc.c) and that it matches a freshly-built archive. Boss-verified RED: linked archive has 0 lock insns ("STALE pre-7bfa11e archive linked"). Does NOT touch the committed #[ignore] of symbol_fan_swarm_leak_free nor the isolated embed_rc_global_stats_race RED (both correct, unchanged).
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
//! RED for `bugfix-swarm-rc-alloc-undercount`.
|
||||
//!
|
||||
//! Named property protected: the `libailang_rt.a` archive that
|
||||
//! `ail-embed` links (emitted by `build.rs` via
|
||||
//! `ail build --emit=staticlib`) MUST be regenerated whenever
|
||||
//! `runtime/rc.c` changes, so the global RC stats counter increment
|
||||
//! in the linked binary is the atomic (`lock`-prefixed) form that
|
||||
//! commit `7bfa11e` introduced. If the build does not track
|
||||
//! `runtime/rc.c` as an input, a stale pre-`7bfa11e` archive is
|
||||
//! linked whose `g_rc_alloc_count++` is the original non-atomic
|
||||
//! racing increment — the exact swarm alloc-undercount
|
||||
//! (`Σfrees` stable-exact, `Σallocs` short by a jittering
|
||||
//! ~1600–57000 across runs) re-surfaces, frozen into a stale
|
||||
//! build artefact even though `runtime/rc.c` itself is correct.
|
||||
//!
|
||||
//! Why this shape and not a swarm re-run: the symptom (jittering
|
||||
//! `Σallocs`) is non-deterministic by construction (it depends on
|
||||
//! thread interleaving on the racing `incq`). The *cause* is fully
|
||||
//! deterministic: the linked archive's `ailang_rc_alloc` either has
|
||||
//! the `lock`-prefixed atomic global increment (correct, from the
|
||||
//! current `runtime/rc.c`) or a plain non-atomic `incq` (stale
|
||||
//! pre-`7bfa11e`). This RED pins the cause directly: it asserts the
|
||||
//! archive `build.rs` produced for THIS crate is built from a
|
||||
//! `runtime/rc.c` whose `g_rc_alloc_count` increment is atomic.
|
||||
//!
|
||||
//! Not a methodology unsoundness: with the correct (current)
|
||||
//! `runtime/rc.c` linked, the swarm's Σ-over-stat-lines balances
|
||||
//! deterministically (`Σallocs == Σfrees == 12000003`, every run).
|
||||
//! The Σ invariant is sound; only the *stale-artefact build gap*
|
||||
//! breaks it. The GREEN fix is a build-dependency completeness fix
|
||||
//! (`ail-embed/build.rs` must declare `runtime/rc.c` — and the rest
|
||||
//! of the staticlib's runtime inputs — as `rerun-if-changed`), NOT
|
||||
//! a runtime change: `runtime/rc.c` at HEAD is already correct.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// `<repo>` — the AILang workspace root, parent of this crate.
|
||||
fn repo_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.expect("ail-embed has a parent (the AILang repo root)")
|
||||
.to_path_buf()
|
||||
}
|
||||
|
||||
/// The `libailang_rt.a` that `build.rs` emitted into this crate's
|
||||
/// `OUT_DIR` and that `swarm_runner` / `lib.rs` link. There is exactly
|
||||
/// one under `ail-embed/target/**/build/ail-embed-*/out/`.
|
||||
fn linked_runtime_archive() -> PathBuf {
|
||||
let target = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target");
|
||||
let mut found: Vec<PathBuf> = Vec::new();
|
||||
fn walk(dir: &Path, acc: &mut Vec<PathBuf>) {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return };
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
walk(&p, acc);
|
||||
} else if p.file_name().and_then(|s| s.to_str())
|
||||
== Some("libailang_rt.a")
|
||||
{
|
||||
acc.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(&target, &mut found);
|
||||
assert!(
|
||||
!found.is_empty(),
|
||||
"no libailang_rt.a under {} — build.rs must have emitted one \
|
||||
(build the crate first: it links this archive)",
|
||||
target.display()
|
||||
);
|
||||
// Newest by mtime — the one the current build links.
|
||||
found.sort_by_key(|p| {
|
||||
std::fs::metadata(p)
|
||||
.and_then(|m| m.modified())
|
||||
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
|
||||
});
|
||||
found.pop().unwrap()
|
||||
}
|
||||
|
||||
/// The linked runtime archive must be built from the current
|
||||
/// `runtime/rc.c`, in which the global `g_rc_alloc_count` increment is
|
||||
/// atomic. On x86-64 a relaxed `atomic_fetch_add_explicit` lowers to a
|
||||
/// `lock`-prefixed RMW; the pre-`7bfa11e` non-atomic `g_rc_*count++`
|
||||
/// emits none. A stale archive (build did not track `runtime/rc.c`)
|
||||
/// therefore links zero `lock` instructions in `ailang_rc_alloc` and
|
||||
/// re-introduces the swarm alloc-undercount race.
|
||||
#[test]
|
||||
fn linked_runtime_archive_has_atomic_global_rc_counters() {
|
||||
let archive = linked_runtime_archive();
|
||||
|
||||
// Disassemble; count `lock`-prefixed instructions. The current
|
||||
// runtime emits exactly two (`lock incq` for g_rc_alloc_count in
|
||||
// ailang_rc_alloc and for g_rc_free_count in ailang_rc_dec). The
|
||||
// stale pre-7bfa11e archive emits zero (plain `incq`, or a
|
||||
// cmov-fused plain `incq`).
|
||||
let out = Command::new("objdump")
|
||||
.args(["-d", "--no-show-raw-insn"])
|
||||
.arg(&archive)
|
||||
.output()
|
||||
.expect("run objdump on libailang_rt.a");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"objdump failed on {}:\n{}",
|
||||
archive.display(),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let disasm = String::from_utf8_lossy(&out.stdout);
|
||||
let lock_insns = disasm
|
||||
.lines()
|
||||
.filter(|l| {
|
||||
// Match the mnemonic column, not the symbol name / path.
|
||||
l.split_once('\t')
|
||||
.map(|(_, ins)| ins.trim_start().starts_with("lock "))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.count();
|
||||
|
||||
assert!(
|
||||
lock_insns >= 2,
|
||||
"linked runtime archive `{}` has {lock_insns} lock-prefixed \
|
||||
instruction(s); the current runtime/rc.c emits an atomic \
|
||||
(lock-prefixed) global RC counter increment in both \
|
||||
ailang_rc_alloc and ailang_rc_dec (>=2 expected). Zero/one \
|
||||
means a STALE pre-7bfa11e archive is linked: build.rs did not \
|
||||
track runtime/rc.c as an input, so the swarm links the old \
|
||||
non-atomic g_rc_alloc_count++ and the alloc-undercount race \
|
||||
re-surfaces (Sfrees stable-exact, Sallocs short & jittering).",
|
||||
archive.display()
|
||||
);
|
||||
|
||||
// Cross-check: a freshly-built archive from the SAME current
|
||||
// runtime/rc.c, via the same `ail build --emit=staticlib` path
|
||||
// build.rs uses, is atomic. If this differs from the linked one,
|
||||
// the linked one is provably stale.
|
||||
let outdir = std::env::temp_dir().join(format!(
|
||||
"ail-embed-rt-fresh-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&outdir);
|
||||
std::fs::create_dir_all(&outdir).unwrap();
|
||||
let repo = repo_root();
|
||||
let ail_bin = repo.join("target/debug/ail");
|
||||
assert!(
|
||||
ail_bin.exists(),
|
||||
"expected the `ail` CLI at {} (build.rs builds it; run \
|
||||
`cargo build -p ail` in the workspace first)",
|
||||
ail_bin.display()
|
||||
);
|
||||
let st = Command::new(&ail_bin)
|
||||
.arg("build")
|
||||
.arg(repo.join("examples/embed_backtest_step_tick.ail"))
|
||||
.args(["--emit=staticlib", "-o"])
|
||||
.arg(&outdir)
|
||||
.output()
|
||||
.expect("ail build --emit=staticlib (fresh runtime archive)");
|
||||
assert!(
|
||||
st.status.success(),
|
||||
"fresh `ail build --emit=staticlib` failed:\n{}",
|
||||
String::from_utf8_lossy(&st.stderr)
|
||||
);
|
||||
let fresh = outdir.join("libailang_rt.a");
|
||||
let fresh_disasm = Command::new("objdump")
|
||||
.args(["-d", "--no-show-raw-insn"])
|
||||
.arg(&fresh)
|
||||
.output()
|
||||
.expect("objdump fresh archive");
|
||||
let fresh_lock = String::from_utf8_lossy(&fresh_disasm.stdout)
|
||||
.lines()
|
||||
.filter(|l| {
|
||||
l.split_once('\t')
|
||||
.map(|(_, ins)| ins.trim_start().starts_with("lock "))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.count();
|
||||
let _ = std::fs::remove_dir_all(&outdir);
|
||||
assert_eq!(
|
||||
lock_insns, fresh_lock,
|
||||
"linked archive ({lock_insns} lock insns) disagrees with a \
|
||||
freshly-built archive from the current runtime/rc.c \
|
||||
({fresh_lock} lock insns) — the linked archive is STALE: \
|
||||
ail-embed/build.rs did not regenerate libailang_rt.a after \
|
||||
runtime/rc.c changed (missing rerun-if-changed on the \
|
||||
staticlib runtime inputs)."
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user