diff --git a/crates/ail/tests/match_arm_consume_split_no_leak_pin.rs b/crates/ail/tests/match_arm_consume_split_no_leak_pin.rs new file mode 100644 index 0000000..d6ed81d --- /dev/null +++ b/crates/ail/tests/match_arm_consume_split_no_leak_pin.rs @@ -0,0 +1,143 @@ +//! RED-pin for leg 3 of bug #63 — the per-arm-vs-per-fn consume- +//! accounting leak at a match fall-through (the last open leg of the +//! Series milestone #61 leak tail). +//! +//! Property protected: under `--alloc=rc`, when an `(own ...)` heap +//! param is consumed on ONE match arm but is LIVE (un-consumed) on a +//! SIBLING fall-through arm, it MUST be dropped on that sibling arm +//! before the fn returns. 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 +//! consume-accounting path, not a Series-specific surface. The shape +//! is the SMALLEST that reproduces — a plain (non-recursive) `match` +//! is enough; tail recursion is not required. `run` takes an +//! `(own Box) b` and an `(own Flag) f`, matches on `f`: the `On` arm +//! consumes `b` (forwards it into the consuming call `sink`), the +//! `Off` arm returns without touching `b`. `main` calls `run b Off`, +//! so the `Off` (fall-through) arm runs and `b` stays live. +//! +//! Root cause: the fn-return Own-param dec in +//! `crates/ailang-codegen/src/lib.rs` (~line 1484) and the +//! pre-tail-call Own-param dec in +//! `crates/ailang-codegen/src/match_lower.rs` (~line 823) both gate +//! the drop on `consume_count != 0 -> skip`, reading the PER-FN +//! AGGREGATE consume map (`self.consume`, keyed `(def, binder)`). For +//! `b` that aggregate is `1` — the `On` arm consumed it — so every +//! drop site skips `b` entirely, including the `Off` arm where `b` is +//! genuinely live. The aggregate is the worst-case-path `max` of the +//! arm consume counts, computed by `merge_states` in +//! `crates/ailang-check/src/uniqueness.rs` (~line 480); the per-arm / +//! per-path consume information is destroyed there before it ever +//! reaches MIR (`MArm` carries only `{ pat, body }`; `MirDef.consume` +//! is the per-fn aggregate). So codegen has no per-arm liveness to +//! gate a fall-through drop on. +//! +//! DOUBLE-FREE HAZARD (central): the aggregate gate is conservative +//! precisely to avoid double-dropping a param that WAS consumed. The +//! emitted IR for `run` drops only the scrutinee `f` at the fn-return +//! join `mjoin`, never `b`; the `On` arm already consumed `b` into +//! `sink`. Any fix MUST drop `b` ONLY on the arm where it stays live +//! (the `Off` arm block), never at the post-`match` join (a merge +//! point that cannot tell the consumed path from the live path) and +//! never on the `On` arm — or it introduces a double-free. +//! +//! As of HEAD this test fails: the binary reports +//! `ailang_rc_stats: allocs=2 frees=1 live=1` — the `Box` param `b` +//! leaks once because the `Off` arm never drops it. +//! +//! The leg-1 (`tail_recur_owned_param_no_leak_pin.rs`) and leg-2 +//! (`tail_recur_let_wrapped_no_leak_pin.rs`) pins guard params whose +//! AGGREGATE `consume_count == 0` (consumed on no arm). This pin is +//! intentionally distinct: the param here is consumed on one arm and +//! live on another, so the aggregate is `>= 1` and the existing gates +//! cannot express it. Do not collapse the three. + +use std::path::Path; +use std::process::Command; + +fn ail_bin() -> &'static str { + env!("CARGO_BIN_EXE_ail") +} + +// IGNORED: leg 3 of #63 is a design fork, not a contained fix. Per-arm +// consume liveness is destroyed by the worst-case `max` merge in +// uniqueness.rs (`merge_states`) before MIR, so codegen has no per-arm +// information to gate a fall-through drop on. Closing this leg needs a +// memory-model change (retain per-arm consume, thread it to codegen, +// emit the drop only on the live arm — never at the join or the +// consuming arm) and a fresh brainstorm cycle. Un-ignore when that +// ships. The assertion below is a correct symptom pin (live == 0); only +// the fix mechanism is undecided. +#[ignore = "leg 3 of #63 — design fork (per-arm consume dataflow); un-ignore when fixed"] +#[test] +fn alloc_rc_owned_param_consumed_on_one_arm_live_on_sibling_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("match_arm_consume_split_no_leak_pin.ail"); + let tmp = std::env::temp_dir().join(format!( + "ailang_match_arm_consume_split_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 match_arm_consume_split_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 (own) heap param consumed on one match arm but live on a \ + sibling fall-through arm leaks {live} slab(s) \ + (allocs={allocs} frees={frees}): the fn-return Own-param dec \ + in crates/ailang-codegen/src/lib.rs (~line 1484) and the \ + pre-tail-call dec in match_lower.rs (~line 823) both gate on \ + the PER-FN AGGREGATE consume_count (>= 1 here, from the \ + consuming arm) instead of per-arm liveness, so the param is \ + never dropped on the arm where it stays live." + ); + assert_eq!( + allocs, frees, + "alloc/free mismatch (allocs={allocs} frees={frees} live={live})" + ); +} diff --git a/examples/match_arm_consume_split_no_leak_pin.ail b/examples/match_arm_consume_split_no_leak_pin.ail new file mode 100644 index 0000000..08da7d9 --- /dev/null +++ b/examples/match_arm_consume_split_no_leak_pin.ail @@ -0,0 +1,22 @@ +(module match_arm_consume_split_no_leak_pin + (data Box (ctor B (con Int))) + (data Flag (ctor On) (ctor Off)) + (fn sink + (type (fn-type (params (own (con Box))) (ret (own (con Unit))) (effects IO))) + (params b) + (body (match b (case (pat-ctor B n) (do io/print_str "x"))))) + (fn run + (type (fn-type (params (own (con Box)) (own (con Flag))) (ret (own (con Unit))) (effects IO))) + (params b f) + (body + (match f + (case (pat-ctor Off) (do io/print_str "")) + (case (pat-ctor On) (app sink b))))) + (fn main + (type (fn-type (params) (ret (own (con Unit))) (effects IO))) + (params) + (body + (let b (term-ctor Box B 7) + (seq + (app run b (term-ctor Flag Off)) + (do io/print_str "done\n"))))))