From 3447ac8039da559762e61e0eab4db8692ae6a4b6 Mon Sep 17 00:00:00 2001 From: Brummel Date: Tue, 2 Jun 2026 15:25:58 +0200 Subject: [PATCH] plan: branch-consume-split drop soundness (#63 leg 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five-task plan for spec 0068, decomposed RED-first: 1. RED — new if-branch fixture + pin, un-ignore the committed match pin. 2. CAPTURE — retain per-branch consume snapshots in uniqueness (new BranchConsume + infer_module_with_cross_branches; old API delegates). 3. CARRY — additive MArm.consume / MTerm::If.{then,else}_consume, attached by a traversal-order cursor in lower_to_mir (post-order pop, exhaustion assert, neutral for const bodies) + lock-step test. 4. GATE — emit_leakclass_branch_param_drops helper + :823 gate-source switch to per-arm; fall-through drops in match arms + if branches. Disjoint-by-aggregate from the unchanged fn-return dec. 5. VERIFY — series_sma live=0 pin, full workspace suite, lockspair check. All inlined code lifted from the live source bodies (uniqueness.rs, lower_to_mir.rs, match_lower.rs, lib.rs) read this session, not from memory. The one new surface fixture (if-branch) parse-checks clean; the correspondence test reuses the existing match fixture via the lower_to_mir_ty.rs elaborate_fixture/body helpers (verified present). refs #63 --- docs/plans/0126-branch-consume-split-drop.md | 988 +++++++++++++++++++ 1 file changed, 988 insertions(+) create mode 100644 docs/plans/0126-branch-consume-split-drop.md diff --git a/docs/plans/0126-branch-consume-split-drop.md b/docs/plans/0126-branch-consume-split-drop.md new file mode 100644 index 0000000..aaeb9ab --- /dev/null +++ b/docs/plans/0126-branch-consume-split-drop.md @@ -0,0 +1,988 @@ +# Branch-Consume-Split Drop Soundness (#63 leg 3) — Implementation Plan + +> **Parent spec:** `docs/specs/0068-branch-consume-split-drop.md` +> +> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` +> skill to run this plan. Steps use `- [ ]` checkboxes for tracking. + +**Goal:** Drop an `(own ...)` heap param exactly once per control-flow +path when it is consumed on one `match`-arm / `if`-branch but stays live +on a sibling — closing the last leg of #63 and the residual `live=2` +leak in `series_sma` (#61). + +**Architecture:** The uniqueness pass already computes a per-branch +consume snapshot for every `if`/`match` branch and discards it at the +`max` merge in `merge_states`. Retain those snapshots, carry them +additively on `MArm.consume` / `MTerm::If.{then,else}_consume` +(attached by `lower_to_mir` in pre-order traversal-order lock-step with +the uniqueness walk, since the AST carries no node id), and let codegen +emit a NEW fall-through drop for the leak class — `branch_consume[p] == 0 +&& aggregate >= 1`. The drop sites partition by aggregate consume +(`== 0` → existing fn-return/pre-tail-call machinery; `>= 1` → +per-branch), so no param is dropped twice and no tail-position analysis +is needed; the checker's `use-after-consume` rejection guarantees an +`aggregate >= 1` param is dead past the construct, so the per-branch +drop is path-terminal. + +**Tech Stack:** `ailang-check` (uniqueness capture + MIR lowering +cursor), `ailang-mir` (additive branch-consume fields), `ailang-codegen` +(leak-class drop helper + gate-source switch), `ail` integration pins. + +--- + +**Files this plan creates or modifies:** + +- Create: `examples/if_branch_consume_split_no_leak_pin.ail` — new RED fixture (if-branch split). +- Create: `crates/ail/tests/if_branch_consume_split_no_leak_pin.rs` — new RED pin, `live==0`. +- Create: `crates/ail/tests/series_sma_no_leak_pin.rs` — #61 close pin, `series_sma` `live==0`. +- Modify: `crates/ail/tests/match_arm_consume_split_no_leak_pin.rs:74` — remove `#[ignore]`. +- Modify: `crates/ailang-check/src/uniqueness.rs` — `BranchConsume` enum, `Walker.branch_consumes`, capture at If/Match, `UniquenessResult` + `infer_module_with_cross_branches`. +- Modify: `crates/ailang-mir/src/lib.rs:91,116` — additive fields on `MTerm::If` and `MArm`. +- Modify: `crates/ailang-check/src/lower_to_mir.rs` — `Ctx` cursor fields, attach at If/Match, switch to the branches API, exhaustion guard. +- Modify: `crates/ailang-codegen/src/escape.rs:612,737,747` — thread the new `MArm` field in test-mod constructions. +- Modify: `crates/ailang-codegen/src/match_lower.rs:823,~965,~1009` — `:823` gate-source switch; new fall-through drop in ctor-arms loop + default/open arm. +- Modify: `crates/ailang-codegen/src/lib.rs:1906` — `MTerm::If` destructure + per-branch drop in both branches; add the `emit_leakclass_branch_param_drops` helper. +- Test: `crates/ailang-check/tests/lower_to_mir_ty.rs` — lock-step correspondence test. + +--- + +### Task 1: RED — if-branch fixture + pin, un-ignore match pin + +**Files:** +- Create: `examples/if_branch_consume_split_no_leak_pin.ail` +- Create: `crates/ail/tests/if_branch_consume_split_no_leak_pin.rs` +- Modify: `crates/ail/tests/match_arm_consume_split_no_leak_pin.rs:74` + +- [ ] **Step 1: Write the if-branch RED fixture** + +Create `examples/if_branch_consume_split_no_leak_pin.ail` verbatim (this is +the spec's Concrete-code block 2; `ail check` passes today with only an +`over-strict-mode` warning on `sink`): + +``` +(module if_branch_consume_split_no_leak_pin + (data Box (ctor B (con Int))) + (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_if + (type (fn-type (params (own (con Box)) (own (con Int))) (ret (own (con Unit))) (effects IO))) + (params b k) + (body + (if (app ge k 0) + (do io/print_str "") + (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_if b 0) + (do io/print_str "done\n")))))) +``` + +- [ ] **Step 2: Write the if-branch RED pin** + +Create `crates/ail/tests/if_branch_consume_split_no_leak_pin.rs`. This is a +structural copy of the committed `match_arm_consume_split_no_leak_pin.rs` +(same build/run/parse-stats body), with the fixture name swapped and NO +`#[ignore]`. Header doc explains: `b` is consumed on the `else` branch +(`sink b`), live on the `then` branch (`ge 0 0` true → `then` runs), so +`b` stays live and must be dropped on the `then` branch: + +```rust +//! RED-pin for leg 3 of bug #63 — the per-branch-vs-per-fn consume- +//! accounting leak at an `if`-branch fall-through (the `if` half of the +//! branch-consume-split class; the `match` half is +//! match_arm_consume_split_no_leak_pin.rs). +//! +//! Property protected: under `--alloc=rc`, when an `(own ...)` heap +//! param is consumed on ONE `if` branch but is LIVE on the sibling +//! branch, it MUST be dropped on the live branch before the fn returns. +//! `run_if b 0` takes the `then` branch (`ge 0 0` is true) where `b` is +//! live; the `else` branch (`sink b`) consumes it. As of HEAD this fails: +//! the binary reports `live=1` because the per-fn aggregate consume of +//! `b` is `>= 1` (the `else` branch consumes it) so the fn-return dec +//! skips `b` and nothing drops it on the `then` path. +//! +//! `if` is a first-class `MTerm::If` (it does NOT desugar to `match`), +//! so it needs the per-branch drop in its own codegen node. Goes green +//! with the #63-leg-3 fix. + +use std::path::Path; +use std::process::Command; + +fn ail_bin() -> &'static str { + env!("CARGO_BIN_EXE_ail") +} + +#[test] +fn alloc_rc_owned_param_consumed_on_one_if_branch_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("if_branch_consume_split_no_leak_pin.ail"); + let tmp = std::env::temp_dir().join(format!( + "ailang_if_branch_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 if_branch_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 if branch but live on the \ + sibling branch leaks {live} slab(s) (allocs={allocs} frees={frees}): \ + the per-branch live param is never dropped because the fn-return \ + dec gates on the per-fn aggregate consume (>= 1 from the consuming \ + branch) instead of the per-branch consume." + ); + assert_eq!( + allocs, frees, + "alloc/free mismatch (allocs={allocs} frees={frees} live={live})" + ); +} +``` + +- [ ] **Step 3: Un-ignore the committed match pin** + +In `crates/ail/tests/match_arm_consume_split_no_leak_pin.rs`, delete line 74: + +```rust +#[ignore = "leg 3 of #63 — design fork (per-arm consume dataflow); un-ignore when fixed"] +``` + +(Leave the `#[test]` and everything else intact.) + +- [ ] **Step 4: Run the two leak pins — expect RED** + +Run: `cargo test -p ail --test if_branch_consume_split_no_leak_pin --test match_arm_consume_split_no_leak_pin` +Expected: BOTH FAIL with `live=1` (assertion `left: 1, right: 0`). This is +the RED baseline — the leak is reproduced for both constructs. + +- [ ] **Step 5: Confirm legs 1/2 still pass (regression baseline)** + +Run: `cargo test -p ail --test tail_recur_owned_param_no_leak_pin --test tail_recur_let_wrapped_no_leak_pin` +Expected: BOTH PASS (these are unaffected by Task 1; they are the +behaviour-preservation baseline for Task 4). + +--- + +### Task 2: CAPTURE — retain per-branch consume in the uniqueness pass + +**Files:** +- Modify: `crates/ailang-check/src/uniqueness.rs` + +- [ ] **Step 1: Add the `BranchConsume` type and a snapshot helper** + +After the `BinderState` struct (around line 248) add: + +```rust +/// Per-branch consume snapshots retained for codegen's leak-class drop +/// siting (#63 leg 3). Each map is the absolute per-binder +/// `consume_count` at a branch's end-state — the value the `max` merge +/// in `merge_states` would otherwise collapse. The pass emits one entry +/// per `if`/`match` construct in PRE-ORDER of branch constructs (the +/// same order `lower_to_mir` lowers them), so `lower_to_mir` correlates +/// entries to MIR branch nodes by a traversal-order cursor (the AST +/// carries no node id). +#[derive(Debug, Clone)] +pub enum BranchConsume { + If { + then: BTreeMap, + else_: BTreeMap, + }, + Match { + arms: Vec>, + }, +} + +fn snapshot_consume(binders: &BTreeMap) -> BTreeMap { + binders + .iter() + .map(|(k, v)| (k.clone(), v.consume_count)) + .collect() +} +``` + +- [ ] **Step 2: Add the accumulator field to `Walker`** + +In `struct Walker<'a>` (around line 250) add a field after `out`: + +```rust + /// Per-branch consume snapshots, accumulated in pre-order during the + /// walk (#63 leg 3). Drained per fn by `infer_fn`. + branch_consumes: Vec, +``` + +- [ ] **Step 3: Capture at `Term::If`** + +Replace the `Term::If` arm (currently lines 297-304) with: + +```rust + Term::If { cond, then, else_ } => { + self.walk(cond, Position::Consume); + let saved = self.binders.clone(); + self.walk(then, pos); + let after_then = std::mem::replace(&mut self.binders, saved); + self.walk(else_, pos); + // #63 leg 3: retain both branch end-states (pre-order push + // AFTER walking both children, so nested branches inside + // then/else are already pushed — matches lower_to_mir's + // post-order pop). + self.branch_consumes.push(BranchConsume::If { + then: snapshot_consume(&after_then), + else_: snapshot_consume(&self.binders), + }); + merge_states(&mut self.binders, &after_then); + } +``` + +- [ ] **Step 4: Capture at `Term::Match`** + +Replace the `Term::Match` arm (currently lines 305-322) with: + +```rust + Term::Match { scrutinee, arms } => { + self.walk(scrutinee, Position::Borrow); + let saved = self.binders.clone(); + let mut acc: Option> = None; + let mut arm_maps: Vec> = + Vec::with_capacity(arms.len()); + for arm in arms { + self.binders = saved.clone(); + self.walk_arm(arm, pos); + // #63 leg 3: snapshot this arm's end-state (pattern + // binders already popped by walk_arm) before the merge. + arm_maps.push(snapshot_consume(&self.binders)); + match acc.as_mut() { + None => acc = Some(self.binders.clone()), + Some(m) => merge_states(m, &self.binders), + } + } + // pre-order push AFTER all arms (nested arm-body branches + // already pushed) — matches lower_to_mir's post-order pop. + self.branch_consumes + .push(BranchConsume::Match { arms: arm_maps }); + if let Some(merged) = acc { + self.binders = merged; + } else { + self.binders = saved; + } + } +``` + +- [ ] **Step 5: Drain the accumulator per fn in `infer_fn`** + +`infer_fn` gains a `branches_out` param and drains `walker.branch_consumes`. +Change the signature (line 181-186) to: + +```rust +fn infer_fn( + f: &FnDef, + globals: &BTreeMap, + cross_module_types: &BTreeMap>, + out: &mut UniquenessTable, + branches_out: &mut BTreeMap>, +) { +``` + +Replace the `param_states` block (lines 192-214) so it also returns the +drained branch vec, and init the new `Walker` field: + +```rust + let (param_states, branch_consumes) = { + let mut walker = Walker { + globals, + cross_module_types, + binders: BTreeMap::new(), + def_name: f.name.clone(), + out, + branch_consumes: Vec::new(), + }; + + // Install fn parameters. Their consume counter starts at 0; + // the walk over the body increments it. + for name in f.params.iter() { + walker.binders.insert(name.clone(), BinderState::default()); + } + + walker.walk(&f.body, Position::Consume); + + // Snapshot params before dropping the walker. + let ps = f + .params + .iter() + .filter_map(|name| walker.binders.get(name).map(|s| (name.clone(), s.clone()))) + .collect::>(); + (ps, walker.branch_consumes) + }; +``` + +Then after the existing `for (name, s) in param_states { ... }` loop (ends +line 223), append: + +```rust + branches_out.insert(f.name.clone(), branch_consumes); +``` + +- [ ] **Step 6: Add `UniquenessResult` + `infer_module_with_cross_branches`; delegate the old API** + +Add the result type near `UniquenessTable` (after line 100): + +```rust +/// The full uniqueness output: the per-`(def, binder)` aggregate table +/// (codegen's classification + scope-close consume) AND the per-fn +/// ordered list of per-branch consume snapshots (#63 leg 3). +pub struct UniquenessResult { + pub table: UniquenessTable, + pub branches: BTreeMap>, +} +``` + +Rename the current `infer_module_with_cross` body to +`infer_module_with_cross_branches` returning `UniquenessResult`, and make +`infer_module_with_cross` a thin delegate. Concretely, change the +signature line (133-136) to: + +```rust +pub fn infer_module_with_cross_branches( + m: &Module, + cross_module_types: &BTreeMap>, +) -> UniquenessResult { +``` + +Replace the table-build tail (lines 170-177) with: + +```rust + let mut table: UniquenessTable = BTreeMap::new(); + let mut branches: BTreeMap> = BTreeMap::new(); + for def in &m.defs { + if let Def::Fn(f) = def { + infer_fn(f, &globals, cross_module_types, &mut table, &mut branches); + } + } + UniquenessResult { table, branches } +} + +/// Aggregate-only convenience used by existing callers (codegen tests, +/// etc.). Delegates to [`infer_module_with_cross_branches`] and drops the +/// per-branch channel. +pub fn infer_module_with_cross( + m: &Module, + cross_module_types: &BTreeMap>, +) -> UniquenessTable { + infer_module_with_cross_branches(m, cross_module_types).table +} +``` + +- [ ] **Step 7: Build the crate, confirm existing uniqueness tests stay green** + +Run: `cargo build -p ailang-check && cargo test -p ailang-check` +Expected: PASS (the change is purely additive — the new channel is +produced but not yet consumed; the aggregate table is unchanged, so every +existing test is unaffected). + +--- + +### Task 3: CARRY — additive MIR fields + lower_to_mir traversal-order cursor + +**Files:** +- Modify: `crates/ailang-mir/src/lib.rs:91,116` +- Modify: `crates/ailang-codegen/src/escape.rs:612,737,747` +- Modify: `crates/ailang-check/src/lower_to_mir.rs` +- Test: `crates/ailang-check/tests/lower_to_mir_ty.rs` + +- [ ] **Step 1: Add the additive fields on `MArm` and `MTerm::If`** + +In `crates/ailang-mir/src/lib.rs`, change `MTerm::If` (line 91) to: + +```rust + If { + cond: Box, + then: Box, + else_: Box, + /// #63 leg 3: per-branch consume of each owned binder on the + /// `then` / `else` branch (absolute count at the branch end). + /// Codegen drops a param live here (`0`) but consumed on the + /// sibling (`MirDef.consume >= 1`). Empty for const bodies. + then_consume: BTreeMap, + else_consume: BTreeMap, + ty: Type, + }, +``` + +Change `struct MArm` (lines 116-119) to: + +```rust +pub struct MArm { + pub pat: Pattern, + pub body: MTerm, + /// #63 leg 3: per-arm consume of each owned binder (absolute count at + /// this arm's end). See `MTerm::If.then_consume`. Empty for const bodies. + pub consume: BTreeMap, +} +``` + +(`BTreeMap` is already imported in this file — `MirDef.consume` uses it.) + +- [ ] **Step 2: Build the workspace to enumerate the break sites** + +Run: `cargo build --workspace 2>&1 | grep -E "error\[|missing field" | head -30` +Expected: errors at the literal-construction sites only — `lower_to_mir.rs` +(the `MTerm::If` at ~326 and `MArm` at ~381) and the three test-mod `MArm` +literals in `escape.rs` (~612, ~737, ~747). All `..`-pattern read sites +compile clean. (This step just confirms the break set before threading.) + +- [ ] **Step 3: Thread the `escape.rs` test-mod `MArm` constructions** + +In `crates/ailang-codegen/src/escape.rs`, the three `MArm { pat, body }` +literals (around lines 612, 737, 747) gain an empty consume map. For each, +add the field: + +```rust + consume: std::collections::BTreeMap::new(), +``` + +(These are `#[cfg(test)]` fixtures; an empty map is correct — the escape +tests do not exercise branch-consume drops.) + +- [ ] **Step 4: Add the cursor fields to `Ctx` and a pop helper** + +In `crates/ailang-check/src/lower_to_mir.rs`, add to the `Ctx` struct (the +struct holding `env`, `in_def`, `locals`, `loop_stack`) these fields: + +```rust + /// #63 leg 3: per-branch consume snapshots for the current def, in the + /// uniqueness pass's pre-order. Consumed by a traversal-order cursor + /// as `lower_term` lowers each `if`/`match` (post-order pop). Empty + + /// `track_branches=false` for const bodies (uniqueness walks only fns). + branch_consumes: Vec, + branch_idx: usize, + track_branches: bool, +``` + +Add an `impl` block with the two pop helpers (place near the other `Ctx` +methods): + +```rust +impl Ctx<'_> { + /// Pop the next `If` branch-consume entry (post-order: called AFTER + /// lowering cond/then/else). Returns empty maps for const bodies. A + /// kind/cursor mismatch is a compiler-internal desync — panic loudly + /// rather than silently mis-attach a drop map. + fn next_branch_if( + &mut self, + ) -> (std::collections::BTreeMap, std::collections::BTreeMap) { + use crate::uniqueness::BranchConsume; + if !self.track_branches { + return (std::collections::BTreeMap::new(), std::collections::BTreeMap::new()); + } + match self.branch_consumes.get(self.branch_idx) { + Some(BranchConsume::If { then, else_ }) => { + let r = (then.clone(), else_.clone()); + self.branch_idx += 1; + r + } + other => panic!( + "lower_to_mir branch-consume desync in `{}` at idx {}: expected If, got {}", + self.in_def, + self.branch_idx, + match other { + Some(BranchConsume::Match { .. }) => "Match", + Some(BranchConsume::If { .. }) => "If", + None => "end-of-list", + } + ), + } + } + + /// Pop the next `Match` branch-consume entry (post-order: called AFTER + /// lowering scrutinee + all arm bodies). Returns `n_arms` empty maps + /// for const bodies. Arm-count / kind mismatch is a desync — panic. + fn next_branch_match( + &mut self, + n_arms: usize, + ) -> Vec> { + use crate::uniqueness::BranchConsume; + if !self.track_branches { + return vec![std::collections::BTreeMap::new(); n_arms]; + } + match self.branch_consumes.get(self.branch_idx) { + Some(BranchConsume::Match { arms }) if arms.len() == n_arms => { + let r = arms.clone(); + self.branch_idx += 1; + r + } + other => panic!( + "lower_to_mir branch-consume desync in `{}` at idx {}: expected Match[{}], got {}", + self.in_def, + self.branch_idx, + n_arms, + match other { + Some(BranchConsume::Match { arms }) => format!("Match[{}]", arms.len()), + Some(BranchConsume::If { .. }) => "If".into(), + None => "end-of-list".into(), + } + ), + } + } +} +``` + +- [ ] **Step 5: Attach per-branch maps at `Term::If` lowering** + +Replace the `Term::If` lowering (lines 326-331) with (lower children +first, then pop — post-order, matching the uniqueness push order): + +```rust + Term::If { cond, then, else_ } => { + let m_cond = lower_term(ctx, cond)?; + let m_then = lower_term(ctx, then)?; + let m_else = lower_term(ctx, else_)?; + let (then_consume, else_consume) = ctx.next_branch_if(); + MTerm::If { + cond: Box::new(m_cond), + then: Box::new(m_then), + else_: Box::new(m_else), + then_consume, + else_consume, + ty, + } + } +``` + +- [ ] **Step 6: Attach per-arm maps at `Term::Match` lowering** + +In the `Term::Match` lowering (lines 359-383), build each `MArm` with an +empty `consume`, then fill from the popped entry after the arm loop. Change +the `m_arms.push(...)` line (381) to: + +```rust + m_arms.push(MArm { + pat: a.pat.clone(), + body: m_body, + consume: BTreeMap::new(), + }); +``` + +and insert, between the `for a in arms { ... }` loop close (382) and the +`MTerm::Match { ... }` construction (383): + +```rust + let arm_maps = ctx.next_branch_match(arms.len()); + for (marm, cmap) in m_arms.iter_mut().zip(arm_maps) { + marm.consume = cmap; + } +``` + +(Ensure `use std::collections::BTreeMap;` is in scope in this file; if not, +qualify as `std::collections::BTreeMap::new()`.) + +- [ ] **Step 7: Switch `lower_module` to the branches API + seed the cursor + exhaustion guard** + +In `lower_module`, change the uniqueness call (lines 602-603) to: + +```rust + let result = + crate::uniqueness::infer_module_with_cross_branches(module, cross_module_types); +``` + +In the const-lowering `Ctx` construction (around line 612-617) add the new +fields (const bodies are not walked by uniqueness → neutral cursor): + +```rust + let mut ctx = Ctx { + env: &env, + in_def: &c.name, + locals: IndexMap::new(), + loop_stack: Vec::new(), + branch_consumes: Vec::new(), + branch_idx: 0, + track_branches: false, + }; +``` + +In the fn-lowering `Ctx` construction (around line 655-660) add: + +```rust + let mut ctx = Ctx { + env: &env, + in_def: &f.name, + locals, + loop_stack: Vec::new(), + branch_consumes: result.branches.get(&f.name).cloned().unwrap_or_default(), + branch_idx: 0, + track_branches: true, + }; + let body = lower_term(&mut ctx, &f.body)?; + assert!( + ctx.branch_idx == ctx.branch_consumes.len(), + "lower_to_mir branch-consume cursor desync for `{}`: consumed {} of {} entries", + f.name, + ctx.branch_idx, + ctx.branch_consumes.len() + ); +``` + +(The `let body = lower_term(&mut ctx, &f.body)?;` already exists at line +661 — replace it with the version above that adds the trailing `assert!`.) + +Finally, change the `MirDef.consume` fill (lines 667-671) to read +`result.table` instead of the old `uniqueness` binding: + +```rust + consume: result + .table + .iter() + .filter(|((d, _), _)| d == &f.name) + .map(|((_, b), info)| (b.clone(), info.consume_count)) + .collect(), +``` + +- [ ] **Step 8: Build the workspace** + +Run: `cargo build --workspace` +Expected: 0 errors. (Codegen still reads the aggregate `MirDef.consume`; +the new per-branch fields are populated but not yet consumed by codegen — +so behaviour is unchanged and the leak pins stay RED.) + +- [ ] **Step 9: Add the lock-step correspondence test** + +Append to `crates/ailang-check/tests/lower_to_mir_ty.rs` a test that +elaborates the existing `match_arm_consume_split_no_leak_pin` fixture and +asserts the per-arm maps attach to the correct arms (guards the +traversal-order cursor). Use the file's existing `elaborate_fixture` and +`body` helpers — do NOT invent a new API. The fixture's `run` body is +directly a `(match f ...)` with arms in source order: `Off` (b live) at +`arms[0]`, `On` (consumes b via `sink`) at `arms[1]`. + +```rust +/// #63 leg 3 lock-step: the per-arm consume map attaches to the matching +/// MArm in traversal order. In the `match_arm_consume_split_no_leak_pin` +/// fixture's `run`, `b` is consumed on the `On` arm (forwarded into +/// `sink`) and live on the `Off` arm — so traversal-order attachment must +/// land MArm.consume[b] == 0 on `Off` (source-order arms[0]) and >= 1 on +/// `On` (arms[1]). A cursor desync would mis-attach and flip these. +#[test] +fn branch_consume_maps_attach_to_matching_arms() { + let m = "match_arm_consume_split_no_leak_pin"; + let mir = elaborate_fixture(m); + let arms = match body(&mir, m, "run") { + MTerm::Match { arms, .. } => arms, + other => panic!("run body is not a Match: {other:?}"), + }; + assert_eq!( + arms[0].consume.get("b").copied().unwrap_or(99), + 0, + "Off arm (arms[0]) must leave `b` live (consume 0); got {:?}", + arms[0].consume.get("b") + ); + assert!( + arms[1].consume.get("b").copied().unwrap_or(0) >= 1, + "On arm (arms[1]) must consume `b` (>= 1); got {:?}", + arms[1].consume.get("b") + ); +} +``` + +- [ ] **Step 10: Run the correspondence test** + +Run: `cargo test -p ailang-check --test lower_to_mir_ty branch_consume_maps_attach_to_matching_arms` +Expected: PASS. + +--- + +### Task 4: GATE — codegen leak-class drop (turns the RED pins GREEN) + +**Files:** +- Modify: `crates/ailang-codegen/src/lib.rs` (helper + `MTerm::If` branches) +- Modify: `crates/ailang-codegen/src/match_lower.rs` (`:823` switch + fall-through drops) + +- [ ] **Step 1: Add the `emit_leakclass_branch_param_drops` helper** + +Add this method to the codegen impl in `crates/ailang-codegen/src/lib.rs` +(near the other drop-emitting helpers — it reuses `field_drop_call`, +`partial_drop_symbol_for_type`, `build_moved_mask`, `moved_slots`, +`current_param_modes`, `current_def`, `consume`, `locals`, `alloc`): + +```rust + /// #63 leg 3: drop owned heap params that are LIVE on this branch + /// (`branch_consume[p] == 0`) but CONSUMED on a sibling branch (per-fn + /// aggregate `self.consume[p] >= 1`) — the leak class. The + /// `aggregate >= 1` guard keeps this disjoint from the fn-return dec + /// (which owns the `aggregate == 0` class), so no param is double- + /// dropped — no fn-return disable / tail-position analysis is needed. + /// And because the checker rejects use-after-consume, an + /// `aggregate >= 1` param is provably dead past the branch construct, + /// so dropping it at the branch close is safe regardless of tail + /// position. `tail_val` is the branch's own tail SSA (a param returned + /// by the branch transfers ownership to the join consumer — skip it); + /// `scrutinee` is the match scrutinee binder to skip (None for `if`). + fn emit_leakclass_branch_param_drops( + &mut self, + branch_consume: &std::collections::BTreeMap, + tail_val: &str, + scrutinee: Option<&str>, + ) { + if !matches!(self.alloc, AllocStrategy::Rc) { + return; + } + let owned_params: Vec<(String, ParamMode)> = self + .current_param_modes + .iter() + .map(|(n, m)| (n.clone(), *m)) + .collect(); + for (pname, mode) in &owned_params { + if !matches!(mode, ParamMode::Own) { + continue; + } + if scrutinee == Some(pname.as_str()) { + continue; + } + let branch_cc = branch_consume.get(pname).copied().unwrap_or(u32::MAX); + if branch_cc != 0 { + continue; // consumed on this branch — ownership transferred + } + let agg = self + .consume + .get(&(self.current_def.clone(), pname.clone())) + .copied() + .unwrap_or(0); + if agg == 0 { + continue; // live on every path — the fn-return dec owns this + } + let p_ssa = format!("%arg_{}", pname); + if p_ssa == tail_val { + continue; // param is this branch's return value + } + let p_ail = self + .locals + .iter() + .find(|(_, ssa, _, _)| ssa == &p_ssa) + .map(|(_, _, lty, ail)| (lty.clone(), ail.clone())); + let (p_lty, p_ail) = match p_ail { + Some(t) => t, + None => continue, + }; + if p_lty != "ptr" { + continue; + } + let moves = self.moved_slots.get(pname).cloned().unwrap_or_default(); + if moves.is_empty() { + let drop_call = self.field_drop_call(&p_ail); + self.body + .push_str(&format!(" call void @{drop_call}(ptr {p_ssa})\n")); + } else { + let sym = self.partial_drop_symbol_for_type(&p_ail); + let mask = Self::build_moved_mask(&moves); + if let (Some(sym), Some(mask)) = (sym, mask) { + self.body + .push_str(&format!(" call void @{sym}(ptr {p_ssa}, i64 {mask})\n")); + } else { + self.body + .push_str(&format!(" call void @ailang_rc_dec(ptr {p_ssa})\n")); + } + } + } + } +``` + +- [ ] **Step 2: Switch the pre-tail-call dec gate source to per-arm** + +In `crates/ailang-codegen/src/match_lower.rs`, replace the aggregate lookup +in the pre-tail-call Own-param dec (lines 823-827): + +```rust + let consume_count = self + .consume + .get(&(self.current_def.clone(), pname.clone())) + .copied() + .unwrap_or(u32::MAX); +``` + +with the per-arm lookup (`arm` is the ctor-arms loop variable, in scope +here): + +```rust + let consume_count = arm.consume.get(pname).copied().unwrap_or(u32::MAX); +``` + +(For an `aggregate == 0` param — legs 1/2 — `arm.consume[p]` is `0` on +every arm, so this is the identical decision. It additionally drops a +param live on this tail-call arm but consumed on a sibling.) + +- [ ] **Step 3: Add the fall-through drop in the ctor-arms loop** + +In `lower_match`, after the arm-close pattern-binder dec block closes +(immediately before the `// pop bindings` comment / the `for _ in +0..pushed { self.locals.pop(); }` loop, around line 966), insert: + +```rust + // #63 leg 3: a fall-through (non-tail-call) arm that left an + // owned param live (arm.consume == 0) but consumed it on a + // sibling arm (aggregate >= 1) must drop it here, before the + // br to the join. The aggregate >= 1 guard inside the helper + // keeps this disjoint from the fn-return dec. + if !self.block_terminated { + self.emit_leakclass_branch_param_drops( + &arm.consume, + &val, + scrutinee_binder.as_deref(), + ); + } +``` + +- [ ] **Step 4: Add the fall-through drop in the default/open arm** + +In the default-block handling, inside `if let Some(arm) = open_arm { ... }`, +after `let (val, vty) = self.lower_term(&arm.body)?;` and the +`for _ in 0..pushed { self.locals.pop(); }` (around line 1009), before the +`if !self.block_terminated { phi_inputs.push(...) ... }` block (line 1010), +insert: + +```rust + // #63 leg 3: same leak-class drop for a Wild/Var default arm. + if !self.block_terminated { + self.emit_leakclass_branch_param_drops( + &arm.consume, + &val, + scrutinee_binder.as_deref(), + ); + } +``` + +- [ ] **Step 5: Add the per-branch drops to `MTerm::If` codegen** + +In `crates/ailang-codegen/src/lib.rs`, change the `MTerm::If` destructure +(line 1906) to bind the new fields: + +```rust + MTerm::If { cond, then, else_, then_consume, else_consume, .. } => { +``` + +In the then-branch emission (lines 1928-1930), emit the drop before the +`br`: + +```rust + if !then_terminated { + self.emit_leakclass_branch_param_drops(then_consume, &then_v, None); + self.body.push_str(&format!(" br label %{join_lbl}\n")); + } +``` + +In the else-branch emission (lines 1941-1943): + +```rust + if !else_terminated { + self.emit_leakclass_branch_param_drops(else_consume, &else_v, None); + self.body.push_str(&format!(" br label %{join_lbl}\n")); + } +``` + +- [ ] **Step 6: Run the two leak pins — expect GREEN** + +Run: `cargo test -p ail --test if_branch_consume_split_no_leak_pin --test match_arm_consume_split_no_leak_pin` +Expected: BOTH PASS (`live=0`, `allocs==frees`). + +- [ ] **Step 7: Run legs 1/2 — expect still GREEN (behaviour-preservation)** + +Run: `cargo test -p ail --test tail_recur_owned_param_no_leak_pin --test tail_recur_let_wrapped_no_leak_pin` +Expected: BOTH PASS (the `:823` gate-source switch is identical for +`aggregate == 0` params; the new fall-through drop never fires for them). + +--- + +### Task 5: Verify — series_sma close + full workspace suite + +**Files:** +- Create: `crates/ail/tests/series_sma_no_leak_pin.rs` + +- [ ] **Step 1: Write the series_sma RC-stats close pin** + +Create `crates/ail/tests/series_sma_no_leak_pin.rs` — structural copy of +the if/match leak pins, building `examples/series_sma.ail` and asserting +`live==0`. (`series_sma_pin.rs` already pins stdout; this pin is the RC +ledger, distinct.) Header doc: "Pins that the #61 headline `series_sma` +example is leak-clean under `--alloc=rc`. The residual `live=2` was #63 +leg 3 (branch-consume-split); this pin closes that leak tail." Body +identical to the if-pin's build/run/parse-stats, with: + +```rust + let src = workspace.join("examples").join("series_sma.ail"); +``` + +and the temp-dir / test-fn names adjusted (`series_sma_no_leak_pin`, +`series_sma_is_leak_clean`), asserting `assert_eq!(live, 0, ...)` and +`assert_eq!(allocs, frees, ...)`. + +- [ ] **Step 2: Run the series_sma pin** + +Run: `cargo test -p ail --test series_sma_no_leak_pin` +Expected: PASS (`live=0`). This clears the #61 leak tail. + +- [ ] **Step 3: Run the full workspace suite** + +Run: `cargo test --workspace` +Expected: ALL PASS. (The typed-MIR re-synth strictness family means a +MIR-node change can ripple — this is the gate that catches a regression +the targeted pins miss, including any double-free/leak from the new drop +interacting with an existing branch-tailed fn in prelude/kernel/examples.) + +- [ ] **Step 4: Confirm no lockstep-pair drift** + +Run: `cargo test -p ailang-codegen intercepts_bijection_with_intrinsic_markers` +Expected: PASS. (Confirms the `INTERCEPTS` ↔ `(intrinsic)` bijection is +intact — this change adds no intrinsic. The `Pattern::Lit` ↔ +`pre_desugar_validation` pair is likewise untouched: no `Pattern::Lit` +reject arm is added.) + +- [ ] **Step 5: Spot-check the diff for the double-free invariant** + +Read the codegen diff and confirm, by eye, that every new drop call sits +inside a `branch_cc == 0 && agg >= 1` gate (the helper) and that the +fn-return dec (`lib.rs:1489`) and arm-close pattern-binder dec +(`match_lower.rs:915`) were NOT modified. This is the human check that the +disjointness-by-aggregate invariant holds in the shipped code.