fix(codegen): drop branch-consume-split owned params (#63 leg 3)

The last leg of the #63 drop-soundness cluster: an (own ...) heap param
consumed on one match-arm / if-branch but live on a sibling was never
dropped on the live path, leaking one slab per call. This was the
residual live=2 leak in series_sma that kept the series headline from
being leak-clean.

Root: the per-fn aggregate consume_count (the worst-case max over all
branches from uniqueness::merge_states) was codegen's only consume
signal, and every owned-param drop site gated on it. A param consumed
on SOME branch makes the aggregate >= 1, so every site skipped it —
including the branch where it stays live. match and if share this root
(if is first-class MTerm::If, not desugared to match).

Mechanism (spec 0068):
- CAPTURE: uniqueness retains the per-branch consume snapshots it
  already computes per branch and discarded at the max merge — new
  BranchConsume + infer_module_with_cross_branches (the aggregate-only
  infer_module_with_cross now delegates and drops the channel).
- CARRY: additive MArm.consume / MTerm::If.{then,else}_consume, attached
  by lower_to_mir via a traversal-order cursor (post-order pop, kind- and
  exhaustion-asserts make a desync a loud panic; neutral for const bodies
  which uniqueness does not walk). The AST carries no node id, so the
  correspondence is the structural pre-order both walks share — pinned by
  branch_consume_maps_attach_to_matching_arms.
- GATE: emit_leakclass_branch_param_drops fires a fall-through drop only
  for the leak class (branch_consume==0 AND aggregate>=1), in match arms
  + both if branches; the pre-tail-call dec switches its gate source from
  the aggregate to per-arm. The fn-return dec and arm-close pattern-binder
  dec are UNCHANGED.

Safety:
- Double-free: the drop sites partition by aggregate (==0 -> existing
  fn-return/pre-tail-call; >=1 -> new per-branch). Disjoint, so no param
  is dropped twice — no fn-return disable, no tail-position analysis.
- Use-after-free: the checker rejects use-after-consume, so an
  aggregate>=1 param is provably dead past the construct (path-terminal
  drop, tail position irrelevant).
- Type eligibility (found by the full-suite gate during implement; spec
  0068 refined): the new agg>=1 site is the FIRST drop site that can
  reach a STATIC closure-pair param (a top-level fn ref like inc in
  Either.either, consumed on one arm, live on the other) — a .rodata
  constant with no rc-header. field_drop_call routes Type::Fn / static-Str
  / Type::Var to the bare ailang_rc_dec, which underflowed on it. The
  helper now drops only params with a real per-type heap-ADT drop fn
  (field_drop_call != "ailang_rc_dec"). Sound (no underflow) and
  leak-correct (a static closure/Str allocates nothing). Witnessed green
  by std_either_demo / std_list_demo / poly_rec_capture_demo.

Verification: full workspace suite green (116 binaries); both new leak
pins (if + match) and series_sma_no_leak_pin green at live=0; legs 1/2
pins still green; INTERCEPTS<->(intrinsic) bijection intact; no
Pattern::Lit reject path added.

The leg-3 helper's type precondition was applied inline by the
orchestrator (a narrowing guard clause in an already-reviewed Task-4
helper, full context loaded) after the implement-orchestrator correctly
surfaced the spec gap rather than papering over the regression.

This clears the series_sma leak tail; the series milestone (#61) close
stays a separate deliberate step (its end-to-end milestone fieldtest).

closes #63
This commit is contained in:
2026-06-02 15:47:01 +02:00
parent 3447ac8039
commit d72fe0c2e6
13 changed files with 585 additions and 35 deletions
+3
View File
@@ -612,6 +612,7 @@ mod tests {
arms: vec![MArm {
pat: Pattern::Wild,
body: lit_int(0),
consume: std::collections::BTreeMap::new(),
}],
ty: Type::int(),
}),
@@ -743,6 +744,7 @@ mod tests {
],
},
body: var("h"),
consume: std::collections::BTreeMap::new(),
},
MArm {
pat: Pattern::Ctor {
@@ -750,6 +752,7 @@ mod tests {
fields: vec![],
},
body: lit_int(0),
consume: std::collections::BTreeMap::new(),
},
],
ty: Type::int(),
+96 -1
View File
@@ -1591,6 +1591,99 @@ impl<'a> Emitter<'a> {
Ok(())
}
/// #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;
}
// #63 leg 3 TYPE precondition: only drop params with a real
// per-type heap-ADT drop fn. `field_drop_call` routes
// `Type::Fn` (static OR heap closures), static-`Str`, and
// `Type::Var` to the bare `ailang_rc_dec`, which reads a
// non-existent rc-header on a `.rodata` static closure/Str and
// underflows. This `agg >= 1` site is the FIRST drop site that
// can reach a static closure param (a called closure has
// `agg >= 1`; every existing site needs `agg == 0`), so it must
// gate on a real drop fn. Sound + leak-correct: a static
// closure / static `Str` allocates nothing to drop.
let drop_call = self.field_drop_call(&p_ail);
if drop_call == "ailang_rc_dec" {
continue;
}
let moves = self.moved_slots.get(pname).cloned().unwrap_or_default();
if moves.is_empty() {
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"));
}
}
}
}
/// closure-pair scaffold for a top-level fn. Always emitted
/// (one wrapper per fn), so cross-module references just use the
/// `<m>_<f>_clos` symbol without coordination.
@@ -1903,7 +1996,7 @@ impl<'a> Emitter<'a> {
r
}
MTerm::If { cond, then, else_, .. } => {
MTerm::If { cond, then, else_, then_consume, else_consume, .. } => {
let (cond_v, cond_ty) = self.lower_term(cond)?;
if cond_ty != "i1" {
return Err(CodegenError::Internal(format!(
@@ -1926,6 +2019,7 @@ impl<'a> Emitter<'a> {
let then_terminated = self.block_terminated;
let then_block_end = self.current_block.clone();
if !then_terminated {
self.emit_leakclass_branch_param_drops(then_consume, &then_v, None);
self.body.push_str(&format!(" br label %{join_lbl}\n"));
}
@@ -1939,6 +2033,7 @@ impl<'a> Emitter<'a> {
)));
}
if !else_terminated {
self.emit_leakclass_branch_param_drops(else_consume, &else_v, None);
self.body.push_str(&format!(" br label %{join_lbl}\n"));
}
+21 -5
View File
@@ -820,11 +820,7 @@ impl<'a> Emitter<'a> {
if p_lty != "ptr" {
continue;
}
let consume_count = self
.consume
.get(&(self.current_def.clone(), pname.clone()))
.copied()
.unwrap_or(u32::MAX);
let consume_count = arm.consume.get(pname).copied().unwrap_or(u32::MAX);
if consume_count != 0 {
continue;
}
@@ -963,6 +959,18 @@ impl<'a> Emitter<'a> {
}
}
}
// #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(),
);
}
// pop bindings
for _ in 0..pushed {
self.locals.pop();
@@ -1007,6 +1015,14 @@ impl<'a> Emitter<'a> {
for _ in 0..pushed {
self.locals.pop();
}
// #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(),
);
}
if !self.block_terminated {
phi_inputs.push((val, self.current_block.clone()));
self.body