JOURNAL: Iter 18d.3 — move-aware pattern bindings (static tracking)

Records strategy (b) (codegen-side bookkeeping, no source
mutation), why strategy (a) was retired (borrow contracts +
desugar re-scrutinise), the moved_slots side table and its
consumers (Term::Let close + reuse arm), and the narrow
memory-hygiene regression on borrow_only_recursive_list_drop
that 18d.4 will close. Also flags 18d.3 as the first iter to
ship with an undetected (no valgrind in CI) memory-hygiene
regression — accepted with explicit follow-up iter, not
papered over.
This commit is contained in:
2026-05-08 12:19:17 +02:00
parent c96940ea96
commit 8e415dd4b4
+169
View File
@@ -6959,3 +6959,172 @@ Boehm — flip default to `--alloc=rc`, drop `-lgc`, mark
Decision 9 historical. The CLAUDE.md tidy-iter rule then
fires: the next iter after 18f is `ailang-architect`-driven
drift cleanup before 19 begins.
## Iter 18d.3 — move-aware pattern bindings (static tracking)
Closes 18d.2's per-field-dec scope cut. Strategy (b) from the
JOURNAL queue: codegen-side bookkeeping rather than source
mutation.
### How and why this strategy was chosen
A first attempt at strategy (a) — "null out the source's slot
at the pattern-bind point" — was dispatched, implemented, and
correctly stopped by the implementer when two structural
problems surfaced:
1. **Borrowed scrutinees must not be mutated.** A `(borrow T)`
parameter walked via `match` would have its slots null'd by
the body, breaking the borrow contract for any downstream
caller. 18a fixed this in the schema; 18c.2 enforces it in
linearity; null-out at codegen would have violated it
silently.
2. **Desugar chains re-scrutinise.** `Desugarer::desugar_match`
produces chains where the same scrutinee binder is matched
in multiple consecutive matches (the canonical lowering for
nested patterns + multi-arm match). Arm 1 nulling the
source's slots makes arm 2's fall-through re-load see null
pointers and segfault. Systematic for any non-flat match.
The implementer's report listed both as design-level issues
not fixable inside the iter's seam. Strategy (a) was retired.
Strategy (b) — codegen-side bookkeeping — sidesteps both: the
source box stays bit-identical, and the move information rides
in a side table that's consulted only at top-level emission
sites that already know which binder they're emitting for.
Substantive reasons:
- **Borrow-safe.** Source is never mutated.
- **Desugar-safe.** Re-loads after the move see the original
pointer, not null.
- **Allocator-uniform without observable IR change.** Under
`--alloc=boehm` / `--alloc=bump`, no per-field dec is
emitted; the bookkeeping is read but never produces output.
Under `--alloc=rc`, it informs which dec calls fire.
- **Bounded scope.** Per-binder, per-fn-body, lexically scoped
by binder lifetime. Same shape that `linearity.rs` and
`uniqueness.rs` already manage.
Trade-off: more state in codegen. Acceptable; the alternative
introduced two structural bugs.
### Implementation
`Emitter` gains a new field:
```rust
moved_slots: BTreeMap<String, BTreeSet<usize>>
```
Keyed by binder name, valued by the set of field indices that
have been moved out via a pattern destructure. Reset per fn
body in `emit_fn`; saved/restored around `lower_lambda` thunk
emission so closure bodies don't see the parent fn's moves.
`lower_match` captures `scrutinee_binder` (when the scrutinee
is a bare `Term::Var`). For each ctor arm, the pattern-binding
loop records `moved_slots[scrutinee_binder].insert(idx)` for
non-wildcard, pointer-typed slots — but does NOT emit any
store into the source. Pattern-bound binders (h, t) are
removed from `moved_slots` at arm body close (they're new
binders with their own clean scope).
Two consumer sites:
- **`Term::Let` scope close.** Existing 18c.4 behaviour: emit
`call void @drop_<m>_<T>(ptr %binder)` if the binder is
RC-allocated and `consume_count == 0`. Now: `take`
`moved_slots[binder]` first. If empty, emit the same call
(no IR change for the common case — every shipping fixture
that doesn't pattern-extract anything follows this path).
If non-empty, route through a new helper
`emit_inlined_partial_drop` that emits per-field
`field_drop_call` for non-moved pointer-typed slots, then
`ailang_rc_dec` on the outer cell.
- **`lower_reuse_as_rc` reuse arm.** Re-introduces the
per-field dec that 18d.2 punted on. For each pointer-typed
field of the source's old ctor: if its index is in
`moved_slots[source_binder]`, skip; otherwise load the field
and emit `field_drop_call`. The 18d.2 in-source comment
block is replaced with: "Iter 18d.3: per-field dec is now
safe — moved-out slots are skipped via `moved_slots`; non-
moved slots are dec'd via `field_drop_call`'s null-guarded
drop fns."
`drop_<m>_<T>` itself is unchanged. The cascade has no notion
of "partially moved" — whatever binder it's called on, the
caller has already decided that binder is fully owned. Partial-
move complexity is confined to the call sites with the static
info.
### Tests
- New fixture `examples/pat_extract_partial_drop.{ailx,ail.json}`
`Pair(IntList, IntList)` destructured `(Pair a _)`
exercises both a moved pointer slot (a) and a wildcarded
pointer slot (the `_` for the second IntList).
- New E2E `alloc_rc_partial_drop_skips_moved_keeps_wildcarded`
asserts:
1. Stdout `6` under both `--alloc=gc` and `--alloc=rc`.
2. The IR at `main`'s let-close inlines a `@drop_<m>_IntList`
call for the wildcarded slot, AND does NOT contain the
uniform `@drop_<m>_Pair` call (which would dec both
slots; the moved one must be skipped).
- Updated `reuse_as_demo_under_rc_uses_inplace_rewrite`'s IR-
shape assertion: the canonical fixture moves the only
pointer slot, so the reuse arm should now contain neither
`@drop_` nor `@ailang_rc_dec` for fields (just the new
field stores).
### Test deltas
- E2E: 56 → 57 (+1).
- All other buckets unchanged.
- `cargo test --workspace` green.
### Known regression — narrow, deliberate, queued
The 18c.4-shipped `alloc_rc_borrow_only_recursive_list_drop`
fixture had a pattern `(Cons h t)` where `t` was bound but
never consumed downstream. Under 18c.4 + `--alloc=rc`,
`drop_<m>_<IntList>(xs)` at let-close cascaded through
`xs.tail` and freed the entire 5-cell chain. Under 18d.3 with
strategy (b), `xs.tail` is in `moved_slots`, so the cascade
is interrupted there — but `t` is never consumed, so its
4-cell tail now leaks.
The fixture stdout is unchanged (still `11`); E2E test still
passes. The regression is in *memory hygiene*, which the
current test infrastructure does not assert against (no
valgrind / leak-check in CI).
The fix is the symmetric counterpart to 18c.4's known
fn-parameter-dec debt: pattern-bound binders whose
`consume_count` is `0` at arm body close should also get a
drop call emitted. Same emission seam as let-close-drop, just
fired at arm boundary instead of let-body boundary. Queued as
18d.4.
This is the first iter to ship with a documented memory-
hygiene regression that's not test-detectable. The trade-off:
strategy (b) is the only sound move-aware approach (strategy
(a) had two structural bugs); pattern-binder-dec at arm close
is mechanically the same shape as let-close-drop and is a
small standalone iter; shipping 18d.3 unblocks 18d.4 without
forcing them into one giant iter. Until 18d.4 ships, fixtures
with pattern-extracted unused pointer binders leak under
`--alloc=rc`.
### Next
Iter 18d.4: pattern-binder dec at arm close. Symmetric to
18c.4's let-close-drop emission but fired when a pattern arm
exits and binders go out of scope. Closes the regression
above and the symmetric `(own T)` parameter-dec gap from
18c.4 / 18c.3 (the param case is a parameter-list rather than
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).