7ae92d3e60
closes #60 The fixture-corpus filter (NON_PARSEABLE_FIXTURES + list_ail_fixtures) was copy-pasted across three test crates and drifted out of sync as new reject fixtures landed; the examples_dir / workspace_root path walk was reimplemented ~30 more times across the suite, under two spellings (workspace_root / ws_root). Introduce crates/ailang-test-support — a zero-dependency, dev-only leaf crate — as the single home for these helpers: - NON_PARSEABLE_FIXTURES, list_ail_fixtures - workspace_root, examples_dir - canonical_workspace_root (symlink-resolved variant, for the tsan/ race tests that feed the root into native build steps) All integration-test copies across ailang-core, ailang-surface, ailang-prose, ailang-check, and ail now import from the shared crate; the local definitions and their now-orphaned path imports are removed. Path helpers resolve via the support crate's own CARGO_MANIFEST_DIR, so they return the correct absolute paths from any caller. ail_bin helpers are intentionally left in place: they depend on env!("CARGO_BIN_EXE_ail"), which Cargo only defines when compiling the ail crate's own integration tests, so they cannot move to a shared crate. Behaviour-identical: same paths, same fixture lists; full workspace test suite green. Net -177 LOC.
157 lines
6.9 KiB
Rust
157 lines
6.9 KiB
Rust
//! Embedding-ABI M3 two-record-param round-trip — coverage backfill
|
||
//! for an already-shipped capability (NO language/checker/codegen
|
||
//! change; these tests pass on HEAD by design — that is the proof).
|
||
//!
|
||
//! Named property protected: the M3 export gate accepts a
|
||
//! single-constructor all-scalar record *independently per parameter*
|
||
//! (`crates/ailang-check/src/lib.rs:1953` — `is_c_abi_type` in a
|
||
//! `for p in ¶m_tys` loop), and the staticlib forwarder maps every
|
||
//! non-scalar `Type::Con` to a bare `ptr` (M3-frozen). Therefore a
|
||
//! kernel `(State, Tick) -> State` where BOTH params are single-ctor
|
||
//! all-scalar records is callable from a C host today — a record
|
||
//! `Tick` pushed per call, not just a scalar sample. Every shipped M3
|
||
//! fixture pushes a *scalar* `Float`; none pinned the two-record-param
|
||
//! per-tick shape (the actual minimal data-server binding). This does.
|
||
//!
|
||
//! `own` (no -DBORROW): the kernel consumes BOTH `(own (con State))`
|
||
//! and `(own (con Tick))` inputs (drop at return); the host's
|
||
//! `make_state`/`make_tick` + final `dec` land on `g_rc_*`.
|
||
//! `borrow` (-DBORROW): `Tick` is `(borrow (con Tick))` — the kernel
|
||
//! does NOT consume it; the host retains and dec's each Tick itself.
|
||
//! Both prove "ownership follows the declared mode" — pinned for the
|
||
//! *Tick* record param both ways, mirroring the M3 `_borrow.ail`
|
||
//! precedent.
|
||
//!
|
||
//! The build/link incantation + dual-stat-line global proof mirrors
|
||
//! `embed_record_e2e.rs` verbatim (M2 TLS-ctx binds the ctx only for
|
||
//! the synchronous forwarder call, so host-side allocs/decs land on
|
||
//! `g_rc_*`, not ctx; the invariant is GLOBAL Σallocs == Σfrees
|
||
//! across *all* stat lines, not per-ctx-line balance).
|
||
|
||
use ailang_test_support::workspace_root;
|
||
use std::path::PathBuf;
|
||
use std::process::Command;
|
||
|
||
fn ail_bin() -> &'static str { env!("CARGO_BIN_EXE_ail") }
|
||
fn manifest() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) }
|
||
|
||
#[derive(Debug)]
|
||
struct RcStats {
|
||
allocs: u64,
|
||
frees: u64,
|
||
exit_code: i32,
|
||
}
|
||
|
||
/// Build `<fixture>` as a staticlib, link `<host_rel>` (under
|
||
/// `crates/ail/tests/`) with `extra_cc` flags, run under
|
||
/// `AILANG_RC_STATS=1`, and return the summed RC-stats readback.
|
||
fn build_link_run_embed(fixture: &str, host_rel: &str, extra_cc: &[&str]) -> RcStats {
|
||
let module = fixture.strip_suffix(".ail").expect("fixture is a .ail file");
|
||
let fixture_path = workspace_root().join("examples").join(fixture);
|
||
let host_c = manifest().join("tests").join(host_rel);
|
||
let outdir = std::env::temp_dir().join(format!(
|
||
"ail-embed-tick-e2e-{}-{}",
|
||
module,
|
||
std::process::id()
|
||
));
|
||
std::fs::create_dir_all(&outdir).unwrap();
|
||
|
||
let build = Command::new(ail_bin())
|
||
.args(["build", fixture_path.to_str().unwrap(),
|
||
"--emit=staticlib", "-o", outdir.to_str().unwrap()])
|
||
.output().expect("ail build --emit=staticlib");
|
||
assert!(build.status.success(),
|
||
"staticlib build failed: {}",
|
||
String::from_utf8_lossy(&build.stderr));
|
||
|
||
let host_bin = outdir.join("host");
|
||
let mut cc = Command::new("cc");
|
||
cc.arg(&host_c);
|
||
for f in extra_cc { cc.arg(f); }
|
||
cc.arg(outdir.join(format!("lib{module}.a")))
|
||
.arg(outdir.join("libailang_rt.a"))
|
||
.arg("-o").arg(&host_bin);
|
||
let cc_out = cc.output().expect("cc host link");
|
||
assert!(cc_out.status.success(),
|
||
"cc link failed: {}", String::from_utf8_lossy(&cc_out.stderr));
|
||
|
||
let run = Command::new(&host_bin)
|
||
.env("AILANG_RC_STATS", "1")
|
||
.output().expect("run host");
|
||
let stderr = String::from_utf8_lossy(&run.stderr);
|
||
// GLOBAL leak-freedom (mirrors embed_record_e2e.rs): the run emits
|
||
// TWO `ailang_rc_stats:` lines — the `ailang_ctx_free` ctx readback
|
||
// AND the `g_rc_*` atexit line. M2's TLS-ctx binds the ctx only for
|
||
// the synchronous forwarder call, so the host's `make_state` /
|
||
// `make_tick` / per-iter `dec` / final `dec` land on `g_rc_*`. The
|
||
// invariant is Σallocs == Σfrees across *all* stat lines
|
||
// (equivalently Σ`live` = 0) — not per-ctx-line balance.
|
||
let mut allocs: u64 = 0;
|
||
let mut frees: u64 = 0;
|
||
let mut seen = 0usize;
|
||
for line in stderr.lines().filter(|l| l.starts_with("ailang_rc_stats:")) {
|
||
seen += 1;
|
||
for tok in line.split_whitespace() {
|
||
if let Some(v) = tok.strip_prefix("allocs=") {
|
||
allocs += v.parse::<u64>().expect("allocs= u64");
|
||
} else if let Some(v) = tok.strip_prefix("frees=") {
|
||
frees += v.parse::<u64>().expect("frees= u64");
|
||
}
|
||
}
|
||
}
|
||
assert!(seen > 0, "missing ailang_rc_stats line; stderr was:\n{stderr}");
|
||
RcStats {
|
||
allocs,
|
||
frees,
|
||
exit_code: run.status.code().unwrap_or(-1),
|
||
}
|
||
}
|
||
|
||
/// own two-record-param: a `(own (con State), own (con Tick)) ->
|
||
/// State` kernel is callable from C today — the export gate accepts
|
||
/// the second single-ctor all-scalar record param independently, and
|
||
/// the forwarder lowers BOTH to `ptr`. Globally leak-free: every
|
||
/// State and Tick box freed exactly once (kernel-consumed inputs on
|
||
/// ctx; host `make_state`/`make_tick`/final `dec` on `g_rc_*`),
|
||
/// Σallocs == Σfrees across both stat lines, and the C host's
|
||
/// `assert(n == 1000000) && assert(acc == Σ prices)` held (exit 0).
|
||
#[test]
|
||
fn tick_roundtrip_own_alloc_eq_free() {
|
||
let stats = build_link_run_embed(
|
||
"embed_backtest_step_tick.ail",
|
||
"embed/tick_roundtrip.c",
|
||
&[],
|
||
);
|
||
assert_eq!(stats.allocs, stats.frees,
|
||
"own two-record-param: globally leak-free — Σallocs == Σfrees \
|
||
across the ctx readback + g_rc atexit lines (kernel-consumed \
|
||
State+Tick on ctx; host make_state/make_tick/final dec on \
|
||
g_rc); {stats:?}");
|
||
assert_eq!(stats.exit_code, 0,
|
||
"C host assert(n==1000000 && acc==Σ prices) held");
|
||
}
|
||
|
||
/// borrow Tick param: with `Tick` declared `(borrow (con Tick))` the
|
||
/// kernel does NOT consume it — ownership follows the declared mode
|
||
/// for a *record* param, the second direction of the M3 `_borrow`
|
||
/// precedent. The host retains and dec's each Tick itself. Globally
|
||
/// leak-free: ctx line shows kernel return allocs (live=+N), g_rc the
|
||
/// host input/Tick/return decs (live=−N), summing to 0 — correct
|
||
/// M2-TLS cross-attribution, not a leak. Σallocs == Σfrees across
|
||
/// both lines. Same host, `-DBORROW`.
|
||
#[test]
|
||
fn tick_roundtrip_borrow_alloc_eq_free() {
|
||
let stats = build_link_run_embed(
|
||
"embed_backtest_step_tick_borrow.ail",
|
||
"embed/tick_roundtrip.c",
|
||
&["-DBORROW"],
|
||
);
|
||
assert_eq!(stats.allocs, stats.frees,
|
||
"borrow Tick: globally leak-free — Σallocs == Σfrees across the \
|
||
ctx readback (kernel return allocs, live=+N) + g_rc atexit \
|
||
(host State/Tick/return decs, live=−N); summed they balance; \
|
||
{stats:?}");
|
||
assert_eq!(stats.exit_code, 0,
|
||
"C host assert(n==1000000 && acc==Σ prices) held");
|
||
}
|