From 483117d39be6a38333cb5fae8c4b5b96e108d1c2 Mon Sep 17 00:00:00 2001 From: Brummel Date: Tue, 19 May 2026 02:14:12 +0200 Subject: [PATCH] =?UTF-8?q?test(ail-embed):=20RED=20=E2=80=94=20build.rs?= =?UTF-8?q?=20links=20a=20stale=20libailang=5Frt.a=20(missing=20rerun-if-c?= =?UTF-8?q?hanged=20on=20runtime=20sources)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 fix 7bfa11e): 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. 7bfa11e was 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). --- ail-embed/tests/rt_archive_freshness.rs | 186 ++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 ail-embed/tests/rt_archive_freshness.rs diff --git a/ail-embed/tests/rt_archive_freshness.rs b/ail-embed/tests/rt_archive_freshness.rs new file mode 100644 index 0000000..975bed4 --- /dev/null +++ b/ail-embed/tests/rt_archive_freshness.rs @@ -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; + +/// `` — 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 = Vec::new(); + fn walk(dir: &Path, acc: &mut Vec) { + 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)." + ); +}