diff --git a/crates/ail/tests/raw_buf_loop_no_leak_pin.rs b/crates/ail/tests/raw_buf_loop_no_leak_pin.rs new file mode 100644 index 0000000..abfd614 --- /dev/null +++ b/crates/ail/tests/raw_buf_loop_no_leak_pin.rs @@ -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 = None; + let mut frees: Option = None; + let mut live: Option = 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})" + ); +} diff --git a/crates/ailang-codegen/src/drop.rs b/crates/ailang-codegen/src/drop.rs index 03b29cc..e711e06 100644 --- a/crates/ailang-codegen/src/drop.rs +++ b/crates/ailang-codegen/src/drop.rs @@ -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 diff --git a/examples/raw_buf_loop_no_leak_pin.ail b/examples/raw_buf_loop_no_leak_pin.ail new file mode 100644 index 0000000..df6c58c --- /dev/null +++ b/examples/raw_buf_loop_no_leak_pin.ail @@ -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)))))))