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:
+140
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user