JOURNAL: Iter 18c.4 — per-type drop fn + recursive dec cascade

Records the drop-symbol scheme (uniform @drop_<m>_<T> for every
ADT, even no-boxed-children ones; pair+env drop fns for escaping
closures), field_drop_call's per-type dispatch and the three
fall-back cases (Str / Fn / Type::Var), the unchanged Term::Let
emission gates, and the explicit deferral of stack-recursion to
18e's worklist allocator. Also lists the four known-debt items
(stack-recursive drop, closure-typed ADT fields, Type::Var
fields, fn parameters) so 18d's design surface is clear.
This commit is contained in:
2026-05-08 10:37:34 +02:00
parent 298dcaf0f7
commit 6511838b58
+174
View File
@@ -6465,3 +6465,177 @@ After 18c.4 closes, the 18-arc continues with 18d (reuse hints
18-arc closes — at which point the new tidy-iter rule from
CLAUDE.md kicks in: the next iter is `ailang-architect`-driven
drift cleanup before 19 starts.
## Iter 18c.4 — per-type drop fn + recursive `dec` cascade
Closes 18c.3's main scope cut. Codegen now emits a per-ADT and
per-closure drop function under `--alloc=rc`; the
`Term::Let`-scope-close call site routes through these drops
instead of `ailang_rc_dec` directly, so a recursive ADT (`List`,
`Tree`) under RC actually frees its tail cells when the outer
binder's drop fires.
### The drop-symbol scheme
For every `Def::Type` `T` in module `m`, codegen emits one
`define void @drop_<m>_<T>(ptr %p)` under `--alloc=rc`. The body
is uniformly shaped:
1. Null-guard the pointer (defensive — drop on null is a no-op).
2. Load tag from offset 0.
3. Switch on tag; one arm per ctor.
4. In each arm, for every pointer-typed field, load the field
and dispatch through `field_drop_call` (described below).
5. After every field is dec'd, `call void @ailang_rc_dec(ptr %p)`
to free the outer cell.
6. `ret void`.
For closures, `Term::Lam` emits two drop fns when the closure
escapes (heap-allocated): `drop_<m>_<lam-id>_env(ptr %env)` for
the captured-free-vars block, and `drop_<m>_<lam-id>_pair(ptr
%pair)` for the `{ env, fn-ptr }` pair. The pair-drop dec's the
env via `drop_<m>_<lam-id>_env`, then dec's the pair box. A new
`closure_drops: BTreeMap<String, String>` field on `Emitter`
keys closure-pair SSA names to their pair-drop symbol so
`Term::Let` lookup is O(1).
Decision (preempted in the brief): **always emit
`@drop_<m>_<T>` for every ADT, even ones with no boxed
children.** A no-boxed-children ADT (like `Box(Int)`) gets a
drop fn whose body is just `null-guard; ailang_rc_dec; ret`.
Call sites uniformly call `drop_<m>_<T>(ptr v)` and never
branch on "does this type have boxed children?". Two payoffs:
(1) the codegen call-site code is simpler; (2) any future
codegen pass that wants to thread additional cleanup through
the drop seam (e.g. atomic dec under threading) has one
canonical entry point per type.
### `field_drop_call` — per-field dispatch
Given a field's `Type`, `field_drop_call` produces the LLVM
instruction(s) that drop the field's value:
- `Type::Con { name, .. }` (an ADT field) → call
`drop_<owner>_<name>` after qualifying `<owner>` via the
`import_map`. Recursive `IntList` → recursive `drop_<m>_IntList`
call, which is the cascade.
- `Type::Str``ailang_rc_dec` directly. Strings have no boxed
children and are not yet wrapped in their own per-type drop
(deferred — the per-type-drop seam is uniform but `Str` is a
primitive in DESIGN.md's eyes; no behaviour change).
- `Type::Fn` (a closure-typed field) → fall back to
`ailang_rc_dec`. The closure-pair dec route requires
`closure_drops` lookup which is keyed by *expression site*,
not *type*; a Fn-typed *field* doesn't carry the closure-pair
ID. Documented as a 18d/18e debt — closure-typed ADT fields
leak their captured envs.
- `Type::Var` (parameterised type, e.g. `Cons<a>`'s head field
in a polymorphic `List<a>`) → fall back to `ailang_rc_dec`.
We don't have monomorphisation yet, so `a` could be `Int`
(no-op) or `(con List Int)` (needs cascade). The conservative
choice is shallow free; full handling is monomorphisation
territory (orthogonal to RC, not in the 18-arc).
The two fall-back cases are flagged at `field_drop_call` with
"# Iter 18c.4 debt" comments so they are greppable.
### `Term::Let` switch-over
The 18c.3 emission seam at `Term::Let` scope close now calls
`drop_symbol_for_binder(value, val_ssa)` instead of
`ailang_rc_dec` directly:
- `Term::Ctor { type, .. }` binders → `drop_<owner-m>_<type>`
(looked up via `import_map`; falls back to current module if
the type is local).
- `Term::Lam` binders → the `closure_drops`-recorded pair-drop
symbol.
- Other expression shapes → unchanged from 18c.3 (codegen does
not own the lifecycle of a returned-from-callee box; that is
18d's mode-aware story).
The other emission gates from 18c.3 (`consume_count == 0`,
body-tail-not-binder, block-not-terminated, `non_escape`
membership) are unchanged. 18c.4 only swaps the *target* of the
call.
### Recursion is stack-recursive (deferred to 18e)
`drop_<m>_IntList`'s `Cons` arm calls `drop_<m>_IntList` on the
tail. For a 5-cell list, that's 5 stack frames. For a million-
cell list, that's a million frames and a stack overflow. **Iter
18e replaces this with a worklist allocator** — the recursive
call becomes a push-onto-worklist + iterative-pop loop. The
recursive call site in `field_drop_call` is commented with
"replaced in 18e by worklist-based iterative free".
For 18c.4's shipping fixtures (5-element list), the depth is
safely below any reasonable stack. The worklist conversion is
mechanical once 18e introduces the worklist primitive.
### Test additions
- `examples/rc_list_drop.ail.json` — 5-element `IntList` summed
via `sum_list`; `xs` has `consume_count == 1` (passed to
`sum_list`), so codegen does NOT dec at main's let-close.
Fixture's purpose is to lock in the IR shape under
`--alloc=rc` (covered by the codegen unit test) and confirm
the recursive ADT compiles + runs correctly under both
allocators. Stdout: `15`.
- `examples/rc_list_drop_borrow.ail.json` — same 5-element
`IntList`, but `main` only matches it (borrow) and prints the
head. `xs` has `consume_count == 0`, so codegen DOES emit
`call void @drop_rc_list_drop_borrow_IntList(ptr xs)` at
scope close. The drop fn's `Cons` arm recurses on the tail,
cascading through all 5 cells and freeing each in turn. The
E2E test asserts identical stdout vs `--alloc=gc` AND clean
exit (which is the implicit "no segfault, no underflow" check
for the recursive cascade).
- `crates/ailang-codegen/src/lib.rs` unit test
`rc_alloc_emits_recursive_drop_fn_for_recursive_adt` — IR
shape lock-in. Asserts:
1. `define void @drop_rclist_IntList(ptr %p)` is emitted
under `--alloc=rc`.
2. The fn body contains a recursive call
`call void @drop_rclist_IntList(ptr %v...)` (the cascade).
3. The fn body ends with
`call void @ailang_rc_dec(ptr %p)` (outer-box dec).
4. Under `--alloc=gc`, NO `@drop_rclist_IntList` symbol
appears (negative complement — drop fns are an RC-only
emission).
### Test deltas
- E2E: 53 → 55 (+2: `alloc_rc_recursive_list_sum`,
`alloc_rc_borrow_only_recursive_list_drop`).
- ailang-codegen unit: 11 → 12 (+1: IR-shape test).
- All other buckets unchanged.
- `cargo test --workspace` green.
### Known debt (deliberate, deferred)
- **Stack-recursive drop** — Iter 18e replaces with worklist
allocator. Long lists overflow the stack today.
- **Closure-typed ADT fields** fall back to shallow
`ailang_rc_dec`. `field_drop_call`'s `Type::Fn` arm is the
greppable seam.
- **Parameterised type fields** (`Type::Var`) fall back to
shallow `ailang_rc_dec`. Full handling is monomorphisation,
orthogonal to RC.
- **Fn parameters** still don't get dec'd at fn return — the
caller-handed-off-ownership signal is the `(own T)` mode, but
wiring it through codegen is part of the wider mode-aware
story (18d alongside reuse hints).
### Next
Iter 18d: reuse hints + reuse analysis. The `(reuse-as
old-binder NewCtor)` form lets the author signal that a freed
cell can be overwritten in-place with a new ctor of the same
shape, avoiding the dec+malloc round-trip. Codegen lowers it to
a tag-overwrite + field-rewrite, eliding both the `@drop_` and
the `@ailang_rc_alloc` calls. Inference identifies the cases
where the rewrite is safe (the freed binder's last use is
exactly here, the new ctor has the same shape, no aliasing).