fix: 18g.1 — pre-tail-call shallow-dec for moved-from scrutinee outer cell

18d.4 Iter A (arm-close pattern-binder dec) and Iter B (Own-param
dec at fn return) both fire AFTER the arm body lowers; for a tail-
call arm body, lower_term emits 'musttail call ... ret' and sets
block_terminated=true, so neither seam fires. The result: the
scrutinee's outer cell (whose ptr fields were all moved into the
tail-call's args via 18d.3 moved_slots) leaks one cell per
recursion step.

Surfaced by 18f.2's tail-latency bench: explicit-mode RC peaked
at the same 511 MB RSS as implicit-mode RC despite full mode
annotations — the per-op IntList chains were never being freed.
Bencher diagnosed the tail-call drop-elision; this iter implements
the fix.

Fix shape: in lower_match, BEFORE lower_term(arm.body), emit
'call void @ailang_rc_dec(ptr <scrutinee_ssa>)' iff:
  - alloc=Rc,
  - arm.body is structurally Term::App{tail:true} or
    Term::Do{tail:true},
  - scrutinee_is_owned (existing 18d.4 Iter A gate, hoisted),
  - every ptr-typed slot in this ctor's pattern is in
    moved_slots[scrutinee] (i.e. all ptr children are moved into
    binders that own them downstream — a Wild-bound ptr would
    still hold a live ref that the shallow free would strand).

Shallow rc_dec, not field_drop_call: the per-type drop fn would
re-walk ptr fields, dec'ing values now owned by the downstream
frame — exactly the bug 18d.3's moved_slots was set up to prevent.

Hoists scrutinee_is_owned to a single declaration shared by both
the new 18g.1 emission and 18d.4 Iter A.

Red: alloc_rc_explicit_mode_tail_sum_does_not_leak_outer_cells
on examples/rc_tail_sum_explicit_leak — 100 LCons cells leaked
pre-fix, 0 post-fix. Verified end-to-end on
bench_latency_explicit: pre-fix live=11068575,
post-fix live=1048575 (= the persistent tree cache, separate
issue at main-scope-close).

Test infrastructure: build_and_run_with_rc_stats helper +
AILANG_RC_STATS=1 env-var for the runtime atexit summary.
This commit is contained in:
2026-05-08 14:18:30 +02:00
parent fc5f4590f8
commit ae2eb2efac
4 changed files with 293 additions and 7 deletions
+113
View File
@@ -2011,3 +2011,116 @@ fn alloc_rc_pattern_bind_in_implicit_fn_does_not_dec_borrowed_children() {
"alloc=rc must match alloc=gc on rc_pin_recurse_implicit"
);
}
/// Iter 18g.0: build the example under `--alloc=rc`, run with
/// `AILANG_RC_STATS=1`, and return the parsed `(allocs, frees, live)`
/// triple from the runtime's atexit summary on stderr. The summary
/// line shape is fixed by `runtime/rc.c`'s `ailang_rc_stats_atexit`:
///
/// ailang_rc_stats: allocs=N frees=M live=K
///
/// Tests that need to assert allocation accounting (leaks, prompt-
/// drop coverage, refcount-based correctness) consume this triple
/// directly. `live == 0` at exit is the leak-free invariant for
/// fixtures whose `main` body returns Int/Unit; fixtures that
/// intentionally leak (e.g. Implicit-mode coverage) are gated to a
/// non-zero expected value naming the leak.
fn build_and_run_with_rc_stats(example: &str) -> (String, u64, u64, i64) {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let src = workspace.join("examples").join(example);
let tmp = std::env::temp_dir().join(format!(
"ailang_e2e_rcstats_{}_{}",
example.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 {example}");
let output = Command::new(&out)
.env("AILANG_RC_STATS", "1")
.output()
.expect("execute binary");
assert!(
output.status.success(),
"binary {} (--alloc=rc, AILANG_RC_STATS=1) exited non-zero",
out.display()
);
let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
let stderr = String::from_utf8(output.stderr).expect("stderr utf8");
let 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 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();
}
}
(
stdout,
allocs.expect("missing allocs= field"),
frees.expect("missing frees= field"),
live.expect("missing live= field"),
)
}
/// Iter 18g.1 regression: explicit-mode tail-recursive list-sum must
/// not leak the LCons outer cells.
///
/// Background: 18f.2's tail-latency bench found that
/// `bench_latency_explicit.ailx` peaks at the same RSS as the
/// implicit-mode variant despite carrying mode annotations
/// throughout the hot path. Diagnosis: in
///
/// (case (LCons h t) (tail-app sum_acc t (...)))
///
/// the pattern-binder `t` is consumed into the tail-call
/// (`consume_count > 0`), so 18d.4 Iter A skips the arm-close drop;
/// the outer LCons cell is now a husk (its only ptr field `t` was
/// moved out) but no drop site emits the shallow `ailang_rc_dec`
/// for it. One outer cell leaks per consumed list element.
///
/// Pre-fix on this fixture: `live = 100` (one LCons leak per
/// element of the 100-cell list).
/// Post-fix: `live = 0` (LNil + LCons cells all reach refcount
/// zero before exit).
///
/// Kept as regression coverage. The `(drop-iterative)` annotation
/// on IntList means the freeing happens via the worklist allocator,
/// not direct recursion — both paths must accumulate frees.
#[test]
fn alloc_rc_explicit_mode_tail_sum_does_not_leak_outer_cells() {
let (stdout, allocs, frees, live) =
build_and_run_with_rc_stats("rc_tail_sum_explicit_leak.ail.json");
assert_eq!(
stdout.trim(),
"4950",
"sum 0..99 must equal 4950 regardless of leak status"
);
assert_eq!(
live, 0,
"explicit-mode tail-sum leaks {live} cells (allocs={allocs} frees={frees}); \
18d.4 Iter A skips the arm-close drop because t is consumed into the tail-call, \
and no drop site emits the shallow rc_dec for the moved-from outer LCons cell"
);
}