From 04f75c8b710aa62cfe64cce841a7c7942ac4c1b1 Mon Sep 17 00:00:00 2001 From: Brummel Date: Sun, 31 May 2026 04:09:44 +0200 Subject: [PATCH] =?UTF-8?q?test(codegen):=20RED-pin=20#49=20Str=20leg=20?= =?UTF-8?q?=E2=80=94=20heap-Str=20loop=20binder=20must=20not=20leak=20acro?= =?UTF-8?q?ss=20recur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A heap-Str accumulator threaded through a `(loop …)`/`recur` as a loop binder leaks: each iteration's superseded `str_concat` slab is never RC-dec'd, even when the loop's final result is consumed. The fixture reports `allocs=4 frees=1 live=3` under AILANG_RC_STATS (stdout `xyyy` is correct). Root (data-flow traced): the `Term::Recur` arm in crates/ailang-codegen/src/lib.rs (~2131) gates its superseded-value dec behind `!is_str`. That exclusion exists because a `Str` loop seed may be a static-literal Str — a constexpr GEP into a packed-struct global with no rc_header (lib.rs:1612) — and `ailang_rc_dec` on a static pointer reads garbage at `payload-8` and aborts on underflow (rc.c:221, no runtime guard, per design/contracts/0011-str-abi.md). So the Str binder's prior heap slab is overwritten by a plain `store` with no dec. Distinct from the loop-RESULT scope-close drop (8bcdae1, which also excluded Str) and from the ADT leg already fixed in f488d31 — this is the per-iteration leak of the *intermediate* Str binder values, which f488d31 explicitly left open pending a representation decision. RED test crates/ail/tests/loop_recur_str_binder_no_leak_pin.rs with fixture examples/loop_recur_str_binder_no_leak_pin.ail: the Str leg asserts live=0 (RED at HEAD, live=3); the ADT leg (f488d31) is re-pinned as a green guard so a regression on the shared Term::Recur dec path is caught with it. GREEN side (chosen direction): promote a static-literal Str seed to heap via str_clone at loop entry so every Str binder slot holds a heap Str with a valid rc_header, then lift the `!is_str` recur-dec exclusion. This UPHOLDS the 0011-str-abi codegen-proof invariant (extends "static Str never reaches dec" to the loop case) rather than adding a runtime guard. refs #49 --- .../loop_recur_str_binder_no_leak_pin.rs | 147 ++++++++++++++++++ .../loop_recur_str_binder_no_leak_pin.ail | 33 ++++ 2 files changed, 180 insertions(+) create mode 100644 crates/ail/tests/loop_recur_str_binder_no_leak_pin.rs create mode 100644 examples/loop_recur_str_binder_no_leak_pin.ail diff --git a/crates/ail/tests/loop_recur_str_binder_no_leak_pin.rs b/crates/ail/tests/loop_recur_str_binder_no_leak_pin.rs new file mode 100644 index 0000000..be38e4c --- /dev/null +++ b/crates/ail/tests/loop_recur_str_binder_no_leak_pin.rs @@ -0,0 +1,147 @@ +//! RED-pin for the per-iteration heap-**Str** loop-binder leak across +//! `recur` (bug #49, Str leg). +//! +//! Property protected: under `--alloc=rc`, when a `(loop ...)` carries a +//! `Str` loop binder seeded with a literal and a `recur` REPLACES that +//! binder with a FRESH heap value (a `str_concat` result), every +//! superseded prior heap slab MUST be RC-decremented at the `recur` +//! store. Even though the loop's FINAL result is correctly consumed +//! (here by `print`), the intermediate per-iteration `str_concat` slabs +//! leak. The runtime RC stats line at exit must report `live == 0` +//! (equivalently `allocs == frees`). +//! +//! Root cause (data-flow traced): codegen's `Term::Recur` arm in +//! `crates/ailang-codegen/src/lib.rs` (the per-binder store loop, the +//! arm added in commit f488d31) emits the superseded-value dec only +//! under the gate `matches!(self.alloc, Rc) && is_ptr && !is_str`. The +//! `!is_str` clause excludes a `Str`-typed binder entirely, so the +//! `recur` falls straight to the plain `store` and the prior +//! `str_concat` heap slab it overwrites is never dec'd. This is the SAME +//! leak class as the boxed-ADT leg fixed in f488d31 — the ONLY +//! difference is the `!is_str` clause in the gate. +//! +//! Why the Str exclusion existed: the `Str` loop *seed* (`"x"`) may be a +//! constexpr-GEP into a static global with no `rc_header`, so an +//! UNCONDITIONAL dec on iteration 1 would be UB. The fix must dec the +//! superseded value WITHOUT dec'ing a static-literal seed (the same +//! static-vs-heap-`Str` representation question behind the deliberate +//! `Str` exclusions in commits 8bcdae1 and f488d31). +//! +//! As of HEAD this test fails: stderr reports +//! `ailang_rc_stats: allocs=4 frees=1 live=3` — the three superseded Str +//! slabs (seed "x" plus two intermediate concat results) leak while +//! stdout is already correct (`xyyy`). +//! +//! This file also re-pins the boxed-ADT leg (`alloc_rc_adt_*`) as a +//! GREEN guard: the f488d31 fix must stay green while the Str leg is +//! closed — fixing Str must not regress the ADT case. + +use std::path::Path; +use std::process::Command; + +fn ail_bin() -> &'static str { + env!("CARGO_BIN_EXE_ail") +} + +/// Build `examples/.ail` under `--alloc=rc`, run it with +/// `AILANG_RC_STATS=1`, and return `(allocs, frees, live, stdout)`. +fn build_run_stats(fixture: &str) -> (u64, u64, i64, String) { + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap(); + let src = workspace.join("examples").join(fixture); + let tmp = std::env::temp_dir().join(format!( + "ailang_{}_{}", + fixture.replace(['/', '.'], "_"), + 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 {fixture}"); + let output = Command::new(&out) + .env("AILANG_RC_STATS", "1") + .output() + .expect("execute binary"); + assert!( + output.status.success(), + "binary exited non-zero for {fixture}: status {:?}", + output.status + ); + let stdout = String::from_utf8(output.stdout).expect("stdout utf8"); + 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 for {fixture}; 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(); + } + } + ( + allocs.expect("missing allocs= field"), + frees.expect("missing frees= field"), + live.expect("missing live= field"), + stdout, + ) +} + +/// The bug under test: a heap-Str loop binder replaced by `recur` leaks +/// every superseded `str_concat` slab. RED at HEAD (live=3). +#[test] +fn alloc_rc_str_loop_binder_replaced_by_recur_does_not_leak() { + let (allocs, frees, live, stdout) = + build_run_stats("loop_recur_str_binder_no_leak_pin.ail"); + assert_eq!( + stdout.trim_end(), + "xyyy", + "loop result mis-built (allocs={allocs} frees={frees} live={live})" + ); + assert_eq!( + live, 0, + "a heap-Str loop binder replaced by `recur` leaks {live} \ + superseded slab(s) (allocs={allocs} frees={frees}); the \ + Term::Recur arm in crates/ailang-codegen/src/lib.rs gates its \ + superseded-value dec behind `!is_str`, so the prior str_concat \ + heap slab is overwritten by a plain store without RC-dec." + ); + assert_eq!( + allocs, frees, + "alloc/free mismatch (allocs={allocs} frees={frees} live={live})" + ); +} + +/// GREEN guard: the boxed-ADT leg fixed in f488d31 must stay leak-clean +/// while the Str leg is closed. Fixing Str must not regress this. +#[test] +fn alloc_rc_adt_loop_binder_replaced_by_recur_stays_leak_clean() { + let (allocs, frees, live, stdout) = + build_run_stats("loop_recur_heap_binder_no_leak_pin.ail"); + assert_eq!( + stdout.trim_end(), + "2", + "ADT loop result mis-built (allocs={allocs} frees={frees} live={live})" + ); + assert_eq!( + live, 0, + "the f488d31 boxed-ADT recur-dec regressed: live={live} \ + (allocs={allocs} frees={frees})" + ); + assert_eq!( + allocs, frees, + "ADT leg alloc/free mismatch (allocs={allocs} frees={frees} live={live})" + ); +} diff --git a/examples/loop_recur_str_binder_no_leak_pin.ail b/examples/loop_recur_str_binder_no_leak_pin.ail new file mode 100644 index 0000000..e432ad9 --- /dev/null +++ b/examples/loop_recur_str_binder_no_leak_pin.ail @@ -0,0 +1,33 @@ +(module loop_recur_str_binder_no_leak_pin + ; RED for bug #49 (Str leg): a heap-Str loop binder that `recur` + ; REPLACES with a fresh heap value each iteration. The seed is the + ; static literal "x"; each `recur` rebinds `acc` to a FRESH heap slab + ; returned by `str_concat`. Codegen's Term::Recur arm + ; (crates/ailang-codegen/src/lib.rs) decs the superseded prior binder + ; value only under the gate `is_ptr && !is_str`; the `!is_str` clause + ; excludes a Str binder entirely, so the plain `store` overwrites the + ; prior str_concat slab without RC-dec'ing it, leaking it every + ; iteration. + ; + ; The companion ADT fixture (loop_recur_heap_binder_no_leak_pin.ail, + ; commit f488d31) is the SAME leak class WITH the dec emitted; the only + ; difference is the `!is_str` clause. The Str exclusion was a + ; conservatism about the static seed literal: the seed "x" may be a + ; constexpr-GEP into a static global with no rc_header, so an + ; unconditional dec on iteration 1 would be UB. But the intermediate + ; str_concat results ARE heap slabs that must be dec'd. + ; + ; allocs: three str_concat results (acc="xy","xyy","xyyy") = 3 heap + ; slabs (the seed "x" is a static literal, not counted as an alloc). + ; The loop returns acc="xyyy"; that FINAL result is consumed by print. + ; The two superseded heap results ("xy", "xyy") plus the final "xyyy" + ; after print must all be freed. Pre-fix: live=3. Post-fix: live=0. + ; Expected stdout: xyyy. + (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)))))