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})"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
(module loop_recur_str_binder_no_leak_pin
|
||||
; RED for bug #49 (Str leg): a heap-Str loop binder that `recur`
|
||||
; REPLACES with a fresh heap value each iteration. The seed is the
|
||||
; static literal "x"; each `recur` rebinds `acc` to a FRESH heap slab
|
||||
; returned by `str_concat`. Codegen's Term::Recur arm
|
||||
; (crates/ailang-codegen/src/lib.rs) decs the superseded prior binder
|
||||
; value only under the gate `is_ptr && !is_str`; the `!is_str` clause
|
||||
; excludes a Str binder entirely, so the plain `store` overwrites the
|
||||
; prior str_concat slab without RC-dec'ing it, leaking it every
|
||||
; iteration.
|
||||
;
|
||||
; The companion ADT fixture (loop_recur_heap_binder_no_leak_pin.ail,
|
||||
; commit f488d31) is the SAME leak class WITH the dec emitted; the only
|
||||
; difference is the `!is_str` clause. The Str exclusion was a
|
||||
; conservatism about the static seed literal: the 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. But the intermediate
|
||||
; str_concat results ARE heap slabs that must be dec'd.
|
||||
;
|
||||
; allocs: three str_concat results (acc="xy","xyy","xyyy") = 3 heap
|
||||
; slabs (the seed "x" is a static literal, not counted as an alloc).
|
||||
; The loop returns acc="xyyy"; that FINAL result is consumed by print.
|
||||
; The two superseded heap results ("xy", "xyy") plus the final "xyyy"
|
||||
; after print must all be freed. Pre-fix: live=3. Post-fix: live=0.
|
||||
; Expected stdout: xyyy.
|
||||
(fn main (type (fn-type (params) (ret (con Unit)) (effects IO))) (params)
|
||||
(body
|
||||
(let s
|
||||
(loop (acc (con Str) "x") (i (con Int) 0)
|
||||
(if (app ge i 3)
|
||||
acc
|
||||
(recur (app str_concat acc "y") (app + i 1))))
|
||||
(app print s)))))
|
||||
Reference in New Issue
Block a user