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"
);
}
+110 -7
View File
@@ -2733,6 +2733,112 @@ impl<'a> Emitter<'a> {
pushed += 1;
}
}
// Iter 18d.4 fix + 18g.1: scrutinee ownership signal,
// hoisted here so both the pre-tail-call shallow-dec
// (Iter 18g.1, below) and the arm-close pattern-binder
// dec (Iter A, further below) consult the same gate.
//
// If the scrutinee resolves to a fn-param whose mode is
// `Borrow` or `Implicit`, the caller still holds a
// reference to the heap value the pattern-binders were
// loaded from. Dec'ing those binders (Iter A) — or the
// scrutinee's outer cell (18g.1) — fragments the
// caller's structure. Only `Own` carries the static
// "caller handed off ownership" signal that makes the
// children-dec safe.
//
// Non-fn-param scrutinees (let-binders, temp expressions)
// are treated as owned: the let-binder owns its RC ref,
// and a temp scrutinee was just produced by the local
// frame.
let scrutinee_is_owned: bool = match &scrutinee_binder {
Some(sb) => match self.current_param_modes.get(sb) {
Some(mode) => matches!(mode, ParamMode::Own),
None => true,
},
None => true,
};
// Iter 18g.1: pre-tail-call shallow-dec for moved-from
// scrutinee outer cell. Iter A (arm-close pattern-binder
// dec, below) cannot fire when `arm.body` is a tail call:
// `lower_term` emits `musttail call ... ret` and sets
// `block_terminated = true`, so the post-arm-body Iter A
// block is skipped. Iter B (Own-param dec at fn return) is
// also skipped for the same reason — the fn body's tail
// path is the tail call, not a fall-through `ret`.
//
// Concretely, in `(case (LCons h t) (tail-app sum_acc t (...)))`,
// the LCons outer cell (`xs`) has its only ptr field `t`
// moved into the tail call's args list (via
// `arm_pushed_binders` -> `moved_slots[xs]`). After the
// tail call, the outer cell is a husk: its tag and Int
// fields are stale, its ptr field points to memory now
// owned downstream. Nothing dec's the husk → one outer
// cell leak per consumed list element. 18f.2's tail-
// latency bench surfaced this: explicit-mode RC RSS peaks
// at the same level as implicit-mode RC despite full mode
// annotations.
//
// The fix is a shallow `ailang_rc_dec(scrutinee_ssa)`
// emitted *before* `lower_term(arm.body)`, which lands
// ahead of the `musttail call`. Shallow is safe iff every
// ptr-typed slot in this ctor was bound by a non-wildcard
// pattern (so its content is moved into a binder that
// owns it downstream); a Wild-bound ptr slot would still
// hold a live reference that the shallow free would
// strand. The gate enforces both: arm.body must be a
// tail call, scrutinee must be statically owned (the
// 18d.4-Iter-A param-mode gate), and every ptr field of
// the active ctor must be in `moved_slots[scrutinee]`.
//
// The dec lowers to `ailang_rc_dec` directly (not
// `field_drop_call` / `drop_<m>_<T>`) because the per-
// type drop fn would re-walk the ptr fields, dec'ing
// values that are now owned by downstream frames —
// that's exactly the bug 18d.3's `moved_slots` was set
// up to prevent.
let arm_body_is_tail_call = matches!(
&arm.body,
Term::App { tail: true, .. } | Term::Do { tail: true, .. }
);
if matches!(self.alloc, AllocStrategy::Rc)
&& arm_body_is_tail_call
&& scrutinee_is_owned
{
if let Some(sb) = &scrutinee_binder {
let moved = self.moved_slots.get(sb).cloned().unwrap_or_default();
let mut all_ptr_moved = true;
let mut any_ptr_field = false;
for idx in 0..bindings.len() {
let raw_ail = cref.ail_fields.get(idx).cloned().unwrap_or(Type::unit());
let qualified_ail = match &owning_module {
Some(m) => {
let owner_local_types = self.collect_owner_local_types(m);
qualify_local_types_codegen(&raw_ail, m, &owner_local_types)
}
None => raw_ail,
};
let bind_ail = if arm_subst.is_empty() {
qualified_ail
} else {
apply_subst_to_type(&qualified_ail, &arm_subst)
};
let fty = llvm_type(&bind_ail).unwrap_or_else(|_| "ptr".into());
if fty == "ptr" {
any_ptr_field = true;
if !moved.contains(&idx) {
all_ptr_moved = false;
break;
}
}
}
if any_ptr_field && all_ptr_moved {
self.body.push_str(&format!(
" call void @ailang_rc_dec(ptr {s_val})\n"
));
}
}
}
let (val, vty) = self.lower_term(&arm.body)?;
// Iter 18d.4: arm-close pattern-binder dec. Symmetric to
// 18c.3/18c.4's `Term::Let`-scope-close drop emission, but
@@ -2775,13 +2881,10 @@ impl<'a> Emitter<'a> {
// expressions) are treated as owned — the let-binder
// owns its RC ref, and a temp scrutinee was just
// produced by the local frame.
let scrutinee_is_owned: bool = match &scrutinee_binder {
Some(sb) => match self.current_param_modes.get(sb) {
Some(mode) => matches!(mode, ParamMode::Own),
None => true,
},
None => true,
};
//
// The `scrutinee_is_owned` binding is hoisted above so
// the 18g.1 pre-tail-call shallow-dec sees the same
// gate; do not re-declare here.
if matches!(self.alloc, AllocStrategy::Rc)
&& !self.block_terminated
&& scrutinee_is_owned
@@ -0,0 +1 @@
{"defs":[{"ctors":[{"fields":[],"name":"LNil"},{"fields":[{"k":"con","name":"Int"},{"k":"con","name":"IntList"}],"name":"LCons"}],"drop-iterative":true,"kind":"type","name":"IntList"},{"body":{"cond":{"args":[{"name":"n","t":"var"},{"lit":{"kind":"int","value":0},"t":"lit"}],"fn":{"name":"==","t":"var"},"t":"app"},"else":{"args":[{"args":[{"name":"n","t":"var"},{"lit":{"kind":"int","value":1},"t":"lit"}],"fn":{"name":"-","t":"var"},"t":"app"},{"args":[{"args":[{"name":"n","t":"var"},{"lit":{"kind":"int","value":1},"t":"lit"}],"fn":{"name":"-","t":"var"},"t":"app"},{"name":"acc","t":"var"}],"ctor":"LCons","t":"ctor","type":"IntList"}],"fn":{"name":"cons_n_acc","t":"var"},"t":"app","tail":true},"t":"if","then":{"name":"acc","t":"var"}},"kind":"fn","name":"cons_n_acc","params":["n","acc"],"type":{"effects":[],"k":"fn","param_modes":["implicit","own"],"params":[{"k":"con","name":"Int"},{"k":"con","name":"IntList"}],"ret":{"k":"con","name":"IntList"},"ret_mode":"own"}},{"body":{"args":[{"name":"n","t":"var"},{"args":[],"ctor":"LNil","t":"ctor","type":"IntList"}],"fn":{"name":"cons_n_acc","t":"var"},"t":"app"},"kind":"fn","name":"cons_n","params":["n"],"type":{"effects":[],"k":"fn","params":[{"k":"con","name":"Int"}],"ret":{"k":"con","name":"IntList"},"ret_mode":"own"}},{"body":{"arms":[{"body":{"name":"acc","t":"var"},"pat":{"ctor":"LNil","fields":[],"p":"ctor"}},{"body":{"args":[{"name":"t","t":"var"},{"args":[{"name":"acc","t":"var"},{"name":"h","t":"var"}],"fn":{"name":"+","t":"var"},"t":"app"}],"fn":{"name":"sum_acc","t":"var"},"t":"app","tail":true},"pat":{"ctor":"LCons","fields":[{"name":"h","p":"var"},{"name":"t","p":"var"}],"p":"ctor"}}],"scrutinee":{"name":"xs","t":"var"},"t":"match"},"kind":"fn","name":"sum_acc","params":["xs","acc"],"type":{"effects":[],"k":"fn","param_modes":["own","implicit"],"params":[{"k":"con","name":"IntList"},{"k":"con","name":"Int"}],"ret":{"k":"con","name":"Int"}}},{"body":{"args":[{"name":"xs","t":"var"},{"lit":{"kind":"int","value":0},"t":"lit"}],"fn":{"name":"sum_acc","t":"var"},"t":"app"},"kind":"fn","name":"sum_list","params":["xs"],"type":{"effects":[],"k":"fn","param_modes":["own"],"params":[{"k":"con","name":"IntList"}],"ret":{"k":"con","name":"Int"}}},{"body":{"args":[{"args":[{"args":[{"lit":{"kind":"int","value":100},"t":"lit"}],"fn":{"name":"cons_n","t":"var"},"t":"app"}],"fn":{"name":"sum_list","t":"var"},"t":"app"}],"op":"io/print_int","t":"do"},"kind":"fn","name":"main","params":[],"type":{"effects":["IO"],"k":"fn","params":[],"ret":{"k":"con","name":"Unit"}}}],"imports":[],"name":"rc_tail_sum_explicit_leak","schema":"ailang/v0"}
+69
View File
@@ -0,0 +1,69 @@
; Iter 18g.1 RED-test fixture: minimal tail-recursive list-sum
; under explicit-mode + (drop-iterative). Demonstrates the leak
; surfaced by the 18f.2 latency bench: the LCons outer cell, whose
; only ptr field `t` is moved into the tail-app of `sum_acc`, has
; no drop site and leaks once per element.
;
; Built and run with `AILANG_RC_STATS=1` under `--alloc=rc`. The
; stderr summary should show `allocs == frees + small_const`,
; where `small_const` covers the Tree-shaped drop-frames (zero
; here — the program returns Int, no live ADTs at exit).
;
; Pre-fix: `live = N` (one outer LCons cell per consumed element).
; Post-fix: `live = 0`.
(module rc_tail_sum_explicit_leak
(data IntList
(ctor LNil)
(ctor LCons (con Int) (con IntList))
(drop-iterative))
(fn cons_n_acc
(type
(fn-type
(params (con Int) (own (con IntList)))
(ret (own (con IntList)))))
(params n acc)
(body
(if (app == n 0)
acc
(tail-app cons_n_acc
(app - n 1)
(term-ctor IntList LCons (app - n 1) acc)))))
(fn cons_n
(type
(fn-type
(params (con Int))
(ret (own (con IntList)))))
(params n)
(body
(app cons_n_acc n (term-ctor IntList LNil))))
(fn sum_acc
(type
(fn-type
(params (own (con IntList)) (con Int))
(ret (con Int))))
(params xs acc)
(body
(match xs
(case (pat-ctor LNil) acc)
(case (pat-ctor LCons h t)
(tail-app sum_acc t (app + acc h))))))
(fn sum_list
(type
(fn-type
(params (own (con IntList)))
(ret (con Int))))
(params xs)
(body
(app sum_acc xs 0)))
(fn main
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body
(do io/print_int (app sum_list (app cons_n 100))))))