Free superseded heap loop-binder values across recur #49

Closed
opened 2026-05-30 17:15:47 +02:00 by Brummel · 1 comment
Owner

Summary

A heap value threaded through a (loop ...)/recur as a loop binder
leaks: each iteration's superseded binder value (the old accumulator,
replaced by the recur) is never RC-dec'd. The leak is observed even
when the loop's final result is consumed.

This is the per-iteration recur-dec gap, distinct from the scope-close
drop of a let-bound loop result, which was fixed in commit 8bcdae1
(fix(codegen): drop a let-bound owned loop result at scope close). That
commit deliberately excluded Str from the loop-result drop because a
loop can return a static Str; this issue is the separate, broader leak
of the intermediate per-iteration values.

Reproduction (verified)

Module (a Str accumulator built across a loop/recur, result
consumed by print):

(module s2b (fn main (type (fn-type (params) (ret (con Unit)) (effects IO))) (params)
  (body
    (let s (loop (acc (con Str) "x") (i (con Int) 0)
             (if (app ge i 3) acc (recur (app str_concat acc "y") (app + i 1))))
      (app print s)))))

ail build s2b.ail --alloc=rc -o /tmp/s2b
AILANG_RC_STATS=1 /tmp/s2b 2>&1 >/dev/null
-> ailang_rc_stats: allocs=4 frees=1 live=3

stdout is correct (xyyy); live=3 is the leak — the three superseded
acc slabs (the initial "x" and the two intermediate concat results)
are never freed.

Scope

  • NOT RawBuf-specific. RawBuf.set mutates in place (own -> own, no
    per-iteration allocation), so a RawBuf loop binder allocates exactly
    one slab and does not exhibit this leak; the RawBuf fill-loop fixtures
    (examples/fieldtest/rawbuf_1_score_table.ail,
    rawbuf_2_running_max.ail) are leak-clean under AILANG_RC_STATS.
  • Claim (unverified): any heap accumulator threaded through a loop binder
    is affected (heap Str, boxed ADTs). Only the Str case is verified
    above.
  • Claim (unverified, not code-pinpointed): the gap is in the loop/
    recur lowering in crates/ailang-codegen/, which does not emit an
    RC-dec for a loop binder's prior value when recur rebinds it. The
    scope-close path in crates/ailang-codegen/src/drop.rs is a different
    mechanism and is not where this leak lives.

Context

Surfaced during the raw-buf fieldtest follow-up (the broader observation
behind finding B2, docs/specs/0058-fieldtest-raw-buf.md). Pre-existing:
loop accumulators in the shipped fixtures are Int (isqrt, collatz,
gcd), which carry no RC, so the gap had no prior repro.

Acceptance

  • A heap-Str accumulator threaded through a loop/recur reports
    live=0 under AILANG_RC_STATS
  • A boxed-ADT accumulator threaded the same way reports live=0
  • RED test pinning the per-iteration leak before the fix
## Summary A heap value threaded through a `(loop ...)`/`recur` as a loop binder leaks: each iteration's superseded binder value (the old accumulator, replaced by the `recur`) is never RC-dec'd. The leak is observed even when the loop's final result is consumed. This is the per-iteration recur-dec gap, distinct from the scope-close drop of a let-bound loop result, which was fixed in commit `8bcdae1` (`fix(codegen): drop a let-bound owned loop result at scope close`). That commit deliberately excluded `Str` from the loop-result drop because a loop can return a static `Str`; this issue is the separate, broader leak of the *intermediate* per-iteration values. ## Reproduction (verified) Module (a `Str` accumulator built across a `loop`/`recur`, result consumed by `print`): (module s2b (fn main (type (fn-type (params) (ret (con Unit)) (effects IO))) (params) (body (let s (loop (acc (con Str) "x") (i (con Int) 0) (if (app ge i 3) acc (recur (app str_concat acc "y") (app + i 1)))) (app print s))))) ail build s2b.ail --alloc=rc -o /tmp/s2b AILANG_RC_STATS=1 /tmp/s2b 2>&1 >/dev/null -> ailang_rc_stats: allocs=4 frees=1 live=3 stdout is correct (`xyyy`); `live=3` is the leak — the three superseded `acc` slabs (the initial `"x"` and the two intermediate concat results) are never freed. ## Scope - NOT RawBuf-specific. `RawBuf.set` mutates in place (`own -> own`, no per-iteration allocation), so a RawBuf loop binder allocates exactly one slab and does not exhibit this leak; the RawBuf fill-loop fixtures (`examples/fieldtest/rawbuf_1_score_table.ail`, `rawbuf_2_running_max.ail`) are leak-clean under `AILANG_RC_STATS`. - Claim (unverified): any heap accumulator threaded through a loop binder is affected (heap `Str`, boxed ADTs). Only the `Str` case is verified above. - Claim (unverified, not code-pinpointed): the gap is in the `loop`/ `recur` lowering in `crates/ailang-codegen/`, which does not emit an RC-dec for a loop binder's prior value when `recur` rebinds it. The scope-close path in `crates/ailang-codegen/src/drop.rs` is a different mechanism and is not where this leak lives. ## Context Surfaced during the raw-buf fieldtest follow-up (the broader observation behind finding B2, `docs/specs/0058-fieldtest-raw-buf.md`). Pre-existing: loop accumulators in the shipped fixtures are `Int` (isqrt, collatz, gcd), which carry no RC, so the gap had no prior repro. ## Acceptance - [ ] A heap-`Str` accumulator threaded through a `loop`/`recur` reports `live=0` under `AILANG_RC_STATS` - [ ] A boxed-ADT accumulator threaded the same way reports `live=0` - [ ] RED test pinning the per-iteration leak before the fix
Brummel added the bug label 2026-05-30 17:15:47 +02:00
Author
Owner

ADT leg landed in f488d31 (fix(codegen): dec superseded heap loop-binder values across recur (ADT leg)).

The recur store now RC-dec's the superseded prior binder value, gated by the scope-close predicate (RC-heap AND lowers to ptr AND not Str), with a same-pointer guard so own->own threading (RawBuf fill loops) is never decremented. Acceptance bullet 2 (boxed-ADT accumulator) is satisfied: a Cell threaded through three recurs reports live=0 (was live=3).

Remaining open: acceptance bullet 1 (heap-Str accumulator -> live=0). Blocked on a representation question, not a missing dec. Str literals lower to constexpr-GEPs into static globals (crates/ailang-codegen/src/lib.rs, Str-literal arm) — a static Str carries no rc_header, so dec'ing a Str loop binder is UB when it holds a literal (e.g. the seed "x"). runtime/rc.c has no static-vs-heap-Str discriminator.

Same obstacle as the deliberate Str exclusion in 8bcdae1 (which left the final-result Str loop leak in place): both the per-iteration superseded Str and the final-result Str cannot be safely RC-dec'd until static-Str and heap-Str pointers are distinguishable at runtime. Verified: a 3-iteration Str accumulator reports allocs=4 frees=1 live=3, stdout correct.

Candidate directions for the Str leg (none chosen; touches content-addressing / Str hashing, so this is a design decision):

  • Runtime static-vs-heap Str discriminator (header sentinel or tagged pointer) so ailang_rc_dec no-ops on static Str — general, but interacts with Str identity/hashing.
  • Force-copy a static-Str loop seed into a heap slab on loop entry so the binder is always heap — narrower, but has a seed-provenance subtlety (the seed may already be a heap value to retain rather than copy).
  • Accept the bounded Str-loop leak as-is and document it.
ADT leg landed in f488d31 (`fix(codegen): dec superseded heap loop-binder values across recur (ADT leg)`). The recur store now RC-dec's the superseded prior binder value, gated by the scope-close predicate (RC-heap AND lowers to `ptr` AND not `Str`), with a same-pointer guard so own->own threading (RawBuf fill loops) is never decremented. Acceptance bullet 2 (boxed-ADT accumulator) is satisfied: a `Cell` threaded through three recurs reports `live=0` (was `live=3`). Remaining open: acceptance bullet 1 (heap-`Str` accumulator -> `live=0`). Blocked on a representation question, not a missing dec. `Str` literals lower to constexpr-GEPs into static globals (`crates/ailang-codegen/src/lib.rs`, Str-literal arm) — a static `Str` carries no rc_header, so dec'ing a `Str` loop binder is UB when it holds a literal (e.g. the seed `"x"`). `runtime/rc.c` has no static-vs-heap-`Str` discriminator. Same obstacle as the deliberate `Str` exclusion in 8bcdae1 (which left the final-result `Str` loop leak in place): both the per-iteration superseded `Str` and the final-result `Str` cannot be safely RC-dec'd until static-`Str` and heap-`Str` pointers are distinguishable at runtime. Verified: a 3-iteration `Str` accumulator reports `allocs=4 frees=1 live=3`, stdout correct. Candidate directions for the `Str` leg (none chosen; touches content-addressing / `Str` hashing, so this is a design decision): - Runtime static-vs-heap `Str` discriminator (header sentinel or tagged pointer) so `ailang_rc_dec` no-ops on static `Str` — general, but interacts with `Str` identity/hashing. - Force-copy a static-`Str` loop seed into a heap slab on loop entry so the binder is always heap — narrower, but has a seed-provenance subtlety (the seed may already be a heap value to retain rather than copy). - Accept the bounded `Str`-loop leak as-is and document it.
Sign in to join this conversation.