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.
This commit is contained in:
2026-05-19 01:50:36 +02:00
parent b724cd17a1
commit 427b687b95
2 changed files with 171 additions and 0 deletions
@@ -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 <stddef.h>
#include <stdio.h>
#include <pthread.h>
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;
}
@@ -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<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:?}");
}