fix(codegen): drop a let-bound owned loop result at scope close

An owned heap value whose `let`-binding value is a `(loop ...)` result,
afterwards only borrow-read (or unused), was never dropped at scope close
— a memory leak (AILANG_RC_STATS live=1). Newly observable once #47 made
fill-loops build: rawbuf_2_running_max leaked one buffer per run.

Root cause: is_rc_heap_allocated — the predicate the let-lowering uses to
decide a scope-close `dec` — matched only Term::Ctor, Term::Lam and
Own-returning Term::App. A Term::Loop-valued binder fell through to false,
so it was never registered for drop. (Sibling of the #47 synth gap: the
Term::Loop arm under-handled in yet another pass.)

Fix: add a Term::Loop arm that tracks the binder iff the loop's static
result type lowers to llvm `ptr` (boxed/heap) AND is not Str — and widen
drop_symbol_for_binder's App arm to cover Term::Loop so it resolves the
per-type drop symbol. Two load-bearing safety gates:
  - the `ptr` gate excludes unboxed primitives (Int/Bool/Float/Unit), so
    an Int-returning loop is not handed to a drop fn;
  - Str is excluded because a loop can return a *static* Str (a seed
    literal threaded unchanged) and ailang_rc_dec on a static-Str pointer
    is UB; an Own-App can never return a static Str, which is why the App
    arm may track Str but the Loop arm must not.
The loop's seed binders are consumed (moved in), so nothing else tracks
the result; linearity guarantees no alias, so the new drop is single.

Verified leak-clean and double-free-free across the fieldtest RawBuf set,
the consumed case, the Int-gate case, and the escape case (a fn returning
a loop-built owned RawBuf to its caller).

The separate per-iteration leak of superseded heap loop-binder values
(e.g. a Str accumulator threaded through recur) is a distinct, broader
pre-existing bug, filed separately — not addressed here.

RED-first: raw_buf_loop_no_leak_pin.
This commit is contained in:
2026-05-30 17:14:28 +02:00
parent db710b1a73
commit 8bcdae1b53
3 changed files with 139 additions and 1 deletions
+25 -1
View File
@@ -475,6 +475,30 @@ impl<'a> Emitter<'a> {
.map(|m| matches!(m, ParamMode::Own))
.unwrap_or(false)
}
Term::Loop { .. } => {
// Loop result is owned-and-untracked (seeds are moved in).
// Track iff its static type is boxed/heap (llvm `ptr`) AND not
// Str. Str is excluded: a loop can return a *static* Str (a seed
// literal threaded unchanged), and `ailang_rc_dec` on a static-Str
// pointer is UB (see drop.rs Str-realisation note). An Own-App can
// never return a static Str, which is why the App arm may track Str
// but the Loop arm must not. Unboxed primitives (Int/Bool/Float/Unit)
// lower to non-`ptr` and are correctly excluded by the `ptr` gate.
match self.synth_arg_type(value) {
Ok(t) => {
let is_ptr = matches!(
crate::synth::llvm_type(&t).as_deref(),
Ok("ptr")
);
let is_str = matches!(
&t,
Type::Con { name, .. } if name == "Str"
);
is_ptr && !is_str
}
Err(_) => false,
}
}
_ => false,
}
}
@@ -533,7 +557,7 @@ impl<'a> Emitter<'a> {
// the ret-type is not a `Type::Con` (e.g. a bare type
// var on an as-yet-unmonomorphised polymorphic call —
// the monomorphised copies will resolve correctly).
Term::App { .. } => {
Term::App { .. } | Term::Loop { .. } => {
if let Ok(Type::Con { name, .. }) = self.synth_arg_type(value) {
// Symmetric to `field_drop_call`'s Str arm: Str is a
// built-in pointer type with no per-type drop fn. Both