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
+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