From 427b687b9588ac8e1f96070ffc20f2644e9cd28c Mon Sep 17 00:00:00 2001 From: Brummel Date: Tue, 19 May 2026 01:50:36 +0200 Subject: [PATCH] =?UTF-8?q?test(rc):=20RED=20=E2=80=94=20non-atomic=20glob?= =?UTF-8?q?al=20g=5Frc=5F*=20stats=20counters=20race=20under=20a=20multi-t?= =?UTF-8?q?hreaded=20host?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/ail/tests/embed/rc_global_stats_race.c | 60 ++++++++++ .../ail/tests/embed_rc_global_stats_race.rs | 111 ++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 crates/ail/tests/embed/rc_global_stats_race.c create mode 100644 crates/ail/tests/embed_rc_global_stats_race.rs diff --git a/crates/ail/tests/embed/rc_global_stats_race.c b/crates/ail/tests/embed/rc_global_stats_race.c new file mode 100644 index 0000000..b9dee9b --- /dev/null +++ b/crates/ail/tests/embed/rc_global_stats_race.c @@ -0,0 +1,60 @@ +/* bugfix-rc-global-stats-race: RED host. + * + * Drives the GLOBAL RC-stats fallback path (`g_rc_alloc_count` / + * `g_rc_free_count`, runtime/rc.c:90-91) under high thread contention. + * + * Crucially: NO `ailang_ctx_new`, so `__ail_tls_ctx` stays NULL in every + * worker and both `ailang_rc_alloc` (rc.c:161) and the to-zero branch of + * `ailang_rc_dec` (rc.c:212) take the `else g_rc_*count++;` arm. Those + * two counters are plain `static uint64_t` with a non-atomic `++`, so + * concurrent increments lose updates (classic read-modify-write race). + * + * Each worker does NCYCLES of alloc-immediately-dec on a 16-byte box. + * No box ever crosses a thread (alloc + dec in the same loop body), so + * the per-object refcount header op is correct and the program is + * bit-exact every run. The ONLY observable defect is the under-counted + * global Σ printed by the atexit handler. + * + * The integration test (crates/ail/tests/embed_rc_global_stats_race.rs) + * parses the atexit `ailang_rc_stats:` line and asserts + * allocs == NTHREADS*NCYCLES AND frees == NTHREADS*NCYCLES. + * Pre-fix: reliably fails (allocs/frees short by lost increments). + * Post-fix (atomic global counters): deterministic exact equality. + * + * This host links libailang_rt.a directly; it does not call any kernel. + */ +#include +#include +#include + +extern void *ailang_rc_alloc(size_t); +extern void ailang_rc_dec(void *); + +/* High contention: 8 threads * 2_000_000 cycles = 16_000_000 expected + * global allocs and 16_000_000 global frees. At this contention the + * non-atomic `++` reliably loses updates on every observed run, so the + * RED is deterministic (it fails pre-fix, not merely flaky). */ +#define NTHREADS 8 +#define NCYCLES 2000000 + +static void *worker(void *_a) { + (void)_a; + /* __ail_tls_ctx left NULL on purpose -> global fallback path. */ + for (int i = 0; i < NCYCLES; i++) { + void *p = ailang_rc_alloc(16); + ailang_rc_dec(p); /* refcount 1 -> 0, frees, g_rc_free_count++ */ + } + return NULL; +} + +int main(void) { + pthread_t t[NTHREADS]; + for (int i = 0; i < NTHREADS; i++) pthread_create(&t[i], NULL, worker, NULL); + for (int i = 0; i < NTHREADS; i++) pthread_join(t[i], NULL); + /* All threads joined before main returns -> the atexit reader sees + * a fully quiesced counter (no reader/writer ordering concern; the + * defect is purely the lost writer-vs-writer increments). */ + printf("rc_global_stats_race: ran %d threads x %d cycles\n", + NTHREADS, NCYCLES); + return 0; +} diff --git a/crates/ail/tests/embed_rc_global_stats_race.rs b/crates/ail/tests/embed_rc_global_stats_race.rs new file mode 100644 index 0000000..838d2da --- /dev/null +++ b/crates/ail/tests/embed_rc_global_stats_race.rs @@ -0,0 +1,111 @@ +//! 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 = None; + let mut frees: Option = 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:?}"); +}