codegen rc: tag-conditional partial-drop helper closes 3 carve-outs

Three sibling sites previously fell back to shallow `ailang_rc_dec`
when a binder had non-empty `moved_slots` and a dynamic runtime ctor
tag: Iter B Own-param dec at fn-return (lib.rs), Iter A arm-close
pattern-binder dec (match_lower.rs), and emit_inlined_partial_drop's
non-Ctor branch (drop.rs). Shallow freed the outer cell but did not
walk the unmoved ptr fields — silent leak.

Adds `emit_partial_drop_fn_for_type`: a per-ADT helper structurally
parallel to `emit_drop_fn_for_type` but taking an i64 moved-slots
bitmask. Each ptr-field's dec is gated on the corresponding mask
bit; the outer box is dec'd at join. Emitted alongside the per-type
drop fn for every ADT under --alloc=rc.

Three call sites rewritten to resolve the partial_drop symbol from
the binder's static type and build the mask from `moved_slots`.

Three RED-then-GREEN fixtures (rc_own_param_/rc_match_arm_/
rc_app_let_partial_drop_leak) pin the carve-outs in regression
coverage; live cell counts go from 3/1/3 (pre-fix) to 0/0/0.
e2e count: 66 → 69.

The pre-existing `alloc_rc_own_param_dec_at_fn_return` IR-shape
assertion is widened to accept `partial_drop_<m>_<T>(%arg_xs,
i64 mask)` as a third valid drop shape (the canonical fu2 case).

Closes the JOURNAL queue's only remaining iter; the "wait for
organic fixture" stance from the 18g tidy is retracted — known
leaks are bugs, CLAUDE.md's TDD rule covers them autonomously.
This commit is contained in:
2026-05-08 16:25:40 +02:00
parent 7b65c121ed
commit caefcf996a
11 changed files with 763 additions and 57 deletions
+140 -7
View File
@@ -1767,17 +1767,27 @@ fn alloc_rc_own_param_dec_at_fn_return() {
let fn_body = &ir[fn_start..fn_start + fn_end_offset];
// The Own-param drop must fire on `%arg_xs` somewhere in
// head_or_zero's body. Either the per-type drop (empty
// moved_slots case) or the shallow `ailang_rc_dec` (non-empty
// moved_slots case) is acceptable. The canonical fixture here
// hits the latter: the Cons arm moves slot 1 into the arm-bound
// `t`, so moved_slots[xs] = {1} at fn return.
// head_or_zero's body. Three acceptable shapes:
// - per-type drop `@drop_<m>_IntList(ptr %arg_xs)` (empty
// moved_slots case),
// - tag-conditional partial-drop
// `@partial_drop_<m>_IntList(ptr %arg_xs, i64 <mask>)`
// (Iter 18g.tidy.fu2: non-empty moved_slots, dynamic-tag),
// - shallow `@ailang_rc_dec(ptr %arg_xs)` (legacy fallback;
// the typechecker accepts non-ADT param types but moved_slots
// cannot populate for those, so this branch is dead in
// practice).
// The canonical fixture here hits the partial-drop shape: the
// Cons arm moves slot 1 into the arm-bound `t`, so
// moved_slots[xs] = {1} at fn return → mask=2.
let has_per_type_drop =
fn_body.contains("call void @drop_rc_own_param_drop_IntList(ptr %arg_xs)");
let has_partial_drop = fn_body
.contains("call void @partial_drop_rc_own_param_drop_IntList(ptr %arg_xs,");
let has_shallow_dec = fn_body.contains("call void @ailang_rc_dec(ptr %arg_xs)");
assert!(
has_per_type_drop || has_shallow_dec,
"head_or_zero's body must drop the Own-mode param `xs` (either via per-type drop or shallow dec) before the fn's `ret`. Body was:\n{fn_body}"
has_per_type_drop || has_partial_drop || has_shallow_dec,
"head_or_zero's body must drop the Own-mode param `xs` (per-type, partial-drop, or shallow dec) before the fn's `ret`. Body was:\n{fn_body}"
);
// The drop must precede the fn's `ret`. Scope: any `ret i64 ...`
@@ -1792,6 +1802,8 @@ fn alloc_rc_own_param_dec_at_fn_return() {
let ret_idx = fn_body.find("\n ret i64").expect("head_or_zero has a ret i64");
let pre_ret = &fn_body[..ret_idx];
let drop_in_pre_ret = pre_ret.contains("call void @drop_rc_own_param_drop_IntList(ptr %arg_xs)")
|| pre_ret
.contains("call void @partial_drop_rc_own_param_drop_IntList(ptr %arg_xs,")
|| pre_ret.contains("call void @ailang_rc_dec(ptr %arg_xs)");
assert!(
drop_in_pre_ret,
@@ -2239,3 +2251,124 @@ fn alloc_rc_let_alias_of_implicit_param_does_not_dec_borrowed_children() {
"alloc=rc must match alloc=gc on rc_let_alias_implicit_param"
);
}
/// Iter 18g.tidy.fu2 regression — RED-then-GREEN for the
/// dynamic-tag partial-drop carve-out at fn-return.
///
/// Three carve-out sites previously fell back to a shallow
/// `ailang_rc_dec` when a binder's `moved_slots` was non-empty
/// and the runtime ctor tag was dynamic (not statically known
/// from a `Term::Ctor` value). The shallow dec frees the outer
/// cell but does not walk the unmoved ptr fields — those leak
/// silently.
///
/// **Site 1 (this fixture): Iter B Own-param dec at fn return
/// (lib.rs).**
///
/// (fn use_first
/// (params (own (con Pair)))
/// (body
/// (match p
/// (case (pat-ctor MkPair a _) 1))))
///
/// `p` is consumed by the match. Its `consume_count == 0`,
/// `mode == Own`, and `moved_slots[p] = {0}` (slot 0 was bound,
/// slot 1 wildcarded). At fn-return, Iter B's dynamic-tag fallback
/// fired shallow `ailang_rc_dec(p)`, freeing p's outer Pair but
/// leaking the unbound second `MkCell` and its two `MkWrap`
/// children — three cells.
///
/// fu2 routes the carve-out through `partial_drop_<m>_Pair(p,
/// mask)`, which dispatches on the runtime tag and dec's slot 1
/// (the unmoved field) via `drop_<m>_Cell`, cascading correctly.
///
/// Pre-fix: `live = 3`. Post-fix: `live = 0`.
#[test]
fn alloc_rc_partial_drop_at_fn_return_does_not_leak_unmoved_fields() {
let (stdout, allocs, frees, live) =
build_and_run_with_rc_stats("rc_own_param_partial_drop_leak.ail.json");
assert_eq!(
stdout.trim(),
"1",
"use_first returns 1 regardless of leak status"
);
assert_eq!(
live, 0,
"Own-param fn-return partial-drop carve-out leaked {live} cells \
(allocs={allocs} frees={frees}); fu2 must route the dynamic-tag \
fallback through `partial_drop_<m>_<T>(p, mask)` so the unmoved \
slots dec via the per-type cascade rather than being shallow-freed"
);
}
/// Iter 18g.tidy.fu2 regression — site 2: Iter A arm-close
/// pattern-binder dec (match_lower.rs).
///
/// An outer match's arm-bound pattern-binder may itself be the
/// scrutinee of an inner match that moves out some of its fields.
/// At outer-arm close, `moved_slots[bname]` is non-empty and
/// `consume_count[bname] == 0` — the carve-out previously emitted
/// a shallow `ailang_rc_dec`, leaking the inner ctor's unbound
/// (wildcarded) ptr fields.
///
/// (match p
/// (case (pat-ctor MkPair a b)
/// (match a
/// (case (pat-ctor MkCell w1 _) 1))))
///
/// `a` has `moves={0}` (w1 bound, slot 1 of MkCell wildcarded).
/// Pre-fix: shallow dec on `a` left slot 1's `MkWrap(2)` cell
/// allocated.
/// Post-fix: `partial_drop_<m>_Cell(a, mask=001)` dec's slot 1.
///
/// Pre-fix: `live = 1`. Post-fix: `live = 0`.
#[test]
fn alloc_rc_partial_drop_at_arm_close_does_not_leak_unmoved_fields() {
let (stdout, allocs, frees, live) =
build_and_run_with_rc_stats("rc_match_arm_partial_drop_leak.ail.json");
assert_eq!(
stdout.trim(),
"1",
"use_first returns 1 regardless of leak status"
);
assert_eq!(
live, 0,
"match-arm pattern-binder partial-drop carve-out leaked {live} cells \
(allocs={allocs} frees={frees}); fu2 must route arm-bound binders \
with non-empty moved_slots through `partial_drop_<m>_<T>(b, mask)`"
);
}
/// Iter 18g.tidy.fu2 regression — site 3: let-close for an
/// App-bound binder (drop.rs `emit_inlined_partial_drop`
/// non-Ctor branch).
///
/// `emit_inlined_partial_drop` was keyed against `Term::Ctor`
/// values; for `Term::App` (Own-returning) values its fallback
/// shallow-dec'd the binder. With pattern-matching of the
/// App-bound binder (`(let p (app build_pair) (match p ...))`),
/// the body's `moved_slots[p]` is non-empty but the binder's
/// runtime tag is dynamic (unknown from the App's surface).
///
/// fu2 derives the type from `synth_arg_type(value)` and routes
/// through `partial_drop_<m>_<T>(p, mask)`.
///
/// Pre-fix: `live = 3` (slot 1's whole MkCell + two MkWraps).
/// Post-fix: `live = 0`.
#[test]
fn alloc_rc_partial_drop_at_app_let_close_does_not_leak_unmoved_fields() {
let (stdout, allocs, frees, live) =
build_and_run_with_rc_stats("rc_app_let_partial_drop_leak.ail.json");
assert_eq!(
stdout.trim(),
"1",
"use_cell returns 1 regardless of leak status"
);
assert_eq!(
live, 0,
"App-bound let-close partial-drop carve-out leaked {live} cells \
(allocs={allocs} frees={frees}); fu2 must derive the binder's \
static type from synth_arg_type(value) and route through \
`partial_drop_<m>_<T>(p, mask)`"
);
}
+219 -24
View File
@@ -538,6 +538,193 @@ impl<'a> Emitter<'a> {
}
}
/// Iter 18g.tidy.fu2: tag-conditional partial-drop helper.
/// Emits `void @partial_drop_<m>_<T>(ptr %p, i64 %mask)` —
/// structurally parallel to [`Self::emit_drop_fn_for_type`] but
/// gates each ptr-field dec on a bit of `%mask`. If bit j of the
/// mask is set, slot j was moved out of the cell (its content
/// belongs to a downstream owner) and the dec is skipped; if the
/// bit is clear, the field is loaded and routed through
/// `field_drop_call` (same dispatch the recursive cascade uses).
/// The outer box is always dec'd at `join`.
///
/// Closes the three dynamic-tag carve-outs that previously
/// fell back to shallow `ailang_rc_dec`:
/// 1. `lib.rs` Iter B Own-param dec at fn-return when
/// `moved_slots[param]` is non-empty;
/// 2. `match_lower.rs` Iter A arm-close pattern-binder dec
/// when the binder was itself the scrutinee of an inner
/// match that moved out some fields;
/// 3. [`Self::emit_inlined_partial_drop`]'s non-Ctor branch
/// (let-binder whose value is `Term::App`).
/// All three share the same shape: a binder whose runtime ctor
/// tag is dynamic (not statically known from a `Term::Ctor`)
/// and whose `moved_slots` is a strict subset of its ptr fields.
/// Pre-fu2 the unmoved fields leaked silently; fu2 routes them
/// through this helper.
///
/// IR shape (analogous to `emit_drop_fn_for_type`):
/// ```text
/// define void @partial_drop_<m>_<T>(ptr %p, i64 %mask) {
/// %is_null = icmp eq ptr %p, null
/// br i1 %is_null, label %ret, label %live
/// live:
/// %tag = load i64, ptr %p
/// switch i64 %tag, label %dflt [...]
/// arm_i:
/// ; for each ptr field f_j of ctor i:
/// %b_j = and i64 %mask, (1 << j)
/// %s_j = icmp ne i64 %b_j, 0
/// br i1 %s_j, label %after_i_j, label %do_i_j
/// do_i_j:
/// %a = gep i8, ptr %p, (8 + 8*j)
/// %v = load ptr, ptr %a
/// call void @<field_drop_call>(ptr %v)
/// br label %after_i_j
/// after_i_j:
/// ; next field, or br label %join
/// dflt: unreachable
/// join:
/// call void @ailang_rc_dec(ptr %p)
/// br label %ret
/// ret:
/// ret void
/// }
/// ```
///
/// One helper per ADT, emitted alongside `drop_<m>_<T>`. We do
/// NOT emit a `(drop-iterative)` partial-drop variant: the
/// helper runs once on the binder (carve-out sites are not
/// cascade points — the unmoved fields go through their own
/// `drop_<m>_<F>` which itself decides recursive vs iterative).
pub(crate) fn emit_partial_drop_fn_for_type(&mut self, td: &TypeDef) {
let m = self.module_name;
let tname = &td.name;
let mut out = String::new();
out.push_str(&format!(
"define void @partial_drop_{m}_{tname}(ptr %p, i64 %mask) {{\n"
));
out.push_str("entry:\n");
out.push_str(" %is_null = icmp eq ptr %p, null\n");
out.push_str(" br i1 %is_null, label %ret, label %live\n");
out.push_str("live:\n");
out.push_str(" %tag = load i64, ptr %p, align 8\n");
let n_ctors = td.ctors.len();
out.push_str(" switch i64 %tag, label %dflt [\n");
for (i, _) in td.ctors.iter().enumerate() {
out.push_str(&format!(" i64 {i}, label %arm_{i}\n"));
}
out.push_str(" ]\n");
let mut local = 0u64;
for (i, ctor) in td.ctors.iter().enumerate() {
out.push_str(&format!("arm_{i}:\n"));
for (j, fty) in ctor.fields.iter().enumerate() {
let lty = llvm_type(fty).unwrap_or_else(|_| "ptr".into());
if lty != "ptr" {
continue;
}
let bit_id = local;
local += 1;
let set_id = local;
local += 1;
let bitmask: u64 = 1u64 << j;
out.push_str(&format!(
" %b{bit_id} = and i64 %mask, {bitmask}\n"
));
out.push_str(&format!(
" %s{set_id} = icmp ne i64 %b{bit_id}, 0\n"
));
out.push_str(&format!(
" br i1 %s{set_id}, label %after_{i}_{j}, label %do_{i}_{j}\n"
));
out.push_str(&format!("do_{i}_{j}:\n"));
let off = 8 + (j as i64) * 8;
let addr_id = local;
local += 1;
let val_id = local;
local += 1;
out.push_str(&format!(
" %a{addr_id} = getelementptr inbounds i8, ptr %p, i64 {off}\n"
));
out.push_str(&format!(
" %v{val_id} = load ptr, ptr %a{addr_id}, align 8\n"
));
let drop_call = self.field_drop_call(fty);
out.push_str(&format!(
" call void @{drop_call}(ptr %v{val_id})\n"
));
out.push_str(&format!(" br label %after_{i}_{j}\n"));
out.push_str(&format!("after_{i}_{j}:\n"));
}
out.push_str(" br label %join\n");
}
out.push_str("dflt:\n");
if n_ctors == 0 {
out.push_str(" br label %join\n");
} else {
out.push_str(" unreachable\n");
}
out.push_str("join:\n");
out.push_str(" call void @ailang_rc_dec(ptr %p)\n");
out.push_str(" br label %ret\n");
out.push_str("ret:\n");
out.push_str(" ret void\n");
out.push_str("}\n\n");
self.body.push_str(&out);
}
/// Iter 18g.tidy.fu2: resolve the `partial_drop_<owner>_<T>`
/// symbol for a type, parallel to the existing per-type drop
/// dispatch in `field_drop_call`. Returns `None` for non-ADT
/// types (Str, fn-typed, type-vars) — callers fall back to
/// shallow `ailang_rc_dec` in those cases (no per-type drop
/// fn exists either, and `moved_slots` would not have been
/// populated for those shapes anyway).
pub(crate) fn partial_drop_symbol_for_type(&self, ty: &Type) -> Option<String> {
match ty {
Type::Con { name, .. } => {
if name == "Str" {
return None;
}
if name.matches('.').count() == 1 {
let (prefix, suffix) = name.split_once('.').expect("checked");
if let Some(target) = self.import_map.get(prefix) {
return Some(format!("partial_drop_{target}_{suffix}"));
}
return Some(format!("partial_drop_{prefix}_{suffix}"));
}
Some(format!(
"partial_drop_{m}_{name}",
m = self.module_name
))
}
_ => None,
}
}
/// Iter 18g.tidy.fu2: build the i64 moved-slots bitmask for a
/// `partial_drop_<m>_<T>` call. Bit j is set iff slot j is in
/// `moved`. We reject slot indices ≥ 64 with `None` — no
/// language-level ADT has that many fields, but the cap is
/// load-bearing because the helper's mask is a single i64.
/// On overflow, callers fall back to shallow dec (correctness-
/// safe; carve-out leak path remains as before, but the cap is
/// effectively unreachable).
pub(crate) fn build_moved_mask(moved: &BTreeSet<usize>) -> Option<u64> {
let mut mask: u64 = 0;
for &idx in moved {
if idx >= 64 {
return None;
}
mask |= 1u64 << idx;
}
Some(mask)
}
/// Iter 18d.3: emit a partial-drop sequence inline at a let-close
/// site whose binder has moved-out pattern slots. Replaces the
/// uniform `drop_<m>_<T>(ptr)` call: load each pointer-typed
@@ -548,15 +735,15 @@ impl<'a> Emitter<'a> {
/// transferred to a pattern-bound binder that owns the dec
/// for them.
///
/// Iter 18g.2 widens the input set: with `Term::App` (Own-
/// returning call) now also trackable, a let-binder whose value is
/// `Term::App` may accumulate moved slots if its body pattern-
/// matches the binder. The per-field static-ctor emission below
/// is not usable in that case (the runtime tag is dynamic, not
/// statically known from `value`'s shape); fall back to shallow
/// `ailang_rc_dec`. The non-moved field cells leak under that
/// path — same dynamic-tag partial-drop debt that 18d.4's match
/// arm already documents.
/// Iter 18g.2 widened the input set to `Term::App` (Own-
/// returning call). The static per-field emission below is keyed
/// against a `Term::Ctor`'s known ctor; a `Term::App` binder
/// whose body pattern-matches it has a *dynamic* runtime tag.
/// Iter 18g.tidy.fu2 closes that path: instead of falling back
/// to shallow `ailang_rc_dec` (which leaked the unmoved fields),
/// we route through the tag-conditional helper
/// [`Self::emit_partial_drop_fn_for_type`], which dispatches on
/// the runtime tag and dec's only the unmoved fields.
pub(crate) fn emit_inlined_partial_drop(
&mut self,
value: &Term,
@@ -566,21 +753,29 @@ impl<'a> Emitter<'a> {
let (type_name, ctor_name) = match value {
Term::Ctor { type_name, ctor, .. } => (type_name.as_str(), ctor.as_str()),
_ => {
// Iter 18g.2 fallback: dynamic-tag partial-drop debt.
// `value` is `Term::App` (Own-returning) or `Term::Lam`
// — `is_rc_heap_allocated` returns true for both, and a
// body that pattern-matches such a binder will populate
// `moved_slots`. We don't know the active ctor
// statically, so we shallow-dec the outer cell. Non-
// moved field cells leak under this path; closing the
// leak requires a tag-conditional partial-drop helper
// (analogous to the per-type drop fn but parameterised
// on the tag). Tracked as future work — same family as
// 18d.4's `(case (LCons h t) ...)` carve-out.
let _ = (val_ssa, moved); // keep compiler quiet about unused
self.body.push_str(&format!(
" call void @ailang_rc_dec(ptr {val_ssa})\n"
));
// Iter 18g.tidy.fu2: dynamic-tag partial-drop via the
// per-type helper. `value` is `Term::App` (Own-
// returning) — the binder's static type is the App's
// ret type, recovered through `synth_arg_type` /
// `partial_drop_symbol_for_type`. `Term::Lam` shapes
// never reach here with a non-empty `moved` (you can't
// pattern-match a closure-pair); the fallback below
// handles them defensively.
let sym = self
.synth_arg_type(value)
.ok()
.and_then(|ty| self.partial_drop_symbol_for_type(&ty));
if let (Some(sym), Some(mask)) =
(sym, Self::build_moved_mask(moved))
{
self.body.push_str(&format!(
" call void @{sym}(ptr {val_ssa}, i64 {mask})\n"
));
} else {
self.body.push_str(&format!(
" call void @ailang_rc_dec(ptr {val_ssa})\n"
));
}
return Ok(());
}
};
+34 -8
View File
@@ -800,6 +800,16 @@ impl<'a> Emitter<'a> {
} else {
self.emit_drop_fn_for_type(td);
}
// Iter 18g.tidy.fu2: tag-conditional partial-drop
// helper alongside the recursive/iterative drop fn.
// Used at carve-out sites where a binder's runtime
// tag is dynamic and `moved_slots` is a strict
// subset of its ptr fields. Same shape regardless
// of `(drop-iterative)` (the helper is single-shot
// on the binder; field cascades go through their
// own drop fns which themselves choose recursive
// vs iterative).
self.emit_partial_drop_fn_for_type(td);
}
}
}
@@ -1085,14 +1095,30 @@ impl<'a> Emitter<'a> {
" call void @{drop_call}(ptr {p_ssa})\n"
));
} else {
// Iter 18d.4 debt: dynamic-tag partial-drop
// see the symmetric arm-close branch in
// `lower_match` for the rationale. Falls back
// to shallow `ailang_rc_dec` of the outer
// cell.
self.body.push_str(&format!(
" call void @ailang_rc_dec(ptr {p_ssa})\n"
));
// Iter 18g.tidy.fu2: dynamic-tag partial-drop
// via the per-type helper. The param's runtime
// tag is dynamic but its static type is known
// (`pty_ail`), so we route through
// `partial_drop_<owner>_<T>(p, mask)` which
// dispatches on the runtime tag and dec's only
// the unmoved fields. The fallback to shallow
// `ailang_rc_dec` only fires for non-ADT param
// types (Str, fn-typed, vars) — none of which
// can populate `moved_slots` in practice, so
// the fallback is dead under the typechecker.
let sym = Self::partial_drop_symbol_for_type(
self, pty_ail,
);
let mask = Self::build_moved_mask(&moves);
if let (Some(sym), Some(mask)) = (sym, mask) {
self.body.push_str(&format!(
" call void @{sym}(ptr {p_ssa}, i64 {mask})\n"
));
} else {
self.body.push_str(&format!(
" call void @ailang_rc_dec(ptr {p_ssa})\n"
));
}
}
}
}
+22 -18
View File
@@ -821,24 +821,28 @@ impl<'a> Emitter<'a> {
" call void @{drop_call}(ptr {b_ssa})\n"
));
} else {
// Iter 18d.4 debt: pattern-binder with
// statically-recorded moved slots requires
// tag-conditional partial-drop emission. The
// 18d.3 `emit_inlined_partial_drop` helper
// assumes a static ctor (it's keyed against a
// `Term::Ctor` node), but a pattern-bound
// binder's runtime tag is dynamic. Until a
// dynamic-tag-aware partial-drop lands, fall
// back to a shallow `ailang_rc_dec` of the
// outer cell. The non-moved fields of the
// active ctor (if any) leak under this path —
// the canonical 18d.4 fixtures avoid this
// case by construction (the regression `t`
// is never re-scrutinised inside the arm,
// so its `moved_slots` entry is empty).
self.body.push_str(&format!(
" call void @ailang_rc_dec(ptr {b_ssa})\n"
));
// Iter 18g.tidy.fu2: pattern-binder with
// statically-recorded moved slots routes
// through the tag-conditional helper
// `partial_drop_<owner>_<T>(b, mask)`. The
// binder's runtime tag is dynamic but its
// static type (`b_ail`) selects the per-type
// helper, which dispatches on the runtime tag
// and dec's only the unmoved fields. Closes
// the 18d.4 carve-out: previously the shallow
// `ailang_rc_dec` leaked the unmoved fields of
// whichever ctor `b` happened to be at runtime.
let sym = self.partial_drop_symbol_for_type(b_ail);
let mask = Self::build_moved_mask(&moves);
if let (Some(sym), Some(mask)) = (sym, mask) {
self.body.push_str(&format!(
" call void @{sym}(ptr {b_ssa}, i64 {mask})\n"
));
} else {
self.body.push_str(&format!(
" call void @ailang_rc_dec(ptr {b_ssa})\n"
));
}
}
}
}
+140
View File
@@ -8451,3 +8451,143 @@ queued iter required it. It is a tidy paid for navigability
alone — the kind of work that compounds across future
sessions without showing up in any single one's bench
numbers.
## 2026-05-08 — Iter 18g.tidy.fu2: tag-conditional partial-drop helper
User feedback closed an open question. The 18g tidy entry had
queued the tag-conditional partial-drop helper as "stays queued;
will close with the next iter that surfaces the leak in a
benchmark or a real workload." The user pushed back: *"Du weisst,
dass es einen Bug gibt — Leaks sind Bugs."* CLAUDE.md's TDD-for-
bug-fixes rule is unambiguous on a known bug: write a RED test,
ship the GREEN, keep as regression. The "wait for organic
fixture" framing was avoidance disguised as discipline. Fixed.
### Three carve-outs, three sites, one helper
The pre-fu2 codebase had three sibling sites that fell back to
shallow `ailang_rc_dec` when a binder had a non-empty
`moved_slots` *and* a dynamic runtime ctor tag:
1. `lib.rs` Iter B Own-param dec at fn-return (`emit_fn`'s
pre-ret seam).
2. `match_lower.rs` Iter A arm-close pattern-binder dec
(outer-arm close when an arm-bound binder was scrutinised
by an inner match that moved out fields).
3. `drop.rs::emit_inlined_partial_drop` non-Ctor branch
(`Term::Let` whose value is a `Term::App` Own-returning
call, body pattern-matches the binder).
All three share a structural shape: a binder whose static type is
known (an ADT), but whose runtime ctor tag is *not* statically
recoverable from the AST node at the drop emission site. The
existing per-type drop fn `drop_<m>_<T>(ptr)` is built around
"emit the dec for *every* ptr field of the active ctor"; what was
missing was the variant "emit the dec for every ptr field of the
active ctor *whose slot index is not in `moved`*."
### `partial_drop_<m>_<T>(ptr %p, i64 %mask)`
The fu2 helper (`drop.rs::emit_partial_drop_fn_for_type`) is a
straight parallel to `emit_drop_fn_for_type`:
```text
define void @partial_drop_<m>_<T>(ptr %p, i64 %mask) {
%is_null = icmp eq ptr %p, null
br i1 %is_null, label %ret, label %live
live:
%tag = load i64, ptr %p
switch i64 %tag, label %dflt [...]
arm_i:
; for each ptr field at slot j of ctor i:
%b = and i64 %mask, (1 << j)
%s = icmp ne i64 %b, 0
br i1 %s, label %after, label %do
do:
%v = load ptr, gep %p, (8 + 8*j)
call void @<field_drop_call>(ptr %v)
br label %after
after:
; next ptr field, or br label %join
dflt: unreachable
join:
call void @ailang_rc_dec(ptr %p)
br label %ret
ret:
ret void
}
```
One helper per ADT in the module, emitted alongside
`drop_<m>_<T>`. We do *not* emit a `(drop-iterative)` partial-drop
variant: the helper runs once on the binder (carve-out sites are
not cascade points — the unmoved fields go through their own
`drop_<m>_<F>` which itself decides recursive vs. iterative).
The mask is `i64`, so ADTs with > 64 fields fall back to shallow
dec via the `build_moved_mask` cap. No language-level ADT has 64
fields; the cap is load-bearing only as a defensive guard.
### Three RED-then-GREEN fixtures
Built before the helper to lock the carve-outs in regression
coverage. All three share the same `Wrap` / `Cell` / `Pair` shape
(Pair has 2 Cell slots, Cell has 2 Wrap slots) so the leak path
is observable through `AILANG_RC_STATS=1`'s
`allocs/frees/live` triple.
- `examples/rc_own_param_partial_drop_leak.{ailx,ail.json}`
site 1. `(fn use_first (params (own (con Pair))) ...)` matches
`p` as `MkPair(a, _)`. Pre-fu2: `live=3` (whole second Cell +
two Wraps leak through Iter B's shallow path). Post-fu2:
`live=0`.
- `examples/rc_match_arm_partial_drop_leak.{ailx,ail.json}`
site 2. Outer match binds `a, b` from `p`; inner match
destructures `a` as `MkCell(w1, _)` (slot 1 wildcarded).
Pre-fu2: `live=1` (slot 1's MkWrap leaks through Iter A's
shallow path). Post-fu2: `live=0`.
- `examples/rc_app_let_partial_drop_leak.{ailx,ail.json}`
site 3. `(let p (app build_pair 1) (match p ...))` with
`MkPair(a, _)`. Pre-fu2: `live=3` (slot 1's whole Cell +
Wraps leak through `emit_inlined_partial_drop`'s non-Ctor
fallback). Post-fu2: `live=0`.
Each fixture has a corresponding e2e test in
`crates/ail/tests/e2e.rs`. e2e count: 66 → 69.
### Routing the call sites
Each of the three sites was rewritten symmetrically: resolve the
`partial_drop_<owner>_<T>` symbol from the binder's static type
(`partial_drop_symbol_for_type`), build the mask
(`build_moved_mask`), call. Falls back to the prior shallow
`ailang_rc_dec` only if the static type is non-ADT (Str, fn-typed,
type-var) — those shapes can't populate `moved_slots` in
practice, so the fallback is dead under the typechecker.
The pre-existing test `alloc_rc_own_param_dec_at_fn_return`
asserted on the IR-level shape "either `drop_<m>_<T>` or
`ailang_rc_dec` is called on `%arg_xs` before the ret"; widened
to also accept `partial_drop_<m>_<T>(%arg_xs, i64 mask)` (the
canonical fu2 shape — the fixture's Cons arm moves slot 1 into
`t`, so `moved_slots[xs]={1}`).
### What stays queued
Carve-out diagnostic surface (item 5 from the 18g tidy entry):
the three carve-outs no longer leak, but they also don't surface
to the user when they fire. Closing this is a separate ergonomics
iter (structured diagnostics from codegen), not a correctness
fix. The fu2 helper makes the codegen-side leak path empty;
diagnostic surface is for the language-design layer.
### Lesson recorded
The "wait for organic fixture" stance from the 18g tidy entry is
now retracted: known leaks are bugs, and CLAUDE.md's TDD rule
covers them autonomously. Constructing a minimal fixture for a
known leak path is *not* "constructing fixtures to drive an
implementation" — it is the literal RED-step that the discipline
prescribes. The three fixtures shipped here are exactly that:
each pins one of the three carve-out sites, observable via the
RC stats line, kept as regression. JOURNAL queue: empty.
@@ -0,0 +1 @@
{"defs":[{"ctors":[{"fields":[{"k":"con","name":"Int"}],"name":"MkWrap"}],"kind":"type","name":"Wrap"},{"ctors":[{"fields":[{"k":"con","name":"Wrap"},{"k":"con","name":"Wrap"}],"name":"MkCell"}],"kind":"type","name":"Cell"},{"ctors":[{"fields":[{"k":"con","name":"Cell"},{"k":"con","name":"Cell"}],"name":"MkPair"}],"kind":"type","name":"Pair"},{"body":{"args":[{"args":[{"args":[{"name":"n","t":"var"}],"ctor":"MkWrap","t":"ctor","type":"Wrap"},{"args":[{"lit":{"kind":"int","value":2},"t":"lit"}],"ctor":"MkWrap","t":"ctor","type":"Wrap"}],"ctor":"MkCell","t":"ctor","type":"Cell"},{"args":[{"args":[{"lit":{"kind":"int","value":3},"t":"lit"}],"ctor":"MkWrap","t":"ctor","type":"Wrap"},{"args":[{"lit":{"kind":"int","value":4},"t":"lit"}],"ctor":"MkWrap","t":"ctor","type":"Wrap"}],"ctor":"MkCell","t":"ctor","type":"Cell"}],"ctor":"MkPair","t":"ctor","type":"Pair"},"kind":"fn","name":"build_pair","params":["n"],"type":{"effects":[],"k":"fn","params":[{"k":"con","name":"Int"}],"ret":{"k":"con","name":"Pair"},"ret_mode":"own"}},{"body":{"arms":[{"body":{"lit":{"kind":"int","value":1},"t":"lit"},"pat":{"ctor":"MkCell","fields":[{"name":"w1","p":"var"},{"name":"w2","p":"var"}],"p":"ctor"}}],"scrutinee":{"name":"c","t":"var"},"t":"match"},"kind":"fn","name":"use_cell","params":["c"],"type":{"effects":[],"k":"fn","param_modes":["own"],"params":[{"k":"con","name":"Cell"}],"ret":{"k":"con","name":"Int"}}},{"body":{"body":{"args":[{"arms":[{"body":{"args":[{"name":"a","t":"var"}],"fn":{"name":"use_cell","t":"var"},"t":"app"},"pat":{"ctor":"MkPair","fields":[{"name":"a","p":"var"},{"p":"wild"}],"p":"ctor"}}],"scrutinee":{"name":"p","t":"var"},"t":"match"}],"op":"io/print_int","t":"do"},"name":"p","t":"let","value":{"args":[{"lit":{"kind":"int","value":1},"t":"lit"}],"fn":{"name":"build_pair","t":"var"},"t":"app"}},"kind":"fn","name":"main","params":[],"type":{"effects":["IO"],"k":"fn","params":[],"ret":{"k":"con","name":"Unit"}}}],"imports":[],"name":"rc_app_let_partial_drop_leak","schema":"ailang/v0"}
@@ -0,0 +1,69 @@
; Iter 18g.tidy.fu2 RED-test fixture 3/3: dynamic-tag partial-drop
; carve-out at let-close for an App-bound binder
; (drop.rs:580 fallback in `emit_inlined_partial_drop`).
;
; Shape:
; - Same `Pair`/`Cell`/`Wrap` types as fixtures 1 and 2.
; - `main` let-binds `p = (app build_pair)` — value is
; `Term::App` (Own-returning), `is_rc_heap_allocated` returns
; true → trackable.
; - Body matches `p` as `MkPair a _` — slot 0 bound, slot 1 wild.
; `moved_slots[p] = {0}`.
; - `a` is consumed by `use_cell` (Own param) → `consume_count[a]
; == 1`, arm-close drop skipped. `use_cell`'s body fully
; destructures `a` and returns Int.
; - At p's let-close: `consume_count[p] == 0`, `moves[p] = {0}`
; non-empty, `value` is `Term::App` (not `Term::Ctor`) →
; `emit_inlined_partial_drop` falls into the non-Ctor branch
; (drop.rs:580) → shallow `ailang_rc_dec(p)`. p's outer cell
; is freed; slot 1 (the second MkCell + its two MkWraps =
; 3 cells) leaks.
;
; Pre-fix: live = 3 (one MkCell + two MkWrap children in slot 1).
; Post-fix: live = 0.
(module rc_app_let_partial_drop_leak
(data Wrap
(ctor MkWrap (con Int)))
(data Cell
(ctor MkCell (con Wrap) (con Wrap)))
(data Pair
(ctor MkPair (con Cell) (con Cell)))
(fn build_pair
(type
(fn-type
(params (con Int))
(ret (own (con Pair)))))
(params n)
(body
(term-ctor Pair MkPair
(term-ctor Cell MkCell
(term-ctor Wrap MkWrap n)
(term-ctor Wrap MkWrap 2))
(term-ctor Cell MkCell
(term-ctor Wrap MkWrap 3)
(term-ctor Wrap MkWrap 4)))))
(fn use_cell
(type
(fn-type
(params (own (con Cell)))
(ret (con Int))))
(params c)
(body
(match c
(case (pat-ctor MkCell w1 w2) 1))))
(fn main
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body
(let p (app build_pair 1)
(do io/print_int
(match p
(case (pat-ctor MkPair a _)
(app use_cell a))))))))
@@ -0,0 +1 @@
{"defs":[{"ctors":[{"fields":[{"k":"con","name":"Int"}],"name":"MkWrap"}],"kind":"type","name":"Wrap"},{"ctors":[{"fields":[{"k":"con","name":"Wrap"},{"k":"con","name":"Wrap"}],"name":"MkCell"}],"kind":"type","name":"Cell"},{"ctors":[{"fields":[{"k":"con","name":"Cell"},{"k":"con","name":"Cell"}],"name":"MkPair"}],"kind":"type","name":"Pair"},{"body":{"args":[{"args":[{"args":[{"name":"n","t":"var"}],"ctor":"MkWrap","t":"ctor","type":"Wrap"},{"args":[{"lit":{"kind":"int","value":2},"t":"lit"}],"ctor":"MkWrap","t":"ctor","type":"Wrap"}],"ctor":"MkCell","t":"ctor","type":"Cell"},{"args":[{"args":[{"lit":{"kind":"int","value":3},"t":"lit"}],"ctor":"MkWrap","t":"ctor","type":"Wrap"},{"args":[{"lit":{"kind":"int","value":4},"t":"lit"}],"ctor":"MkWrap","t":"ctor","type":"Wrap"}],"ctor":"MkCell","t":"ctor","type":"Cell"}],"ctor":"MkPair","t":"ctor","type":"Pair"},"kind":"fn","name":"build_pair","params":["n"],"type":{"effects":[],"k":"fn","params":[{"k":"con","name":"Int"}],"ret":{"k":"con","name":"Pair"},"ret_mode":"own"}},{"body":{"arms":[{"body":{"arms":[{"body":{"lit":{"kind":"int","value":1},"t":"lit"},"pat":{"ctor":"MkCell","fields":[{"name":"w1","p":"var"},{"p":"wild"}],"p":"ctor"}}],"scrutinee":{"name":"a","t":"var"},"t":"match"},"pat":{"ctor":"MkPair","fields":[{"name":"a","p":"var"},{"name":"b","p":"var"}],"p":"ctor"}}],"scrutinee":{"name":"p","t":"var"},"t":"match"},"kind":"fn","name":"use_first","params":["p"],"type":{"effects":[],"k":"fn","param_modes":["own"],"params":[{"k":"con","name":"Pair"}],"ret":{"k":"con","name":"Int"}}},{"body":{"args":[{"args":[{"args":[{"lit":{"kind":"int","value":1},"t":"lit"}],"fn":{"name":"build_pair","t":"var"},"t":"app"}],"fn":{"name":"use_first","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_match_arm_partial_drop_leak","schema":"ailang/v0"}
@@ -0,0 +1,71 @@
; Iter 18g.tidy.fu2 RED-test fixture 2/3: dynamic-tag partial-drop
; carve-out at outer-arm-close pattern-binder dec
; (Iter A / match_lower.rs:839).
;
; Shape:
; - `Pair` carries two `Cell` fields; `Cell` carries two `Wrap`
; fields. (Same types as fixture 1.)
; - `use_first` takes `(own Pair)` and the OUTER match binds both
; slots: `MkPair a b`.
; - The arm body INNER-matches `a` as `MkCell w1 _` — slot 0 of
; a is bound, slot 1 is wildcarded.
; - Inner arm-close drops `w1` properly via `drop_<m>_Wrap`.
; Inner match adds slot 0 to `moved_slots[a]`.
; - Outer arm-close drops `a` and `b`. `a` has `moves={0}`
; (non-empty) and `consume_count==0` (only Borrow use as
; scrutinee of inner match) → Iter A's dynamic-tag carve-out
; fires: shallow `ailang_rc_dec(a)`. a's outer Cell is freed,
; but slot 1 (the unbound MkWrap(2) cell) leaks.
; - `b` has `moves={}` and `consume_count==0` → routes through
; `drop_<m>_Cell(b)` which cascades through both Wraps. No
; leak from b.
; - At fn return, `p`'s `moves={0,1}` (both slots bound) → Iter
; B carve-out fires too, but with all slots moved, the shallow
; dec is correct (no additional leak from p).
;
; Pre-fix: live = 1 (the unbound MkWrap(2) cell in slot 1 of a).
; Post-fix: live = 0.
(module rc_match_arm_partial_drop_leak
(data Wrap
(ctor MkWrap (con Int)))
(data Cell
(ctor MkCell (con Wrap) (con Wrap)))
(data Pair
(ctor MkPair (con Cell) (con Cell)))
(fn build_pair
(type
(fn-type
(params (con Int))
(ret (own (con Pair)))))
(params n)
(body
(term-ctor Pair MkPair
(term-ctor Cell MkCell
(term-ctor Wrap MkWrap n)
(term-ctor Wrap MkWrap 2))
(term-ctor Cell MkCell
(term-ctor Wrap MkWrap 3)
(term-ctor Wrap MkWrap 4)))))
(fn use_first
(type
(fn-type
(params (own (con Pair)))
(ret (con Int))))
(params p)
(body
(match p
(case (pat-ctor MkPair a b)
(match a
(case (pat-ctor MkCell w1 _) 1))))))
(fn main
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body
(do io/print_int (app use_first (app build_pair 1))))))
@@ -0,0 +1 @@
{"defs":[{"ctors":[{"fields":[{"k":"con","name":"Int"}],"name":"MkWrap"}],"kind":"type","name":"Wrap"},{"ctors":[{"fields":[{"k":"con","name":"Wrap"},{"k":"con","name":"Wrap"}],"name":"MkCell"}],"kind":"type","name":"Cell"},{"ctors":[{"fields":[{"k":"con","name":"Cell"},{"k":"con","name":"Cell"}],"name":"MkPair"}],"kind":"type","name":"Pair"},{"body":{"args":[{"args":[{"args":[{"name":"n","t":"var"}],"ctor":"MkWrap","t":"ctor","type":"Wrap"},{"args":[{"lit":{"kind":"int","value":2},"t":"lit"}],"ctor":"MkWrap","t":"ctor","type":"Wrap"}],"ctor":"MkCell","t":"ctor","type":"Cell"},{"args":[{"args":[{"lit":{"kind":"int","value":3},"t":"lit"}],"ctor":"MkWrap","t":"ctor","type":"Wrap"},{"args":[{"lit":{"kind":"int","value":4},"t":"lit"}],"ctor":"MkWrap","t":"ctor","type":"Wrap"}],"ctor":"MkCell","t":"ctor","type":"Cell"}],"ctor":"MkPair","t":"ctor","type":"Pair"},"kind":"fn","name":"build_pair","params":["n"],"type":{"effects":[],"k":"fn","params":[{"k":"con","name":"Int"}],"ret":{"k":"con","name":"Pair"},"ret_mode":"own"}},{"body":{"arms":[{"body":{"lit":{"kind":"int","value":1},"t":"lit"},"pat":{"ctor":"MkPair","fields":[{"name":"a","p":"var"},{"p":"wild"}],"p":"ctor"}}],"scrutinee":{"name":"p","t":"var"},"t":"match"},"kind":"fn","name":"use_first","params":["p"],"type":{"effects":[],"k":"fn","param_modes":["own"],"params":[{"k":"con","name":"Pair"}],"ret":{"k":"con","name":"Int"}}},{"body":{"args":[{"args":[{"args":[{"lit":{"kind":"int","value":1},"t":"lit"}],"fn":{"name":"build_pair","t":"var"},"t":"app"}],"fn":{"name":"use_first","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_own_param_partial_drop_leak","schema":"ailang/v0"}
@@ -0,0 +1,65 @@
; Iter 18g.tidy.fu2 RED-test fixture 1/3: dynamic-tag partial-drop
; carve-out at fn-return (Iter B / lib.rs:1093).
;
; Shape:
; - `Pair` carries two `Cell` fields (both pointer-typed).
; - `Cell` carries two `Wrap` fields (both pointer-typed).
; - `use_first` takes `(own Pair)` and pattern-binds the first
; slot only, wildcarding the second.
; - The bound slot's `Cell` is dec'd via `drop_<m>_Cell` at the
; outer arm-close (moves[a] empty → field_drop_call routes
; correctly).
; - At fn return, `p`'s `consume_count == 0`, mode = Own, and
; `moved_slots[p] = {0}`. Iter B's drop (lib.rs:1093) hits the
; dynamic-tag carve-out: moves non-empty → shallow
; `ailang_rc_dec(p)`. p's outer cell is freed; slot 1 (the
; wildcarded second Cell + its two MkWrap children = 3 cells)
; leaks.
;
; Pre-fix: live = 3 (one Cell + two MkWrap children).
; Post-fix: live = 0.
;
; This fixture is the cleanest of the three because no inner match
; is involved: the leak surfaces from Iter B alone.
(module rc_own_param_partial_drop_leak
(data Wrap
(ctor MkWrap (con Int)))
(data Cell
(ctor MkCell (con Wrap) (con Wrap)))
(data Pair
(ctor MkPair (con Cell) (con Cell)))
(fn build_pair
(type
(fn-type
(params (con Int))
(ret (own (con Pair)))))
(params n)
(body
(term-ctor Pair MkPair
(term-ctor Cell MkCell
(term-ctor Wrap MkWrap n)
(term-ctor Wrap MkWrap 2))
(term-ctor Cell MkCell
(term-ctor Wrap MkWrap 3)
(term-ctor Wrap MkWrap 4)))))
(fn use_first
(type
(fn-type
(params (own (con Pair)))
(ret (con Int))))
(params p)
(body
(match p
(case (pat-ctor MkPair a _) 1))))
(fn main
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body
(do io/print_int (app use_first (app build_pair 1))))))