From 2488d9d345471501b5cb2f65d49d3f7ab619d278 Mon Sep 17 00:00:00 2001 From: Brummel Date: Tue, 2 Jun 2026 13:46:06 +0200 Subject: [PATCH] test(codegen): RED pin for let-wrapped tail-call leak (#63 leg 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../tail_recur_let_wrapped_no_leak_pin.rs | 118 ++++++++++++++++++ .../tail_recur_let_wrapped_no_leak_pin.ail | 18 +++ 2 files changed, 136 insertions(+) create mode 100644 crates/ail/tests/tail_recur_let_wrapped_no_leak_pin.rs create mode 100644 examples/tail_recur_let_wrapped_no_leak_pin.ail diff --git a/crates/ail/tests/tail_recur_let_wrapped_no_leak_pin.rs b/crates/ail/tests/tail_recur_let_wrapped_no_leak_pin.rs new file mode 100644 index 0000000..9a5c4a1 --- /dev/null +++ b/crates/ail/tests/tail_recur_let_wrapped_no_leak_pin.rs @@ -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 = None; + let mut frees: Option = None; + let mut live: Option = 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})" + ); +} diff --git a/examples/tail_recur_let_wrapped_no_leak_pin.ail b/examples/tail_recur_let_wrapped_no_leak_pin.ail new file mode 100644 index 0000000..0f579d7 --- /dev/null +++ b/examples/tail_recur_let_wrapped_no_leak_pin.ail @@ -0,0 +1,18 @@ +(module tail_recur_let_wrapped_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) + (let b_new (term-ctor Box B v) + (tail-app run b_new 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))))))))