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
+21 -1
View File
@@ -810,9 +810,22 @@ Add this method to the codegen impl in `crates/ailang-codegen/src/lib.rs`
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() {
let drop_call = self.field_drop_call(&p_ail);
self.body
.push_str(&format!(" call void @{drop_call}(ptr {p_ssa})\n"));
} else {
@@ -970,6 +983,13 @@ 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.)
In particular the `std_either_demo`, `std_list_demo`, and
`poly_rec_capture_demo` e2e tests are the **closure-param regression
witnesses**: they pass top-level fn refs (static closure-pairs) through a
`match` that consumes them on one arm and leaves them live on another —
exactly the leak class. Without the helper's `field_drop_call ==
"ailang_rc_dec"` type precondition (Step 1) they abort with
`ailang_rc_dec: refcount underflow`; with it they stay green.
- [ ] **Step 4: Confirm no lockstep-pair drift**
@@ -119,6 +119,31 @@ Two independent guarantees make this safe:
does double duty: it secures both disjointness and the no-after-use
property.
### Type eligibility — the per-branch drop fires only for heap-RC ADTs
The new fall-through drop is the *first* drop site that can reach an
`aggregate >= 1` owned param — and an `aggregate >= 1` closure param is
routinely a *static* closure-pair (a top-level fn ref like `inc` lowered
to a `.rodata` constant with no rc-header), not a heap RC slab. The
existing drop sites never hit this because they all require
`aggregate == 0`, and a *called* closure has `aggregate >= 1`; their
soundness for `Type::Fn` / static-`Str` params rests on a codegen-level
invariant (move-tracking + the `aggregate == 0` gate) that keeps static
constants out of the bare `ailang_rc_dec` route. The new site would
violate that invariant, so it carries an explicit **type precondition**:
it drops a param only when `field_drop_call` resolves to a real per-type
heap-ADT drop fn (`drop_<owner>_<T>`), i.e. **not** the bare
`ailang_rc_dec` fallback that `Type::Fn`, static-`Str`, and `Type::Var`
route to. This is sound (no underflow on a static constant) *and*
leak-correct for the real cases: a static closure / static `Str`
allocates nothing, so there is nothing to drop. (A heap-capturing closure
param in the exact leak-class position would leak rather than be freed —
but the existing machinery cannot free it either, so this is not a
regression; soundness is preferred over completeness here.) Witnessed by
the `std_either_demo` / `std_list_demo` / `poly_rec_capture_demo` e2e
tests, which pass closure refs through a `match` that consumes them on
one arm and leaves them live on another.
### Why this is behaviour-preserving for legs 1/2
Legs 1 and 2 (already shipped) drop an owned param whose **aggregate**