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
+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