Owned ADT threaded through tail-recursion is never dropped (general drop-soundness leak) #63

Closed
opened 2026-06-02 13:23:49 +02:00 by Brummel · 1 comment
Owner

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 user Box) threaded through a tail-app recursion whose base-case arm returns a non-matching type (e.g. Unit) and neither returns nor consumes the owned value is never dropped. Under AILANG_RC_STATS=1 the program reports live > 0.

Minimal repro:

ail build --alloc=rc examples/fieldtest/series-step1_5_drop_leak_repro.ail -o /tmp/r && AILANG_RC_STATS=1 /tmp/r
# => ailang_rc_stats: allocs=7 frees=3 live=4

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 is live=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.ail leaks (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.

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 user `Box`) threaded through a `tail-app` recursion whose base-case arm returns a non-matching type (e.g. Unit) and neither returns nor consumes the owned value is never dropped. Under `AILANG_RC_STATS=1` the program reports `live > 0`. **Minimal repro:** ``` ail build --alloc=rc examples/fieldtest/series-step1_5_drop_leak_repro.ail -o /tmp/r && AILANG_RC_STATS=1 /tmp/r # => ailang_rc_stats: allocs=7 frees=3 live=4 ``` **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 is `live=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.ail` leaks (`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.
Brummel added the bug label 2026-06-02 13:23:49 +02:00
Author
Owner

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 trailing let/seq defeated arm_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-case max merge 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).

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 trailing `let`/`seq` defeated `arm_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-case `max` merge 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).
Sign in to join this conversation.