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