plan: branch-consume-split drop soundness (#63 leg 3)
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
This commit is contained in:
@@ -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<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,
|
||||
"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<String, u32>,
|
||||
else_: BTreeMap<String, u32>,
|
||||
},
|
||||
Match {
|
||||
arms: Vec<BTreeMap<String, u32>>,
|
||||
},
|
||||
}
|
||||
|
||||
fn snapshot_consume(binders: &BTreeMap<String, BinderState>) -> BTreeMap<String, u32> {
|
||||
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<BranchConsume>,
|
||||
```
|
||||
|
||||
- [ ] **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<BTreeMap<String, BinderState>> = None;
|
||||
let mut arm_maps: Vec<BTreeMap<String, u32>> =
|
||||
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<String, Type>,
|
||||
cross_module_types: &BTreeMap<String, BTreeMap<String, Type>>,
|
||||
out: &mut UniquenessTable,
|
||||
branches_out: &mut BTreeMap<String, Vec<BranchConsume>>,
|
||||
) {
|
||||
```
|
||||
|
||||
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::<Vec<_>>();
|
||||
(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<String, Vec<BranchConsume>>,
|
||||
}
|
||||
```
|
||||
|
||||
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<String, BTreeMap<String, Type>>,
|
||||
) -> UniquenessResult {
|
||||
```
|
||||
|
||||
Replace the table-build tail (lines 170-177) with:
|
||||
|
||||
```rust
|
||||
let mut table: UniquenessTable = BTreeMap::new();
|
||||
let mut branches: BTreeMap<String, Vec<BranchConsume>> = 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<String, BTreeMap<String, Type>>,
|
||||
) -> 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<MTerm>,
|
||||
then: Box<MTerm>,
|
||||
else_: Box<MTerm>,
|
||||
/// #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<String, u32>,
|
||||
else_consume: BTreeMap<String, u32>,
|
||||
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<String, u32>,
|
||||
}
|
||||
```
|
||||
|
||||
(`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<crate::uniqueness::BranchConsume>,
|
||||
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<String, u32>, std::collections::BTreeMap<String, u32>) {
|
||||
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<std::collections::BTreeMap<String, u32>> {
|
||||
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<String, u32>,
|
||||
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.
|
||||
Reference in New Issue
Block a user