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
@@ -0,0 +1,104 @@
//! RED-pin for the loop-valued let-binder scope-close drop gap
//! (surfaced by the raw-buf fieldtest, refs #7).
//!
//! Property protected: under `--alloc=rc`, an owned `(RawBuf Int)`
//! whose `let`-binding value is a `(loop ...)` result, and which is
//! afterwards only borrow-read (`RawBuf.get`) — never consumed by an
//! `own`-taking callee — MUST be dropped at let-scope-close. The
//! runtime RC stats line at exit must report `live == 0`
//! (equivalently `allocs == frees`).
//!
//! Root cause: codegen's `is_rc_heap_allocated`
//! (`crates/ailang-codegen/src/drop.rs`) — the predicate the
//! `Term::Let` lowering uses to decide whether a let-binder owns a
//! fresh RC-heap allocation to `dec` at scope close — has no
//! `Term::Loop` arm. A loop-valued let-value falls through to
//! `_ => false`, so the binder is never flagged trackable and
//! `drop_symbol_for_binder` is never called for it. Compare the
//! direct-`(new ...)`-value path (`examples/raw_buf_drop_min.ail`)
//! which IS dropped, and the Own-returning `Term::App` arm which is.
//!
//! As of HEAD this test fails: stderr reports
//! `ailang_rc_stats: allocs=2 frees=1 live=1`. The owned RawBuf slab
//! threaded through the loop binder leaks.
//!
//! Scope note: this pins ONLY the scope-close drop gap. A distinct,
//! broader per-iteration recur-dec leak (superseded heap loop-binder
//! values not RC-dec'd inside the loop body) is filed separately and
//! is NOT covered here. RawBuf manifests only the scope-close gap
//! because `RawBuf.set` mutates in place (no per-iteration alloc).
use std::path::Path;
use std::process::Command;
fn ail_bin() -> &'static str {
env!("CARGO_BIN_EXE_ail")
}
#[test]
fn alloc_rc_loop_valued_rawbuf_binder_drops_at_scope_close() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let src = workspace
.join("examples")
.join("raw_buf_loop_no_leak_pin.ail");
let tmp = std::env::temp_dir().join(format!(
"ailang_raw_buf_loop_no_leak_pin_{}",
std::process::id()
));
std::fs::create_dir_all(&tmp).unwrap();
let out = tmp.join("bin");
let status = Command::new(ail_bin())
.args(["build", src.to_str().unwrap(), "--alloc=rc", "-o"])
.arg(&out)
.status()
.expect("ail build failed to run");
assert!(
status.success(),
"ail build --alloc=rc failed for raw_buf_loop_no_leak_pin.ail"
);
let output = Command::new(&out)
.env("AILANG_RC_STATS", "1")
.output()
.expect("execute binary");
assert!(
output.status.success(),
"binary exited non-zero: status {:?}",
output.status
);
let stderr = String::from_utf8(output.stderr).expect("stderr utf8");
let stats_line = stderr
.lines()
.find(|l| l.starts_with("ailang_rc_stats:"))
.unwrap_or_else(|| {
panic!("missing ailang_rc_stats line in stderr; stderr was:\n{stderr}")
});
let mut allocs: Option<u64> = None;
let mut frees: Option<u64> = None;
let mut live: Option<i64> = None;
for tok in stats_line.split_whitespace() {
if let Some(v) = tok.strip_prefix("allocs=") {
allocs = v.parse().ok();
} else if let Some(v) = tok.strip_prefix("frees=") {
frees = v.parse().ok();
} else if let Some(v) = tok.strip_prefix("live=") {
live = v.parse().ok();
}
}
let allocs = allocs.expect("missing allocs= field");
let frees = frees.expect("missing frees= field");
let live = live.expect("missing live= field");
assert_eq!(
live, 0,
"an owned (RawBuf Int) bound to a (loop ...) result and only \
borrow-read leaks {live} slab cell(s) (allocs={allocs} \
frees={frees}); the loop-valued let-binder must drop at \
scope close. `is_rc_heap_allocated` in \
crates/ailang-codegen/src/drop.rs has no Term::Loop arm, so \
the binder is never flagged trackable."
);
assert_eq!(
allocs, frees,
"alloc/free mismatch (allocs={allocs} frees={frees} live={live})"
);
}
+25 -1
View File
@@ -475,6 +475,30 @@ impl<'a> Emitter<'a> {
.map(|m| matches!(m, ParamMode::Own)) .map(|m| matches!(m, ParamMode::Own))
.unwrap_or(false) .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, _ => false,
} }
} }
@@ -533,7 +557,7 @@ impl<'a> Emitter<'a> {
// the ret-type is not a `Type::Con` (e.g. a bare type // the ret-type is not a `Type::Con` (e.g. a bare type
// var on an as-yet-unmonomorphised polymorphic call — // var on an as-yet-unmonomorphised polymorphic call —
// the monomorphised copies will resolve correctly). // the monomorphised copies will resolve correctly).
Term::App { .. } => { Term::App { .. } | Term::Loop { .. } => {
if let Ok(Type::Con { name, .. }) = self.synth_arg_type(value) { if let Ok(Type::Con { name, .. }) = self.synth_arg_type(value) {
// Symmetric to `field_drop_call`'s Str arm: Str is a // Symmetric to `field_drop_call`'s Str arm: Str is a
// built-in pointer type with no per-type drop fn. Both // built-in pointer type with no per-type drop fn. Both
+10
View File
@@ -0,0 +1,10 @@
(module raw_buf_loop_no_leak_pin
(fn main
(doc "RED-pin fixture for the loop-valued let-binder scope-close drop gap. Under --alloc=rc, an owned `(RawBuf Int)` whose let-value is a `(loop ...)` result and which is afterwards only borrow-read (RawBuf.get) is NEVER dropped at scope close. `is_rc_heap_allocated` (crates/ailang-codegen/src/drop.rs) has no `Term::Loop` arm, so the `Term::Let` lowering never flags the binder `filled` as trackable and emits no drop. AILANG_RC_STATS=1 reports `allocs=2 frees=1 live=1` (the RawBuf slab leaks). Compare examples/raw_buf_drop_min.ail (direct `(new ...)` value, no loop) which is dropped correctly. Expected post-fix: `allocs == frees && live == 0`.")
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body
(let buf (new RawBuf (con Int) 4)
(let filled (loop (b (con RawBuf (con Int)) buf) (i (con Int) 0)
(if (app ge i 4) b (recur (app RawBuf.set b i i) (app + i 1))))
(app print (app RawBuf.get filled 0)))))))