test(codegen): RED pin for owned-param leak in tail-recursion (#63)
An owned heap ADT param threaded through a tail-recursive fn whose base-case arm neither forwards nor consumes it is never RC-dec'd, so it leaks one slab per recursion step. Series-free minimal repro (a plain `Box` ADT, no Series/RawBuf), so the pin guards the GENERAL memory-model invariant rather than the Series surface that first surfaced it (examples/series_sma.ail, live=8). The pin builds the fixture with --alloc=rc, runs it under AILANG_RC_STATS=1, and asserts live == 0 (allocs == frees). RED today: `allocs=6 frees=4 live=2`. The fixture passes the replacement ctor directly into the tail call (not let-bound) to isolate leg 1 — the owned-param drop omission — from the second, distinct leg the debugger found (a `let`-wrapped tail call also defeats the scrutinee-husk shallow-dec gate; that leg is what pushes box_ctrl to live=4 and series_sma to live=8, and gets its own pin once leg 1 is green). Cause (debugger, for the GREEN side): the fn-return Own-param dec in crates/ailang-codegen/src/lib.rs (~1444) is gated behind `!block_terminated`, so it fires only on the fall-through ret path; crates/ailang-codegen/src/match_lower.rs (~805) drops only pattern binders, never the fn's owned params. A tail-call-terminated match arm thus has no drop site for an owned param with consume_count==0 that is not forwarded into the tail call. refs #63
This commit is contained in:
@@ -0,0 +1,124 @@
|
|||||||
|
//! RED-pin for the general owned-parameter drop-soundness leak across a
|
||||||
|
//! (tail-)recursive fn whose base-case arm does not consume that param
|
||||||
|
//! (bug #63 — the open tail of the Series milestone #61).
|
||||||
|
//!
|
||||||
|
//! Property protected: under `--alloc=rc`, when an owned heap ADT value
|
||||||
|
//! is passed as an `(own ...)` parameter to a tail-recursive fn, and a
|
||||||
|
//! recursive arm neither forwards that param into the tail call nor
|
||||||
|
//! otherwise consumes it (here `run` rebuilds a fresh `Box` from the
|
||||||
|
//! head element and passes THAT, dropping the incoming `b` on the
|
||||||
|
//! floor), the superseded owned param MUST be RC-dec'd before the
|
||||||
|
//! `musttail call`. The runtime RC stats line at exit must report
|
||||||
|
//! `live == 0` (equivalently `allocs == frees`) even though stdout is
|
||||||
|
//! already correct.
|
||||||
|
//!
|
||||||
|
//! This is deliberately Series/RawBuf-FREE: a plain user ADT (`Box`)
|
||||||
|
//! leaks identically, so the pin guards the GENERAL memory-model drop
|
||||||
|
//! path, not a Series-specific surface. A Series-based pin would let a
|
||||||
|
//! Series-only patch pass while the real bug survives.
|
||||||
|
//!
|
||||||
|
//! Root cause: codegen never emits an owned-param drop on a tail-call-
|
||||||
|
//! terminated match arm. The fn-return Own-param dec
|
||||||
|
//! (`crates/ailang-codegen/src/lib.rs` ~line 1444) is gated behind
|
||||||
|
//! `if !self.block_terminated` (~line 1437) and so only runs on the
|
||||||
|
//! fall-through `ret` (base-case join) path; the per-arm drop in
|
||||||
|
//! `crates/ailang-codegen/src/match_lower.rs` (~line 805) iterates
|
||||||
|
//! only `arm_pushed_binders` (pattern binders), never the fn's owned
|
||||||
|
//! params. The pre-tail-call shallow-dec at match_lower.rs ~line 721
|
||||||
|
//! drops the SCRUTINEE husk only. So an owned param with
|
||||||
|
//! `consume_count == 0` that is not forwarded into the tail call has
|
||||||
|
//! NO drop site on the recursive arm and leaks one slab per recursion.
|
||||||
|
//!
|
||||||
|
//! As of HEAD this test fails: stderr reports
|
||||||
|
//! `ailang_rc_stats: allocs=6 frees=4 live=2` — the incoming `Box`
|
||||||
|
//! param leaks once per `ICons` element (list `[1,2]` → 2 leaks).
|
||||||
|
//!
|
||||||
|
//! Scope note: this fixture passes `(term-ctor Box B v)` DIRECTLY as
|
||||||
|
//! the tail-call arg (so the scrutinee-husk shallow-dec DOES fire and
|
||||||
|
//! `arg_input` does not leak), isolating ONLY the owned-param leg. A
|
||||||
|
//! sibling shape that wraps the tail call in a `let`
|
||||||
|
//! (`(let b_new (term-ctor Box B v) (tail-app run b_new rest))`)
|
||||||
|
//! exhibits a SECOND, distinct leak: the `let`-wrapped tail call makes
|
||||||
|
//! `arm.body` an `MTerm::Let` rather than `MTerm::App { tail: true }`,
|
||||||
|
//! so `arm_body_is_tail_call` is false and the scrutinee-husk
|
||||||
|
//! shallow-dec is skipped too — pushing the canonical `box_ctrl` repro
|
||||||
|
//! and `examples/series_sma.ail` to `live=4`/`live=8`. That second leg
|
||||||
|
//! is filed as a concern on this diagnosis; the minimal correct fix to
|
||||||
|
//! the owned-param leg should be made first.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn ail_bin() -> &'static str {
|
||||||
|
env!("CARGO_BIN_EXE_ail")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn alloc_rc_owned_param_not_forwarded_in_tail_recursion_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("tail_recur_owned_param_no_leak_pin.ail");
|
||||||
|
let tmp = std::env::temp_dir().join(format!(
|
||||||
|
"ailang_tail_recur_owned_param_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 tail_recur_owned_param_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,
|
||||||
|
"an owned (own (con Box)) param that a tail-recursive arm \
|
||||||
|
neither forwards into the tail call nor consumes leaks {live} \
|
||||||
|
slab(s) (allocs={allocs} frees={frees}); codegen emits no \
|
||||||
|
owned-param drop on a tail-call-terminated match arm — the \
|
||||||
|
fn-return Own-param dec in crates/ailang-codegen/src/lib.rs \
|
||||||
|
(~line 1444) is gated behind `!block_terminated`, and \
|
||||||
|
match_lower.rs (~line 805) drops only pattern binders, never \
|
||||||
|
the fn's owned params."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
allocs, frees,
|
||||||
|
"alloc/free mismatch (allocs={allocs} frees={frees} live={live})"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
(module tail_recur_owned_param_no_leak_pin
|
||||||
|
(data Box (ctor B (con Int)))
|
||||||
|
(data IntList (ctor INil) (ctor ICons (con Int) (con IntList)))
|
||||||
|
(fn run
|
||||||
|
(type (fn-type (params (own (con Box)) (own (con IntList))) (ret (own (con Unit))) (effects IO)))
|
||||||
|
(params b input)
|
||||||
|
(body
|
||||||
|
(match input
|
||||||
|
(case (pat-ctor INil) (do io/print_str "done\n"))
|
||||||
|
(case (pat-ctor ICons v rest)
|
||||||
|
(tail-app run (term-ctor Box B v) rest)))))
|
||||||
|
(fn main
|
||||||
|
(type (fn-type (params) (ret (own (con Unit))) (effects IO)))
|
||||||
|
(params)
|
||||||
|
(body
|
||||||
|
(let b (term-ctor Box B 0)
|
||||||
|
(app run b (term-ctor IntList ICons 1 (term-ctor IntList ICons 2 (term-ctor IntList INil))))))))
|
||||||
Reference in New Issue
Block a user