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:
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user