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