fix: 18d.4 Iter A — gate arm-close pattern-binder dec on scrutinee mode

Iter B (Own-param dec at fn return) gates on param_modes[i] ==
ParamMode::Own and skips Implicit/Borrow because those modes carry
no caller-handed-off-ownership signal. Iter A (arm-close pattern-
binder dec) is the same shape — pattern-binders loaded out of a
scrutinee owe their drop-validity to the scrutinee's ownership —
but Iter A had no such gate and fired on every ptr-typed binder
with consume_count == 0.

Concrete failure (regression test): pin (params t) is Implicit,
caller loop holds t and re-passes it to itself. pin's (TNode v l r)
arm dec'd l and r, fragmenting the tree the caller still references.
Next recursion tripped ailang_rc_dec underflow.

Fix: thread current_param_modes onto the emitter, set it in
emit_fn from the fn type's param_modes (and reset/save it across
lambda thunk emission). In lower_match, derive scrutinee_is_owned
from the scrutinee's mode (Own only; let-binders and temps are
treated as owned) and skip Iter A when not owned.

Carve-out: a let-alias of a non-Own param is not yet detected;
the regression doesn't trigger it and a propagation pass belongs
in its own iter. Recorded in JOURNAL.

Red: alloc_rc_pattern_bind_in_implicit_fn_does_not_dec_borrowed_children.
Pre-fix the rc binary aborted with refcount underflow; post-fix
matches alloc=gc ("0").
This commit is contained in:
2026-05-08 13:56:17 +02:00
parent 7e841ad90e
commit e8c6e99a87
5 changed files with 262 additions and 1 deletions
+102
View File
@@ -7626,3 +7626,105 @@ the family hasn't formally closed. Two paths forward:
slate.
Both reasonable. Orchestrator's call.
## 2026-05-08 — Bug fix: 18d.4 Iter A scrutinee-mode gate
First bug fix shipped under the new TDD-for-bug-fixes rule
(CLAUDE.md, same commit). The bug was surfaced indirectly: the
new `ailang-bencher` agent could not run a tail-latency bench
under `--alloc=rc` because the bench fixture (Implicit-mode
recursive list/tree walk) crashed with `ailang_rc_dec`
refcount-underflow. Minimised to
`examples/rc_pin_recurse_implicit.ailx`:
```
(fn pin (params t) (body
(match t
(case (pat-ctor TLeaf) 0)
(case (pat-ctor TNode v l r) 1))))
(fn loop (params n t) (body
(if (app == n 0) 0
(let _v (app pin t) (app loop (app - n 1) t)))))
```
`pin` is Implicit-mode. Caller `loop` passes `t` to `pin` and
then re-uses `t` itself in the recursive call. Under `--alloc=
rc`, `pin`'s pattern arm `(TNode v l r)` was emitting
`drop_<m>_Tree(l)` and `drop_<m>_Tree(r)` at arm close — but
the cells `l` and `r` were loaded out of `t`'s heap layout, and
`t` is still owned by `loop`. The dec'd children fragment the
tree the caller still references; on the recursive call into
`pin` again, the now-dangling children get re-loaded and the
ailang_rc_dec underflow trips.
### Why Iter A had this bug and Iter B did not
Iter B (Own-param dec at fn return, also 18d.4) gated correctly:
it consults `param_modes[i] == ParamMode::Own` and skips
`Implicit` and `Borrow`. The rationale was already in
`emit_fn`'s comment block: "Implicit-mode params do NOT get
this dec: they have no static caller-handed-off-ownership
signal." Iter A is the same shape — pattern-binders loaded out
of a scrutinee owe their drop-validity to the scrutinee's
ownership — but Iter A's emission seam in `lower_match`
predated the same gate and just fired on every ptr-typed
pattern-binder with `consume_count == 0`.
The asymmetry was a copy-paste-of-rationale-without-copy-
paste-of-gate. Both sites need the same check; the fix is the
literal Iter B gate, threaded onto the emitter as
`current_param_modes` and consulted in `lower_match`.
### Fix shape
- New per-fn-body field on the emitter:
`current_param_modes: BTreeMap<String, ParamMode>`. Set in
`emit_fn` from the fn type's `param_modes`; reset and saved
in `lower_lambda` (lambda thunks have their own param-mode
frame; thunk params default to `Implicit`).
- In `lower_match`, before Iter A's emission loop, derive
`scrutinee_is_owned`:
- If the scrutinee binder resolves to a fn-param: must be
`ParamMode::Own`.
- If non-fn-param (let-binder, temp expression): treat as
owned.
- Skip Iter A when `!scrutinee_is_owned`.
`emit_fn`'s Iter B gate is unchanged.
### TDD record
Red: `crates/ail/tests/e2e.rs`
`alloc_rc_pattern_bind_in_implicit_fn_does_not_dec_borrowed_children`.
Builds the fixture under both `--alloc=gc` (control: prints
`0`) and `--alloc=rc` (must match). Pre-fix: rc binary exited
with the underflow abort. Post-fix: both print `0`. Kept as
regression.
### Carve-out: let-aliases of borrowed scrutinees
A scrutinee that is a let-binder *holding* a non-Own param's
value (e.g. `(let x t (match x ...))` where `t` is Implicit)
would still mis-dec, because `current_param_modes` only knows
about params, not let-aliases. The fix gates only the direct-
fn-param case. The carve-out is recorded here rather than
fixed: the canonical regression doesn't trigger it (matches go
directly on `t`), and a let-alias-aware extension would either
need (a) propagation of "borrow-flavour" through let-bindings
in codegen, or (b) using uniqueness inference's `consume_count`
on the let-binder more aggressively. Both are widening of the
mode-as-ownership-signal apparatus and belong in their own
iter, not in this fix.
### Out-of-scope: Implicit-mode safety more broadly
This fix removes a refcount underflow on Implicit-mode
recursive shapes. It does NOT address the broader 18c.3 debt
that Implicit-mode params don't get *any* dec (Iter B already
documented this — `Implicit` is the back-compat lane, params
in this mode leak rather than trip). The fix here only ensures
arm-close pattern-binders don't dec what they shouldn't; it
does not start dec'ing what they should. Net effect on
Implicit fixtures: same correctness as `--alloc=gc` (no
regression), allocate-path tax only (matches the 18f bench).