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
+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"
));
}
}
}
}