diff --git a/crates/ail/tests/e2e.rs b/crates/ail/tests/e2e.rs index 4bba138..2bced03 100644 --- a/crates/ail/tests/e2e.rs +++ b/crates/ail/tests/e2e.rs @@ -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 = None; + let mut frees: Option = None; + let mut live: Option = 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" + ); +} diff --git a/crates/ailang-codegen/src/lib.rs b/crates/ailang-codegen/src/lib.rs index fb84328..e428ad4 100644 --- a/crates/ailang-codegen/src/lib.rs +++ b/crates/ailang-codegen/src/lib.rs @@ -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__`) 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 diff --git a/examples/rc_tail_sum_explicit_leak.ail.json b/examples/rc_tail_sum_explicit_leak.ail.json new file mode 100644 index 0000000..6592581 --- /dev/null +++ b/examples/rc_tail_sum_explicit_leak.ail.json @@ -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"} diff --git a/examples/rc_tail_sum_explicit_leak.ailx b/examples/rc_tail_sum_explicit_leak.ailx new file mode 100644 index 0000000..a5924c4 --- /dev/null +++ b/examples/rc_tail_sum_explicit_leak.ailx @@ -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))))))