diff --git a/docs/JOURNAL.md b/docs/JOURNAL.md index 90ad771..fb6654d 100644 --- a/docs/JOURNAL.md +++ b/docs/JOURNAL.md @@ -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> +``` + +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__(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__` 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__IntList` + call for the wildcarded slot, AND does NOT contain the + uniform `@drop__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__(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).