Owned ADT threaded through tail-recursion is never dropped (general drop-soundness leak) #63
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Surfaced by the series-step1 fieldtest (docs/specs/0067-fieldtest-series-step1.md), but NOT Series-specific.
Symptom. An owned heap value (any
(own (con T))ADT, including Series/RawBuf but also a plain userBox) threaded through atail-apprecursion whose base-case arm returns a non-matching type (e.g. Unit) and neither returns nor consumes the owned value is never dropped. UnderAILANG_RC_STATS=1the program reportslive > 0.Minimal repro:
Generality proof. A control ADT
(data Box (ctor B (con Int)))threaded through the identical tail-recursion pattern (no Series, no RawBuf) also leaks (live=4). A non-recursive own-ADT consumed by a fn islive=0. So the root is general drop soundness for an owned value reaching a non-consuming base case of a (tail-)recursive fn — Series merely surfaces it heavily (RawBuf slab + wrapper) and on the canonical streaming pattern.Blast radius. The committed reference example
examples/series_sma.ailleaks (live=8), as do fieldtest fixtures series-step1_1 and series-step1_3. Contradicts model 0007's 'dropping the Window frees the slab / leak-clean' claim. Resolve by fixing the drop, NOT by softening the doc.Severity note. Programs still produce correct output; the leak grows with stream length, so long-running streaming workloads (the first Series consumer's headline use case) accumulate unbounded heap. Candidate BLOCKER. Likely in the own-param-at-non-consuming-base-case drop family seen in prior cutover/raw-buf work, which historically clustered — expect possibly multiple legs.
Entry path: debug (RED-first), per project bug discipline.
Triage update — the leak is a 3-leg cluster (drop soundness for owned heap params at match arms). Legs 1 and 2 are FIXED and committed; leg 3 is a design fork.
Leg 1 (FIXED,
68f500c). Owned param not forwarded into a direct tail-call arm had no drop site (fn-return dec gated!block_terminated; per-arm drop handled only pattern binders). Pin: tail_recur_owned_param_no_leak_pin.rs (green).Leg 2 (FIXED,
e3a9065). A tail call wrapped in a trailinglet/seqdefeatedarm_body_is_tail_call, skipping both pre-tail-call drops. Fix peels the detection through the Let/Seq tail chain. Pin: tail_recur_let_wrapped_no_leak_pin.rs (green). examples/series_sma.ail: live=8 → live=2.Leg 3 (DESIGN FORK, ignored pin
a2808a6). An owned param consumed on one match arm but live on a sibling fall-through arm is never dropped: both drop sites gate on the per-fn AGGREGATE consume_count (>=1 from the consuming arm), and per-arm/per-path consume info is destroyed by the worst-casemaxmerge in uniqueness.rs (merge_states) before MIR. Codegen has no per-arm liveness. This is the residual live=2 in series_sma.Design question (needs brainstorm): where is per-arm owned-param liveness computed and how is it threaded to codegen? Candidate: retain per-arm consume in uniqueness.rs (stop collapsing in merge_states), surface on MArm / a per-(def,arm,param) map on MirDef, emit the drop at each arm's live-exit block. DOUBLE-FREE HAZARD: the drop must land on the live arm ONLY — never at the post-match join (cannot distinguish consumed vs live path) and never on the consuming arm. Minimal Series-free repro committed (ignored): examples/match_arm_consume_split_no_leak_pin.ail (live=1).