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