From 1be6e49b4fde1fd4645d2a70832e6aa10722d0ff Mon Sep 17 00:00:00 2001 From: Brummel Date: Tue, 2 Jun 2026 13:31:54 +0200 Subject: [PATCH] test(codegen): RED pin for owned-param leak in tail-recursion (#63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../tail_recur_owned_param_no_leak_pin.rs | 124 ++++++++++++++++++ .../tail_recur_owned_param_no_leak_pin.ail | 17 +++ 2 files changed, 141 insertions(+) create mode 100644 crates/ail/tests/tail_recur_owned_param_no_leak_pin.rs create mode 100644 examples/tail_recur_owned_param_no_leak_pin.ail diff --git a/crates/ail/tests/tail_recur_owned_param_no_leak_pin.rs b/crates/ail/tests/tail_recur_owned_param_no_leak_pin.rs new file mode 100644 index 0000000..c227c30 --- /dev/null +++ b/crates/ail/tests/tail_recur_owned_param_no_leak_pin.rs @@ -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 = 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, + "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})" + ); +} diff --git a/examples/tail_recur_owned_param_no_leak_pin.ail b/examples/tail_recur_owned_param_no_leak_pin.ail new file mode 100644 index 0000000..ec96087 --- /dev/null +++ b/examples/tail_recur_owned_param_no_leak_pin.ail @@ -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))))))))