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:
@@ -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(),
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user