test(codegen): RED-pin #49 Str leg — heap-Str loop binder must not leak across recur
A heap-Str accumulator threaded through a `(loop …)`/`recur` as a loop binder leaks: each iteration's superseded `str_concat` slab is never RC-dec'd, even when the loop's final result is consumed. The fixture reports `allocs=4 frees=1 live=3` under AILANG_RC_STATS (stdout `xyyy` is correct). Root (data-flow traced): the `Term::Recur` arm in crates/ailang-codegen/src/lib.rs (~2131) gates its superseded-value dec behind `!is_str`. That exclusion exists because a `Str` loop seed may be a static-literal Str — a constexpr GEP into a packed-struct global with no rc_header (lib.rs:1612) — and `ailang_rc_dec` on a static pointer reads garbage at `payload-8` and aborts on underflow (rc.c:221, no runtime guard, per design/contracts/0011-str-abi.md). So the Str binder's prior heap slab is overwritten by a plain `store` with no dec. Distinct from the loop-RESULT scope-close drop (8bcdae1, which also excluded Str) and from the ADT leg already fixed inf488d31— this is the per-iteration leak of the *intermediate* Str binder values, whichf488d31explicitly left open pending a representation decision. RED test crates/ail/tests/loop_recur_str_binder_no_leak_pin.rs with fixture examples/loop_recur_str_binder_no_leak_pin.ail: the Str leg asserts live=0 (RED at HEAD, live=3); the ADT leg (f488d31) is re-pinned as a green guard so a regression on the shared Term::Recur dec path is caught with it. GREEN side (chosen direction): promote a static-literal Str seed to heap via str_clone at loop entry so every Str binder slot holds a heap Str with a valid rc_header, then lift the `!is_str` recur-dec exclusion. This UPHOLDS the 0011-str-abi codegen-proof invariant (extends "static Str never reaches dec" to the loop case) rather than adding a runtime guard. refs #49
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
//! RED-pin for the per-iteration heap-**Str** loop-binder leak across
|
||||
//! `recur` (bug #49, Str leg).
|
||||
//!
|
||||
//! Property protected: under `--alloc=rc`, when a `(loop ...)` carries a
|
||||
//! `Str` loop binder seeded with a literal and a `recur` REPLACES that
|
||||
//! binder with a FRESH heap value (a `str_concat` result), every
|
||||
//! superseded prior heap slab MUST be RC-decremented at the `recur`
|
||||
//! store. Even though the loop's FINAL result is correctly consumed
|
||||
//! (here by `print`), the intermediate per-iteration `str_concat` slabs
|
||||
//! leak. The runtime RC stats line at exit must report `live == 0`
|
||||
//! (equivalently `allocs == frees`).
|
||||
//!
|
||||
//! Root cause (data-flow traced): codegen's `Term::Recur` arm in
|
||||
//! `crates/ailang-codegen/src/lib.rs` (the per-binder store loop, the
|
||||
//! arm added in commit f488d31) emits the superseded-value dec only
|
||||
//! under the gate `matches!(self.alloc, Rc) && is_ptr && !is_str`. The
|
||||
//! `!is_str` clause excludes a `Str`-typed binder entirely, so the
|
||||
//! `recur` falls straight to the plain `store` and the prior
|
||||
//! `str_concat` heap slab it overwrites is never dec'd. This is the SAME
|
||||
//! leak class as the boxed-ADT leg fixed in f488d31 — the ONLY
|
||||
//! difference is the `!is_str` clause in the gate.
|
||||
//!
|
||||
//! Why the Str exclusion existed: the `Str` loop *seed* (`"x"`) may be a
|
||||
//! constexpr-GEP into a static global with no `rc_header`, so an
|
||||
//! UNCONDITIONAL dec on iteration 1 would be UB. The fix must dec the
|
||||
//! superseded value WITHOUT dec'ing a static-literal seed (the same
|
||||
//! static-vs-heap-`Str` representation question behind the deliberate
|
||||
//! `Str` exclusions in commits 8bcdae1 and f488d31).
|
||||
//!
|
||||
//! As of HEAD this test fails: stderr reports
|
||||
//! `ailang_rc_stats: allocs=4 frees=1 live=3` — the three superseded Str
|
||||
//! slabs (seed "x" plus two intermediate concat results) leak while
|
||||
//! stdout is already correct (`xyyy`).
|
||||
//!
|
||||
//! This file also re-pins the boxed-ADT leg (`alloc_rc_adt_*`) as a
|
||||
//! GREEN guard: the f488d31 fix must stay green while the Str leg is
|
||||
//! closed — fixing Str must not regress the ADT case.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
fn ail_bin() -> &'static str {
|
||||
env!("CARGO_BIN_EXE_ail")
|
||||
}
|
||||
|
||||
/// Build `examples/<fixture>.ail` under `--alloc=rc`, run it with
|
||||
/// `AILANG_RC_STATS=1`, and return `(allocs, frees, live, stdout)`.
|
||||
fn build_run_stats(fixture: &str) -> (u64, u64, i64, String) {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
|
||||
let src = workspace.join("examples").join(fixture);
|
||||
let tmp = std::env::temp_dir().join(format!(
|
||||
"ailang_{}_{}",
|
||||
fixture.replace(['/', '.'], "_"),
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
let out = tmp.join("bin");
|
||||
let status = Command::new(ail_bin())
|
||||
.args(["build", src.to_str().unwrap(), "--alloc=rc", "-o"])
|
||||
.arg(&out)
|
||||
.status()
|
||||
.expect("ail build failed to run");
|
||||
assert!(status.success(), "ail build --alloc=rc failed for {fixture}");
|
||||
let output = Command::new(&out)
|
||||
.env("AILANG_RC_STATS", "1")
|
||||
.output()
|
||||
.expect("execute binary");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"binary exited non-zero for {fixture}: status {:?}",
|
||||
output.status
|
||||
);
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
|
||||
let stderr = String::from_utf8(output.stderr).expect("stderr utf8");
|
||||
let stats_line = stderr
|
||||
.lines()
|
||||
.find(|l| l.starts_with("ailang_rc_stats:"))
|
||||
.unwrap_or_else(|| {
|
||||
panic!("missing ailang_rc_stats line for {fixture}; stderr was:\n{stderr}")
|
||||
});
|
||||
let mut allocs: Option<u64> = None;
|
||||
let mut frees: Option<u64> = None;
|
||||
let mut live: Option<i64> = None;
|
||||
for tok in stats_line.split_whitespace() {
|
||||
if let Some(v) = tok.strip_prefix("allocs=") {
|
||||
allocs = v.parse().ok();
|
||||
} else if let Some(v) = tok.strip_prefix("frees=") {
|
||||
frees = v.parse().ok();
|
||||
} else if let Some(v) = tok.strip_prefix("live=") {
|
||||
live = v.parse().ok();
|
||||
}
|
||||
}
|
||||
(
|
||||
allocs.expect("missing allocs= field"),
|
||||
frees.expect("missing frees= field"),
|
||||
live.expect("missing live= field"),
|
||||
stdout,
|
||||
)
|
||||
}
|
||||
|
||||
/// The bug under test: a heap-Str loop binder replaced by `recur` leaks
|
||||
/// every superseded `str_concat` slab. RED at HEAD (live=3).
|
||||
#[test]
|
||||
fn alloc_rc_str_loop_binder_replaced_by_recur_does_not_leak() {
|
||||
let (allocs, frees, live, stdout) =
|
||||
build_run_stats("loop_recur_str_binder_no_leak_pin.ail");
|
||||
assert_eq!(
|
||||
stdout.trim_end(),
|
||||
"xyyy",
|
||||
"loop result mis-built (allocs={allocs} frees={frees} live={live})"
|
||||
);
|
||||
assert_eq!(
|
||||
live, 0,
|
||||
"a heap-Str loop binder replaced by `recur` leaks {live} \
|
||||
superseded slab(s) (allocs={allocs} frees={frees}); the \
|
||||
Term::Recur arm in crates/ailang-codegen/src/lib.rs gates its \
|
||||
superseded-value dec behind `!is_str`, so the prior str_concat \
|
||||
heap slab is overwritten by a plain store without RC-dec."
|
||||
);
|
||||
assert_eq!(
|
||||
allocs, frees,
|
||||
"alloc/free mismatch (allocs={allocs} frees={frees} live={live})"
|
||||
);
|
||||
}
|
||||
|
||||
/// GREEN guard: the boxed-ADT leg fixed in f488d31 must stay leak-clean
|
||||
/// while the Str leg is closed. Fixing Str must not regress this.
|
||||
#[test]
|
||||
fn alloc_rc_adt_loop_binder_replaced_by_recur_stays_leak_clean() {
|
||||
let (allocs, frees, live, stdout) =
|
||||
build_run_stats("loop_recur_heap_binder_no_leak_pin.ail");
|
||||
assert_eq!(
|
||||
stdout.trim_end(),
|
||||
"2",
|
||||
"ADT loop result mis-built (allocs={allocs} frees={frees} live={live})"
|
||||
);
|
||||
assert_eq!(
|
||||
live, 0,
|
||||
"the f488d31 boxed-ADT recur-dec regressed: live={live} \
|
||||
(allocs={allocs} frees={frees})"
|
||||
);
|
||||
assert_eq!(
|
||||
allocs, frees,
|
||||
"ADT leg alloc/free mismatch (allocs={allocs} frees={frees} live={live})"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user