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