fix(codegen): dec superseded heap loop-binder values across recur (ADT leg)
A heap value threaded through a `(loop ...)`/`recur` as a loop binder
leaked: the `Term::Recur` arm stored each new arg into the binder's
loop-carried alloca with a plain `store` and never RC-dec'd the heap
value already in that slot, so every iteration's superseded value was
lost. The scope-close drop path (drop.rs, commit 8bcdae1) only ever
sees the loop's FINAL result, never the intermediates.
The recur store now decs the prior value before overwriting it, gated
by the SAME predicate the scope-close path uses — RC-heap AND lowers
to `ptr` AND not `Str`. The binder slot tuple is widened to carry the
binder's AILang type so the gate and the typed drop-symbol lookup
(`field_drop_call`) are available at the store. A same-pointer guard
(`icmp eq prior, new`) skips the dec when a `recur` threads the
identical pointer back — the own->own case (RawBuf fill loops, where
`RawBuf.set` mutates in place), so a retained buffer is never freed.
Scope: the boxed-ADT leg only. Every ADT value is heap-allocated, so
dec'ing a superseded ADT binder is unambiguously UB-free. A `Str`
accumulator is deliberately NOT covered: `Str` literals lower to
constexpr-GEPs into static globals (no rc_header), so a `Str` loop
seed may be static and dec'ing it would be UB. That is the same
static-vs-heap-`Str` obstacle behind 8bcdae1's deliberate `Str`
exclusion; the `Str` accumulator leak remains open in #49 pending a
runtime representation decision.
RED test in crates/ail/tests/loop_recur_heap_binder_no_leak_pin.rs
threads a boxed `Cell` through three recurs: pre-fix allocs=5 frees=2
live=3, post-fix live=0, stdout 2. Verified: zero-recur ADT control
live=0 (no spurious dec), Int loops (isqrt/collatz/gcd) live=0
(non-ptr, no dec), RawBuf fill loops live=0 out 35/16 (same-pointer
guard), Str accumulator unchanged (live=3, no crash).
refs #49
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
//! RED-pin for the per-iteration heap loop-binder leak across `recur`
|
||||
//! (bug #49).
|
||||
//!
|
||||
//! Property protected: under `--alloc=rc`, when a `(loop ...)` carries a
|
||||
//! heap-valued loop binder (here a boxed-ADT `Cell` accumulator) and a
|
||||
//! `recur` REPLACES that binder with a fresh heap value, the superseded
|
||||
//! prior value MUST be RC-decremented at the `recur` store. The runtime
|
||||
//! RC stats line at exit must report `live == 0` (equivalently
|
||||
//! `allocs == frees`) even though stdout is already correct.
|
||||
//!
|
||||
//! Root cause: codegen's `Term::Recur` arm in
|
||||
//! `crates/ailang-codegen/src/lib.rs` (~line 2126) stores each new arg
|
||||
//! value into the binder alloca with a plain `store` and never emits an
|
||||
//! RC-dec for the heap value already in that slot. Each `recur` over a
|
||||
//! heap binder therefore leaks the value it overwrites. This is DISTINCT
|
||||
//! from the scope-close drop path in `crates/ailang-codegen/src/drop.rs`
|
||||
//! (commit 8bcdae1), which drops only the loop's FINAL result — the
|
||||
//! intermediate per-iteration values it never sees.
|
||||
//!
|
||||
//! A boxed ADT (not a `Str`) is the deliberate trigger: every ADT value
|
||||
//! is heap-allocated, so dec'ing the superseded value is unambiguously
|
||||
//! UB-free. `Str` literals lower to static globals, so a `Str` loop seed
|
||||
//! must NOT be dec'd; the Str accumulator case is the separate
|
||||
//! static-vs-heap-Str representation question (the obstacle behind the
|
||||
//! deliberate Str exclusion in commit 8bcdae1), out of scope for this pin.
|
||||
//!
|
||||
//! The fixture threads a `Cell` through three `recur`s. As of HEAD this
|
||||
//! test fails: stderr reports `ailang_rc_stats: allocs=4 frees=1 live=3`
|
||||
//! — the three superseded `Cell` slabs leak. RawBuf loop binders do NOT
|
||||
//! exhibit this leak because `RawBuf.set` mutates in place (own->own, no
|
||||
//! per-iteration alloc), so their binder is never replaced; the trigger
|
||||
//! requires a binder that a `recur` rebinds to a *fresh* heap allocation.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
fn ail_bin() -> &'static str {
|
||||
env!("CARGO_BIN_EXE_ail")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alloc_rc_heap_loop_binder_replaced_by_recur_does_not_leak() {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
|
||||
let src = workspace
|
||||
.join("examples")
|
||||
.join("loop_recur_heap_binder_no_leak_pin.ail");
|
||||
let tmp = std::env::temp_dir().join(format!(
|
||||
"ailang_loop_recur_heap_binder_no_leak_pin_{}",
|
||||
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 loop_recur_heap_binder_no_leak_pin.ail"
|
||||
);
|
||||
let output = Command::new(&out)
|
||||
.env("AILANG_RC_STATS", "1")
|
||||
.output()
|
||||
.expect("execute binary");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"binary exited non-zero: status {:?}",
|
||||
output.status
|
||||
);
|
||||
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 in stderr; 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();
|
||||
}
|
||||
}
|
||||
let allocs = allocs.expect("missing allocs= field");
|
||||
let frees = frees.expect("missing frees= field");
|
||||
let live = live.expect("missing live= field");
|
||||
assert_eq!(
|
||||
live, 0,
|
||||
"a heap (boxed-ADT) 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 (~line \
|
||||
2126) stores the new binder value without RC-dec'ing the prior \
|
||||
heap value it overwrites."
|
||||
);
|
||||
assert_eq!(
|
||||
allocs, frees,
|
||||
"alloc/free mismatch (allocs={allocs} frees={frees} live={live})"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user