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
@@ -0,0 +1,91 @@
//! 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})"
);
}
@@ -62,16 +62,6 @@ fn ail_bin() -> &'static str {
env!("CARGO_BIN_EXE_ail")
}
// IGNORED: leg 3 of #63 is a design fork, not a contained fix. Per-arm
// consume liveness is destroyed by the worst-case `max` merge in
// uniqueness.rs (`merge_states`) before MIR, so codegen has no per-arm
// information to gate a fall-through drop on. Closing this leg needs a
// memory-model change (retain per-arm consume, thread it to codegen,
// emit the drop only on the live arm — never at the join or the
// consuming arm) and a fresh brainstorm cycle. Un-ignore when that
// ships. The assertion below is a correct symptom pin (live == 0); only
// the fix mechanism is undecided.
#[ignore = "leg 3 of #63 — design fork (per-arm consume dataflow); un-ignore when fixed"]
#[test]
fn alloc_rc_owned_param_consumed_on_one_arm_live_on_sibling_does_not_leak() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
@@ -0,0 +1,78 @@
//! 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.
//!
//! Property protected: `series_sma` runs to completion under
//! `AILANG_RC_STATS=1` with `live == 0` (equivalently `allocs == frees`).
//! `series_sma_pin.rs` already pins stdout; this pin is the RC ledger,
//! distinct. Goes green once the branch-consume-split leak is fixed.
use std::path::Path;
use std::process::Command;
fn ail_bin() -> &'static str {
env!("CARGO_BIN_EXE_ail")
}
#[test]
fn series_sma_is_leak_clean() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let src = workspace.join("examples").join("series_sma.ail");
let tmp = std::env::temp_dir().join(format!(
"ailang_series_sma_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 series_sma.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,
"series_sma leaks {live} slab(s) (allocs={allocs} frees={frees}): the \
#61 residual live=2 was the #63 leg-3 branch-consume-split leak; if \
this is non-zero the leak-class drop is not firing on the series path."
);
assert_eq!(
allocs, frees,
"alloc/free mismatch (allocs={allocs} frees={frees} live={live})"
);
}