Files
AILang/crates/ail/tests/embed_rc_global_stats_race.rs
T
Brummel 427b687b95 test(rc): RED — non-atomic global g_rc_* stats counters race under a multi-threaded host
bugfix-rc-global-stats-race, RED stage (audit trail; GREEN follows
separately via implement mini-mode).

`g_rc_alloc_count`/`g_rc_free_count` (runtime/rc.c:90-91) are plain
`static uint64_t`; the `__ail_tls_ctx == NULL` fallback at rc.c:161
and rc.c:212 does a non-atomic `++`. Concurrent host-side
ailang_rc_alloc/ailang_rc_dec outside a bound ctx race the
read-modify-write and silently drop updates. New C host (8 threads
x 2_000_000 alloc-then-dec, no ailang_ctx_new so the global path is
taken, no box crosses a thread) + integration test asserting the
atexit Σ is exact. Boss-verified RED: allocs=2141382 expected
16000000, live=-131242 (deterministic-fail under this contention,
not flaky).

NOT a memory bug — Ctx:!Send keeps every box on one thread, the
per-object refcount header op is correct, programs are bit-exact;
the only defect is the under-counted statistics Σ (the M5 iter 2
symbol-fan leak-proof finding, b724cd1). Existing green per-ctx
harnesses (embed_swarm_tsan.rs, embed_rc_accounting_tsan.rs) always
bind __ail_tls_ctx, exercising the zero-contention per-thread
counters and never this global fallback — which is why the suite is
green while the bug ships.
2026-05-19 01:50:36 +02:00

112 lines
4.8 KiB
Rust

//! RED for `bugfix-rc-global-stats-race`.
//!
//! Named property protected: under a multi-threaded host that uses the
//! NULL-ctx GLOBAL RC-stats fallback path (`__ail_tls_ctx == NULL` ->
//! `g_rc_alloc_count` / `g_rc_free_count`, runtime/rc.c:90-91,161,212),
//! the `AILANG_RC_STATS` atexit Σ is EXACT: it counts every
//! `ailang_rc_alloc` and every to-zero `ailang_rc_dec`, with no lost
//! increments. The two global counters are plain `static uint64_t`
//! incremented by a non-atomic `++`; concurrent workers race on the
//! read-modify-write and silently drop updates, so the printed total
//! under-counts non-deterministically (M5 iter 2's symbol-fan swarm:
//! allocs jitter 5993321..5999318, never the true 6_000_000).
//!
//! This is NOT a memory bug: no box crosses a thread (alloc + dec in
//! the same loop body), the per-object refcount header op is correct,
//! and the program is bit-exact every run. The ONLY defect is the
//! under-counted statistics Σ — exactly the deferred atomic-counter
//! decision rc.c's own header (rc.c:44-49,85) names, with M5 the first
//! concurrent consumer.
//!
//! The existing per-ctx swarm/accounting harnesses
//! (embed_swarm_tsan.rs, embed_rc_accounting_tsan.rs) always set
//! `__ail_tls_ctx = ctx`, so they exercise the per-thread (zero-
//! contention) counters and never the global fallback — that is why
//! the suite is green while this bug ships.
//!
//! Build/link mechanism mirrors `embed_swarm_tsan.rs`: a staticlib
//! build of any kernel emits `libailang_rt.a`; the C host links the
//! runtime archive directly and never calls a kernel.
use std::path::PathBuf;
use std::process::Command;
fn ws_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().unwrap()
}
fn ail_bin() -> &'static str { env!("CARGO_BIN_EXE_ail") }
/// 8 threads * 2_000_000 cycles — must match the `#define`s in
/// `crates/ail/tests/embed/rc_global_stats_race.c`.
const NTHREADS: u64 = 8;
const NCYCLES: u64 = 2_000_000;
const EXPECTED: u64 = NTHREADS * NCYCLES;
#[test]
fn global_rc_stats_counters_are_exact_under_thread_contention() {
let outdir = std::env::temp_dir().join(format!(
"ail-rc-global-stats-race-{}", std::process::id()));
std::fs::create_dir_all(&outdir).unwrap();
let root = ws_root();
// Any kernel staticlib build emits libailang_rt.a alongside it.
let st = Command::new(ail_bin())
.args(["build", "examples/embed_backtest_step_tick.ail",
"--emit=staticlib", "--alloc=rc",
"-o", outdir.to_str().unwrap()])
.current_dir(&root).status().expect("ail build");
assert!(st.success(), "ail build --emit=staticlib failed");
let rt_archive = outdir.join("libailang_rt.a");
assert!(rt_archive.exists(), "libailang_rt.a not emitted");
let host_c = root.join("crates/ail/tests/embed/rc_global_stats_race.c");
let bin = outdir.join("rc_global_stats_race");
let st = Command::new("cc")
.args([
"-O2", "-pthread",
host_c.to_str().unwrap(),
rt_archive.to_str().unwrap(),
"-o", bin.to_str().unwrap(),
])
.status().expect("cc");
assert!(st.success(), "linking rc_global_stats_race.c failed");
let out = Command::new(&bin)
.env("AILANG_RC_STATS", "1")
.output().expect("run rc_global_stats_race");
assert!(out.status.success(),
"host must exit 0; stderr:\n{}",
String::from_utf8_lossy(&out.stderr));
let stderr = String::from_utf8_lossy(&out.stderr);
let line = stderr.lines()
.find(|l| l.starts_with("ailang_rc_stats:"))
.unwrap_or_else(|| panic!(
"missing ailang_rc_stats line; stderr was:\n{stderr}"));
let mut allocs: Option<u64> = None;
let mut frees: Option<u64> = None;
for tok in line.split_whitespace() {
if let Some(v) = tok.strip_prefix("allocs=") {
allocs = Some(v.parse().expect("allocs= u64"));
} else if let Some(v) = tok.strip_prefix("frees=") {
frees = Some(v.parse().expect("frees= u64"));
}
}
let allocs = allocs.expect("allocs= present");
let frees = frees.expect("frees= present");
// Pre-fix: the non-atomic `g_rc_*count++` loses updates under
// contention, so allocs/frees are < EXPECTED and jitter run-to-run.
// Post-fix (atomic global counters): exact equality, deterministic.
assert_eq!(allocs, EXPECTED,
"global RC alloc counter must count every ailang_rc_alloc on \
the NULL-ctx fallback path with no lost increments \
(allocs={allocs}, expected {EXPECTED}); line: {line:?}");
assert_eq!(frees, EXPECTED,
"global RC free counter must count every to-zero ailang_rc_dec \
on the NULL-ctx fallback path with no lost increments \
(frees={frees}, expected {EXPECTED}); line: {line:?}");
}