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