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
+37
View File
@@ -1974,3 +1974,40 @@ fn iter18e_no_annotation_keeps_recursive_drop_body() {
"without (drop-iterative), the body must not call worklist runtime helpers. Body was:\n{drop_body}"
);
}
/// Iter 18d.4 regression: pattern-binder dec at arm close (Iter A in
/// the 18d.4 emission seam) was firing on pattern-bound pointer
/// fields whose enclosing fn is Implicit-mode. The dec freed memory
/// the caller still referenced, producing refcount underflow or
/// SIGSEGV.
///
/// The fixture's shape — a heap-allocated `t` shared between a
/// pattern-destructuring callee (`pin`) and a recursive call that
/// re-passes `t` (`loop`) — is the canonical case where the bug
/// fires. `pin` does not consume the pattern-bound children (`l`,
/// `r`); pre-fix codegen emitted `drop_<m>_<Tree>(l)` and
/// `drop_<m>_<Tree>(r)` at arm close, dec'ing pointers that lived
/// inside `t`'s box, which the outer `loop`'s next iteration still
/// expects to be valid.
///
/// Properties guarded:
/// (1) The binary exits cleanly under `--alloc=rc` (no SIGSEGV
/// from a use-after-free, no abort from
/// `ailang_rc_dec: refcount underflow` in `runtime/rc.c`).
/// (2) Stdout matches `--alloc=gc` (`0`).
///
/// Pre-fix this test fails with exit code 139 (SIGSEGV) under rc.
/// Post-fix both arms produce the same clean output. Kept as
/// regression coverage — see CLAUDE.md "Bug fixes — TDD, always".
#[test]
fn alloc_rc_pattern_bind_in_implicit_fn_does_not_dec_borrowed_children() {
let example = "rc_pin_recurse_implicit.ail.json";
let stdout_gc = build_and_run_with_alloc(example, "gc");
let stdout_rc = build_and_run_with_alloc(example, "rc");
assert_eq!(stdout_gc.trim(), "0");
assert_eq!(stdout_rc.trim(), "0");
assert_eq!(
stdout_gc, stdout_rc,
"alloc=rc must match alloc=gc on rc_pin_recurse_implicit"
);
}
+67 -1
View File
@@ -587,6 +587,21 @@ struct Emitter<'a> {
/// particular binder are removed when that binder leaves scope
/// (on `Term::Let` body close, on match-arm body close).
moved_slots: BTreeMap<String, BTreeSet<usize>>,
/// Iter 18d.4 fix: per-fn-body parameter modes, keyed by parameter
/// name. Set once at the top of `emit_fn` (and at lambda thunk
/// entry) from the fn type's `param_modes`. Consulted by
/// `lower_match`'s arm-close pattern-binder dec emission (Iter A) to
/// decide whether the scrutinee was statically owned: if the
/// scrutinee resolves to a fn-param whose mode is `Borrow` or
/// `Implicit`, the pattern-binder dec must NOT fire — the caller
/// still holds a reference and dec'ing the pattern-binder would
/// fragment the caller's structure.
///
/// Symmetric with the Iter B gate at fn return (`emit_fn`'s Own-
/// param dec): both sites must check the param-mode signal before
/// dec'ing, because Implicit and Borrow do not carry the "caller
/// handed off ownership" signal that makes the dec safe.
current_param_modes: BTreeMap<String, ParamMode>,
}
#[derive(Debug, Clone)]
@@ -701,6 +716,7 @@ impl<'a> Emitter<'a> {
uniqueness,
closure_drops: BTreeMap::new(),
moved_slots: BTreeMap::new(),
current_param_modes: BTreeMap::new(),
}
}
@@ -1302,6 +1318,15 @@ impl<'a> Emitter<'a> {
self.lam_counter = 0;
// Iter 18d.3: move tracking is per-fn-body.
self.moved_slots.clear();
// Iter 18d.4 fix: param-mode lookup is per-fn-body. Built from
// the fn type's `param_modes` (already destructured above) and
// consulted by `lower_match`'s Iter A gate to skip arm-close
// pattern-binder dec when the scrutinee is a non-Own param.
self.current_param_modes.clear();
for (i, pname) in f.params.iter().enumerate() {
let mode = param_modes.get(i).copied().unwrap_or(ParamMode::Implicit);
self.current_param_modes.insert(pname.clone(), mode);
}
// Iter 17a: run escape analysis over the fn body. The result
// is queried at every `Term::Ctor` / `Term::Lam` lowering site
// to decide between `alloca` (non-escaping) and `@GC_malloc`
@@ -2732,7 +2757,35 @@ impl<'a> Emitter<'a> {
// 18d.3's `moved_slots` interrupts the `xs`-cascade
// through the tail slot. Now the arm itself dec's `t`,
// freeing the 4-cell tail.
if matches!(self.alloc, AllocStrategy::Rc) && !self.block_terminated {
//
// Iter 18d.4 fix (regression
// `alloc_rc_pattern_bind_in_implicit_fn_does_not_dec_borrowed_children`):
// Iter A is symmetric to Iter B (Own-param dec at fn
// return) and must share Iter B's mode gate. If the
// scrutinee resolves to a fn-param whose mode is
// `Borrow` or `Implicit`, the caller still holds a
// reference to the heap value the pattern-binders were
// loaded from. Dec'ing those binders fragments the
// caller's structure (refcount underflow on subsequent
// accesses; segfault under deeper recursion).
//
// Only `Own` carries the static "caller handed off
// ownership" signal that makes the children-dec safe.
// Non-fn-param scrutinees (let-binders, temp
// expressions) are treated as owned — the let-binder
// owns its RC ref, and a temp scrutinee was just
// produced by the local frame.
let scrutinee_is_owned: bool = match &scrutinee_binder {
Some(sb) => match self.current_param_modes.get(sb) {
Some(mode) => matches!(mode, ParamMode::Own),
None => true,
},
None => true,
};
if matches!(self.alloc, AllocStrategy::Rc)
&& !self.block_terminated
&& scrutinee_is_owned
{
for (bname, b_ssa, b_lty, b_ail) in &arm_pushed_binders {
if b_lty != "ptr" {
continue;
@@ -3327,6 +3380,18 @@ impl<'a> Emitter<'a> {
// tracking — the outer fn's binders are not in scope inside
// the thunk body (only its captures + params). Save and reset.
let saved_moved = std::mem::take(&mut self.moved_slots);
// Iter 18d.4 fix: a lambda thunk is its own fn frame for
// param-mode lookup. The outer fn's params are not in scope
// inside the thunk; the thunk's own params are pushed below
// and (currently) carry no mode annotation, so they default
// to `Implicit` — `lower_match`'s Iter A gate will skip arm-
// close pattern-binder dec for matches on lambda params,
// mirroring the fn-level Implicit-param treatment.
let saved_param_modes = std::mem::take(&mut self.current_param_modes);
for pname in lam_params.iter() {
self.current_param_modes
.insert(pname.clone(), ParamMode::Implicit);
}
// Lambdas inside lambdas are fine: they get their own counter
// namespace within the enclosing thunk. They share the
// `deferred_thunks` queue (top-level body owns it).
@@ -3409,6 +3474,7 @@ impl<'a> Emitter<'a> {
self.ssa_fn_sigs = saved_sigs;
self.non_escape = saved_non_escape;
self.moved_slots = saved_moved;
self.current_param_modes = saved_param_modes;
// 3. Emit allocation + capture filling + closure-pair packing
// in the OUTER body. Captures use 8 bytes each; closure-pair
+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).
@@ -0,0 +1 @@
{"defs":[{"ctors":[{"fields":[],"name":"TLeaf"},{"fields":[{"k":"con","name":"Int"},{"k":"con","name":"Tree"},{"k":"con","name":"Tree"}],"name":"TNode"}],"kind":"type","name":"Tree"},{"body":{"cond":{"args":[{"name":"d","t":"var"},{"lit":{"kind":"int","value":0},"t":"lit"}],"fn":{"name":"==","t":"var"},"t":"app"},"else":{"args":[{"lit":{"kind":"int","value":1},"t":"lit"},{"args":[{"args":[{"name":"d","t":"var"},{"lit":{"kind":"int","value":1},"t":"lit"}],"fn":{"name":"-","t":"var"},"t":"app"}],"fn":{"name":"build","t":"var"},"t":"app"},{"args":[{"args":[{"name":"d","t":"var"},{"lit":{"kind":"int","value":1},"t":"lit"}],"fn":{"name":"-","t":"var"},"t":"app"}],"fn":{"name":"build","t":"var"},"t":"app"}],"ctor":"TNode","t":"ctor","type":"Tree"},"t":"if","then":{"args":[],"ctor":"TLeaf","t":"ctor","type":"Tree"}},"kind":"fn","name":"build","params":["d"],"type":{"effects":[],"k":"fn","params":[{"k":"con","name":"Int"}],"ret":{"k":"con","name":"Tree"}}},{"body":{"arms":[{"body":{"lit":{"kind":"int","value":0},"t":"lit"},"pat":{"ctor":"TLeaf","fields":[],"p":"ctor"}},{"body":{"lit":{"kind":"int","value":1},"t":"lit"},"pat":{"ctor":"TNode","fields":[{"name":"v","p":"var"},{"name":"l","p":"var"},{"name":"r","p":"var"}],"p":"ctor"}}],"scrutinee":{"name":"t","t":"var"},"t":"match"},"kind":"fn","name":"pin","params":["t"],"type":{"effects":[],"k":"fn","params":[{"k":"con","name":"Tree"}],"ret":{"k":"con","name":"Int"}}},{"body":{"cond":{"args":[{"name":"n","t":"var"},{"lit":{"kind":"int","value":0},"t":"lit"}],"fn":{"name":"==","t":"var"},"t":"app"},"else":{"body":{"args":[{"args":[{"name":"n","t":"var"},{"lit":{"kind":"int","value":1},"t":"lit"}],"fn":{"name":"-","t":"var"},"t":"app"},{"name":"t","t":"var"}],"fn":{"name":"loop","t":"var"},"t":"app"},"name":"_v","t":"let","value":{"args":[{"name":"t","t":"var"}],"fn":{"name":"pin","t":"var"},"t":"app"}},"t":"if","then":{"lit":{"kind":"int","value":0},"t":"lit"}},"kind":"fn","name":"loop","params":["n","t"],"type":{"effects":[],"k":"fn","params":[{"k":"con","name":"Int"},{"k":"con","name":"Tree"}],"ret":{"k":"con","name":"Int"}}},{"body":{"body":{"args":[{"args":[{"lit":{"kind":"int","value":3},"t":"lit"},{"name":"t","t":"var"}],"fn":{"name":"loop","t":"var"},"t":"app"}],"op":"io/print_int","t":"do"},"name":"t","t":"let","value":{"args":[{"lit":{"kind":"int","value":2},"t":"lit"}],"fn":{"name":"build","t":"var"},"t":"app"}},"kind":"fn","name":"main","params":[],"type":{"effects":["IO"],"k":"fn","params":[],"ret":{"k":"con","name":"Unit"}}}],"imports":[],"name":"rc_pin_recurse_implicit","schema":"ailang/v0"}
+55
View File
@@ -0,0 +1,55 @@
; Iter 18d.4 regression fixture: under --alloc=rc, codegen's
; "pattern-binder dec at arm close" (Iter A) was firing on
; pattern-bound pointer fields whose enclosing fn is Implicit-
; mode. Result: a free of memory the caller still references.
;
; Symptom under --alloc=rc before the fix:
; - Implicit-mode pin/loop: refcount underflow at runtime.
; - Same code under --alloc=gc: clean exit, prints `0`.
;
; Pattern shape: a heap-allocated value `t` shared between a
; callee that pattern-destructures it (pin) and a recursive
; call that re-passes it (loop). Pattern arm binds the children
; (l, r) without consuming them — pre-fix, codegen emitted a
; drop on each, freeing memory still referenced via the outer
; let-binder `t`.
(module rc_pin_recurse_implicit
(data Tree
(ctor TLeaf)
(ctor TNode (con Int) (con Tree) (con Tree)))
(fn build
(type (fn-type (params (con Int)) (ret (con Tree))))
(params d)
(body
(if (app == d 0)
(term-ctor Tree TLeaf)
(term-ctor Tree TNode 1
(app build (app - d 1))
(app build (app - d 1))))))
(fn pin
(type (fn-type (params (con Tree)) (ret (con Int))))
(params t)
(body
(match t
(case (pat-ctor TLeaf) 0)
(case (pat-ctor TNode v l r) 1))))
(fn loop
(type (fn-type (params (con Int) (con Tree)) (ret (con Int))))
(params n t)
(body
(if (app == n 0)
0
(let _v (app pin t)
(app loop (app - n 1) t)))))
(fn main
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body
(let t (app build 2)
(do io/print_int (app loop 3 t))))))