test(codegen): RED pin for let-wrapped tail-call leak (#63 leg 2)

After the leg-1 fix (68f500c), a tail call wrapped in a trailing
`(let ...)` (or `seq`) still leaks: the arm body is an MTerm::Let, not
a direct App{tail:true}, so `arm_body_is_tail_call` (match_lower.rs
~717) is false and both the scrutinee-husk shallow-dec and the leg-1
owned-param dec are skipped — while lower_term still descends through
the Let body and emits the inner call as musttail. The scrutinee husk
and the non-forwarded owned param each leak one slab per recursion
step.

Series-free minimal repro (a plain `Box` whose replacement is
let-bound before the tail call), asserting live == 0 under
AILANG_RC_STATS=1. RED today: `allocs=6 frees=2 live=4`. This is the
leg that keeps examples/series_sma.ail at live=8 (its canonical
`(let s_new (push …) (seq … (tail-app …)))` body).

The debugger confirmed the fix is contained, not a design fork:
`arm_body_is_tail_call` feeds only the two drop-emission gates, never
the musttail-emission decision (which lives independently in
lower_term off App{tail:true}), so peeling the detection through a
trailing Let/Seq chain ending in a tail call is safe. The let
scope-close drop is gated on `!block_terminated` and so is correctly
skipped, avoiding a double-free of the let binding.

refs #63
This commit is contained in:
2026-06-02 13:46:06 +02:00
parent 68f500c4b0
commit 2488d9d345
2 changed files with 136 additions and 0 deletions
@@ -0,0 +1,118 @@
//! RED-pin for leg 2 of bug #63 — the let/seq-wrapped tail-call leak
//! (the last open leg of the Series milestone #61 leak tail).
//!
//! Property protected: under `--alloc=rc`, when a tail-recursive arm's
//! tail call is *wrapped* in a trailing `(let ...)` (and/or `(seq ...)`)
//! rather than being the arm body directly, the two pre-tail-call
//! drops MUST still fire:
//! 1. the scrutinee-husk shallow-dec (the moved-from outer cell of the
//! matched ctor), and
//! 2. the leg-1 owned-param dec (an `(own ...)` param the arm neither
//! forwards into the tail call nor otherwise consumes — here `run`
//! rebuilds a fresh `Box` and passes THAT, dropping the incoming
//! `b` on the floor).
//! 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 through let/seq-wrapped tail calls, not a Series-specific
//! surface.
//!
//! Root cause: in `crates/ailang-codegen/src/match_lower.rs` the local
//! `arm_body_is_tail_call` (~line 717) matches only a *direct*
//! `MTerm::App { tail: true }` / `MTerm::Do { tail: true }` arm body. A
//! tail call wrapped in a trailing `let`/`seq` gives an arm body of
//! `MTerm::Let` / `MTerm::Seq`, so the gate is false and BOTH the
//! husk shallow-dec (~line 721) and the leg-1 owned-param dec
//! (~line 779) are skipped — yet `lower_term` still descends through
//! the `Let`/`Seq` body and emits the inner call as `musttail`. The
//! scrutinee husk and the non-forwarded owned `Box` param therefore
//! have no drop site on the recursive arm and leak one slab each per
//! recursion step.
//!
//! As of HEAD this test fails: the binary reports
//! `ailang_rc_stats: allocs=6 frees=2 live=4` for the list `[1,2]`
//! (two `ICons` husks + two superseded `Box` params leak; two of those
//! four slabs are the husks, two are the dropped params).
//!
//! The leg-1 pin (`tail_recur_owned_param_no_leak_pin.rs`) already
//! guards the *direct* tail-call form (`run` passes the fresh `Box`
//! inline, live=0). This pin is intentionally distinct: it isolates the
//! let-wrapped shape (leg 2). Do not collapse the two.
use std::path::Path;
use std::process::Command;
fn ail_bin() -> &'static str {
env!("CARGO_BIN_EXE_ail")
}
#[test]
fn alloc_rc_let_wrapped_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_let_wrapped_no_leak_pin.ail");
let tmp = std::env::temp_dir().join(format!(
"ailang_tail_recur_let_wrapped_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_let_wrapped_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 tail call wrapped in a trailing `let` leaks {live} slab(s) \
(allocs={allocs} frees={frees}): the arm body is `MTerm::Let`, \
so `arm_body_is_tail_call` in match_lower.rs (~line 717) is \
false and both the scrutinee-husk shallow-dec (~line 721) and \
the leg-1 owned-param dec (~line 779) are skipped, even though \
the inner call is still emitted as `musttail`."
);
assert_eq!(
allocs, frees,
"alloc/free mismatch (allocs={allocs} frees={frees} live={live})"
);
}