JOURNAL: Iter 18d.4 — pattern-binder + Own-param dec at scope close

Records the two emission seams (pattern-binder at arm close,
Own-param at fn return), why they fit one iter (uniform
emission shape), the explicit dynamic-tag partial-drop fallback
to shallow ailang_rc_dec when moved_slots is non-empty for a
dynamic-runtime-ctor binder, the side effect on reuse_as_demo's
sum_list (correctness improvement, stdout unchanged, existing
assertions still hold), and the closing of 18d.3's regression.

Notes that 18d.3 + 18d.4 together close the move-aware-pattern
story for the static case; dynamic-tag partial-drop is the
remaining hygiene gap and is structurally orthogonal (would
exist even without moves). 18e (worklist) may be the natural
place to also close the dynamic-tag fallback.
This commit is contained in:
2026-05-08 12:34:52 +02:00
parent ba63a16366
commit 5e401a3520
+176
View File
@@ -7128,3 +7128,179 @@ above and the symmetric `(own T)` parameter-dec gap from
a pattern arm, but the emission seam is the same shape).
After 18d.4, the move-aware-pattern story is complete and
the 18-arc moves to 18e (worklist).
## Iter 18d.4 — pattern-binder + Own-param dec at scope close
Closes 18d.3's known memory-hygiene regression AND the
symmetric Own-param-leaks debt 18c.3 / 18c.4 left open. Both
emission sites share the same shape: a binder going out of
scope without being consumed (`consume_count == 0`) gets a
drop call emitted, gated on the value being `ptr`-typed and
the current block being open.
### Two emission seams, one shape
**(A) Pattern-binder dec at arm body close.** Inside
`lower_match`, after each arm's body lowers, the codegen
walks the arm's pattern-bound binders. For each whose value
is `ptr`-typed and uniqueness side table reports
`consume_count == 0`, emit a drop:
- If `moved_slots[binder]` is empty (the common case —
pattern-bound binders rarely have moves of their own),
emit `call void @drop_<m>_<T>(ptr %binder)` via
`field_drop_call` — exactly the 18c.4 path.
- If `moved_slots[binder]` is non-empty, fall back to shallow
`ailang_rc_dec` of the outer cell only. See "dynamic-tag
partial-drop" below.
Wildcard slots are NOT dec'd here — they have no binder. The
source's drop (handled by 18d.3's `emit_inlined_partial_drop`
or the uniform `drop_<m>_<T>`) takes care of them.
**(B) Own-param dec at fn return.** Inside `emit_fn`, the
fn-type destructure now also pulls `param_modes`. After the
body's tail value lowers but before the `ret` instruction,
the codegen walks the parameter list. For each parameter `p`
where:
- `param_modes[i] == ParamMode::Own`,
- the parameter type lowers to `ptr`,
- the uniqueness side table reports `consume_count == 0`,
- AND the parameter's SSA is NOT the body's tail value (that
would transfer ownership to the caller — caller dec's),
emit a drop with the same routing as (A).
`Borrow`-mode parameters are explicitly excluded — caller
still owns. `Implicit`-mode parameters are also excluded —
no static "caller handed off ownership" signal under
back-compat (a future iter could choose to opt-in this case
via uniqueness inference).
### Why both in one iter
The emission shape is uniform: "binder leaves scope, no one
consumed it, drop." Pattern-arm-close and fn-return are just
two lifecycle points where this fires. Splitting them into
separate iters would have produced two iters with effectively
the same emission code at different binder-lifecycle points;
the implementer noted no friction in handling them together.
### `drop_<m>_<T>` remains unchanged
Same call as 18c.4 emits — same uniform per-type drop fn.
The partial-move complexity stays at the call sites with the
static info. The cascade has no notion of "partially moved";
when a parent's drop walks into a child via
`field_drop_call`, the child is fully owned at that point.
### Dynamic-tag partial-drop — debt continues
`emit_inlined_partial_drop` (18d.3) requires a static
`Term::Ctor` for layout — its per-field load sequence is
keyed against a fixed ctor's field shape. Pattern-bound
binders and Own params often have only their static *type*
known at the dec site, not their runtime ctor (e.g. an `xs:
List` whose ctor is determined by the runtime value). When
`moved_slots[binder]` is non-empty for such a binder, the
partial-drop emission falls back to **shallow
`ailang_rc_dec`** of the outer cell only — it doesn't try to
walk fields.
For 18d.4's shipping fixtures, this fallback is sound:
- `rc_list_drop_borrow` (the 18d.3 regression): `t`'s
`moved_slots` is empty (the regression fixture doesn't
re-scrutinise `t`), so the per-type
`drop_<m>_<IntList>(t)` fires cleanly via the (A) path.
Closes the leak.
- `rc_own_param_drop`: `xs` has `moved_slots[xs] = {1}`, but
the active ctor's only ptr field (`Cons.tail`) has already
been dec'd by the (A) path on `t`, and the `Nil` runtime
alternative has no ptr fields anyway. Shallow dec of the
outer cell is correct on every reachable runtime path.
A general dynamic-tag partial-drop would emit a tag-switch
at the dec site (or pass a moved-mask parameter into a
parameterised `drop_<m>_<T>`). That's a separate iter; not
load-bearing for the shipping fixtures of the 18-arc.
### Side effect on `reuse_as_demo` (correctness improvement)
`sum_list` in `examples/reuse_as_demo` has signature `(fn
((own (con List))) → Int)` and `consume_count(xs) == 0`
(match scrutinee is Borrow). (B) now fires shallow
`ailang_rc_dec(%arg_xs)` at each recursive return. Combined
with the recursive `sum_list(t)` chain, this dec's one cell
per stack frame on unwind, freeing the entire chain before
`main` exits.
Stdout unchanged (`9`). The existing IR-shape assertions on
`map_inc`'s reuse arm continue to hold — they scope between
`reuse.<n>:` and `\nfresh.<n>:` labels and don't see
`sum_list`'s body.
### Test deltas
- E2E: 57 → 58 (+1: `alloc_rc_own_param_dec_at_fn_return`).
- All other buckets unchanged.
- `cargo test --workspace` green.
- The 18d.3 regression on `alloc_rc_borrow_only_recursive_list_drop`
is now closed: the IR-shape assertion confirms `t`'s drop
fires at arm close. Stdout unchanged (still `11`); memory
hygiene strictly improved.
### Known debt (deliberate, deferred)
- **Dynamic-tag partial-drop** — described above. Falls back
to shallow `ailang_rc_dec` for binders with both non-empty
`moved_slots` AND dynamic runtime ctor.
- **Closure-typed Own params** — fall through to
`field_drop_call`'s `Type::Fn` arm (shallow dec). Same path
18c.4 carved out for closure-typed ADT fields.
- **Implicit-mode params with `consume_count == 0`** — not
dec'd. `Implicit` is the back-compat opt-out lane; its
parameters carry no caller-ownership signal.
- **`(drop-iterative)` worklist allocator** — Iter 18e.
### What 18d.4 closes structurally
The move-aware-pattern story spanning 18d.3 + 18d.4 is now
complete for the static cases:
1. Pattern destructure of an owned scrutinee marks the slot
as moved (18d.3's `moved_slots`).
2. Drop of the source at let-close inlines a per-field dec
sequence skipping moved slots (18d.3's
`emit_inlined_partial_drop`).
3. The reuse-as reuse-arm dec's old non-moved fields safely
(18d.3, since moves are tracked statically and source is
never mutated).
4. Pattern-bound binders whose new homes don't consume them
get drop-emission at arm close (18d.4 (A)).
5. `(own T)` parameters that aren't consumed inside the fn
get drop-emission at fn return (18d.4 (B)).
Together: the move-aware story closes the 18d.2 explicit
scope cut AND the implicit 18c.3 / 18c.4 Own-param-leaks gap.
The remaining hygiene gap is dynamic-tag partial-drop, which
is structurally orthogonal to the move-aware story (it'd
exist even with no moves anywhere — it's about not knowing
the runtime ctor at a partial-drop site).
### Next
Iter 18e: `(drop-iterative)` annotation + worklist allocator.
The recursive cascade in `drop_<m>_<T>` overflows the stack
on long lists (millions of cells). The annotation switches
the synthesised drop fn from recursive to iterative-with-
explicit-worklist, allowing arbitrarily deep ADT chains to
free without stack growth. The IR infrastructure overlaps
with the dynamic-tag-partial-drop story (both want a
per-call-site customised free), so 18e may also be the
natural place to close the dynamic-tag fallback.
After 18e, Iter 18f closes the 18-arc with the RC validation
bench. The CLAUDE.md tidy-iter rule then fires: `ailang-architect`
drift cleanup before 19 begins.