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:
@@ -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**
|
||||
|
||||
|
||||
Reference in New Issue
Block a user