Iter 18d.2: codegen reuse-as in-place rewrite under --alloc=rc

Lowers Term::ReuseAs { source, body=Term::Ctor } under
--alloc=rc to a runtime refcount-1 dispatch. At the reuse-as
site:

  %hdr_ptr = getelementptr i8, ptr %src, i64 -8
  %refcnt  = load i64, ptr %hdr_ptr
  %is_one  = icmp eq i64 %refcnt, 1
  br i1 %is_one, label %reuse, label %fresh

The reuse arm overwrites the source's box in place: stores the
new tag at offset 0 and the new field values at offsets 8, 16,
... — no malloc, no outer drop. The fresh arm allocates a brand-
new box via ailang_rc_alloc, stores tag and fields, then shallow-
dec's the source. Both arms phi-join to the same `ptr` result.

Other allocators (gc, bump) keep 18d.1's identity behaviour
unchanged.

Static shape compatibility is enforced by a new pre-codegen
pass crates/ailang-check/src/reuse_shape.rs. It tracks the
path-resolved ctor of every let-bound and pattern-matched
binder, then checks each Term::ReuseAs site against the body's
ctor. Mismatches emit reuse-as-shape-mismatch with stable
ctx.reason sub-codes (field-count-mismatch, field-type-mismatch,
indeterminate-source-ctor, cross-module-body-ctor, ctor-not-in-
module) and a drop-the-wrapper SuggestedRewrite. Build fails on
mismatch — the structured diagnostic tells the author whether
to fix the shapes or remove the hint.

Field-cleanup design — deliberate scope cut:

The reuse arm does NOT dec the old pointer-typed field values
before overwriting them. Doing so on the canonical fixture
`(reuse-as xs (Cons (+h 1) (map_inc t)))` causes use-after-free:
the recursive map_inc(t) returns t's in-place-rewritten box, so
the "new tail" being stored is the same pointer as the "old
tail". A dec-old-then-store-new schedule would dec the box (to
zero, free), then store the freed pointer.

The root cause is upstream: 18c.4 doesn't yet null-out pattern-
moved field slots, so codegen can't statically distinguish
"old field still owned" from "already moved out". The chosen
trade-off: skip the field dec entirely; pattern-wildcarded
fields (rare in shipping fixtures) leak; pattern-moved fields
are the body's responsibility (consistent with the 18c.4 leak
baseline — Own params still leak today). 18d.2 ships zero
regression vs 18c.4, plus the perf win on the alloc + outer
cascade. Closing the leak is the move-aware-pattern story
queued for 18d.3 / 18e.

Tests:
- e2e reuse_as_demo_under_rc_uses_inplace_rewrite — runs
  examples/reuse_as_demo.ail.json under --alloc=rc, asserts
  stdout 9 and locks in the IR shape (icmp eq i64 ..., 1; the
  reuse arm has no @ailang_rc_alloc call).
- workspace reuse_as_shape_mismatch_is_reported_on_cons_to_nil
  — happy negative for the new diagnostic.
- 3 unit tests in reuse_shape::tests covering same-ctor-clean,
  cons-to-nil-reject, and indeterminate-source-ctor-reject.

Test deltas: e2e 56, ailang-check unit 46 -> 49 (+3), workspace
9 -> 10 (+1). cargo test --workspace green. Hand-verified
--alloc=rc binary on reuse_as_demo: stdout 9, exit 0.
This commit is contained in:
2026-05-08 11:21:02 +02:00
parent 0bf1a2fa1c
commit 73545ab086
5 changed files with 1235 additions and 22 deletions
+279 -11
View File
@@ -1512,17 +1512,28 @@ impl<'a> Emitter<'a> {
}
Ok((val_ssa, val_ty))
}
Term::ReuseAs { source: _, body } => {
// Iter 18d.1: identity. Iter 18d.2 will lower this as
// in-place rewrite under --alloc=rc. For now we simply
// lower `body` and return its `(ssa, ty)`. The `source`
// is dropped on the floor — in shipping fixtures it is
// always a `Term::Var` (no IR side effects), so the
// identity lowering is observably equivalent to lowering
// `body` alone. The linearity check (18c.2) and
// typecheck (18d.1) reject ill-formed shapes before we
// get here.
self.lower_term(body)
Term::ReuseAs { source, body } => {
// Iter 18d.2: under --alloc=rc, lower as a runtime-
// refcount-1 dispatch — if the source's box is
// unique we overwrite it in place (skipping the
// alloc-and-cascade-dec round-trip); otherwise we
// allocate a fresh box and dec the source. Other
// allocators keep the 18d.1 identity behaviour.
if !matches!(self.alloc, AllocStrategy::Rc) {
return self.lower_term(body);
}
// The body must be a Term::Ctor for the in-place
// rewrite to make sense. 18d.1 typecheck rejects
// any other shape; lams are accepted by typecheck
// but not yet supported by reuse codegen — fall
// back to identity for those (the body still
// allocates via ailang_rc_alloc, just without the
// reuse fast path).
let (body_type_name, body_ctor, body_args) = match body.as_ref() {
Term::Ctor { type_name, ctor, args } => (type_name, ctor, args),
_ => return self.lower_term(body),
};
self.lower_reuse_as_rc(source, body_type_name, body_ctor, body_args)
}
}
}
@@ -1751,6 +1762,263 @@ impl<'a> Emitter<'a> {
Ok((p, "ptr".into()))
}
/// Iter 18d.2: lower `Term::ReuseAs { source, body = Term::Ctor }`
/// under `--alloc=rc` as a runtime refcount-1 dispatch.
///
/// IR shape (Lean 4 / Roc lineage):
/// ```text
/// %src = <source's SSA>
/// %hdr_ptr = getelementptr inbounds i8, ptr %src, i64 -8
/// %refcnt = load i64, ptr %hdr_ptr
/// %is_one = icmp eq i64 %refcnt, 1
/// br i1 %is_one, label %reuse, label %fresh
///
/// reuse:
/// ; for each pointer-typed slot j: load old, drop_<field-T>(old)
/// ; store new tag at offset 0
/// ; store new field values at offsets 8, 16, ...
/// br label %join
///
/// fresh:
/// %newp = call ptr @ailang_rc_alloc(i64 SIZE)
/// ; store new tag + new fields (mirrors lower_ctor)
/// ; dec the now-superseded source
/// call void @drop_<m>_<source-type>(ptr %src)
/// br label %join
///
/// join:
/// %result = phi ptr [ %src, %reuse_end ], [ %newp, %fresh_end ]
/// ```
///
/// The shape-mismatch invariant (source's ctor has the same field
/// count and per-field LLVM types as `body`'s ctor) is enforced
/// upstream by `ailang_check::reuse_shape::check_module`. Codegen
/// trusts the check; if a mismatched shape ever reaches us, the
/// `reuse` arm's per-slot field iteration is bounded by
/// `expected_llvm_tys.len()` and the source-cell layout
/// guaranteed by `lower_ctor`/`@ailang_rc_alloc` — we won't read
/// past the end of the source, but we will silently corrupt
/// fields. The shape check ensures that path is unreachable.
fn lower_reuse_as_rc(
&mut self,
source: &Term,
body_type_name: &str,
body_ctor_name: &str,
body_args: &[Term],
) -> Result<(String, String)> {
// 1. Lower the source. Linearity (18d.1) guarantees this is a
// bare Var of an in-scope binder; lower_term resolves it
// to the binder's SSA.
let (src_ssa, src_ty) = self.lower_term(source)?;
if src_ty != "ptr" {
return Err(CodegenError::Internal(format!(
"reuse-as source type must be ptr, got {src_ty}"
)));
}
// The source's drop symbol — needed on the fresh arm to
// release this caller's share of the source (refcount > 1,
// by the branch we are inside). We deliberately use the
// shallow `ailang_rc_dec` here, NOT the per-type cascading
// drop:
//
// The fresh arm only fires when the source's refcount is
// > 1, i.e. another holder also has a reference. In that
// case the source's children are still owned by the other
// holder; cascading dec would dec children we don't have
// exclusive ownership over, and on aliasing with the new
// box's fields could leave a use-after-free in the new box.
// A shallow dec on the source's refcount preserves the
// 18c.4 baseline: source.refcount goes down by one; the
// box only frees when the LAST holder dec's it; children
// are dec'd by that final dec's cascade, not by ours.
//
// Identical to the unused-source-after-reuse-misfire
// pattern in Lean 4's reset/reuse codegen: the
// refcount-decrement is the contract; the drop fn cascade
// is owned by the last release, not by intermediate ones.
let _ = source; // synth_arg_type not needed for shallow dec
let src_drop_call = "ailang_rc_dec".to_string();
// 2. Resolve the body ctor and its expected per-field LLVM
// types. Mirrors the head of lower_ctor — same type-var
// substitution path so parameterised ADT bodies work the
// same way reuse-as lowers as plain ctor lowers.
let cref = self.lookup_ctor_by_type(body_type_name, body_ctor_name)?;
if body_args.len() != cref.ail_fields.len() {
return Err(CodegenError::Internal(format!(
"reuse-as body ctor `{body_type_name}/{body_ctor_name}` arity"
)));
}
let qualified_ail_fields: Vec<Type> = if body_type_name.matches('.').count() == 1 {
let (prefix, _) = body_type_name.split_once('.').expect("checked");
if let Some(target) = self.import_map.get(prefix) {
let owner_local_types = self.collect_owner_local_types(target);
cref.ail_fields
.iter()
.map(|f| qualify_local_types_codegen(f, target, &owner_local_types))
.collect()
} else {
cref.ail_fields.clone()
}
} else {
cref.ail_fields.clone()
};
let expected_llvm_tys: Vec<String> = if cref.type_vars.is_empty() {
cref.fields.clone()
} else {
let arg_ail_tys: Vec<Type> = body_args
.iter()
.map(|a| self.synth_arg_type(a))
.collect::<Result<_>>()?;
let var_set: BTreeSet<&str> =
cref.type_vars.iter().map(|s| s.as_str()).collect();
let mut subst: BTreeMap<String, Type> = BTreeMap::new();
for (exp, actual) in qualified_ail_fields.iter().zip(arg_ail_tys.iter()) {
unify_for_subst(exp, actual, &var_set, &mut subst)?;
}
qualified_ail_fields
.iter()
.map(|f| llvm_type(&apply_subst_to_type(f, &subst)))
.collect::<Result<_>>()?
};
// (18d.2 currently does not dec old fields in the reuse
// arm — see the rationale at the reuse arm — so the post-
// substitution per-field AILang types are not needed here.
// 18d.3 / 18e will revisit when the move-aware pattern
// story lands and the reuse arm can dec moved-out slots
// safely.)
// 3. Evaluate the body's args. They live in SSAs that both
// branches consume, so they are computed before the branch
// on refcount.
let mut compiled = Vec::new();
for (a, exp) in body_args.iter().zip(expected_llvm_tys.iter()) {
let (v, vty) = self.lower_term(a)?;
if &vty != exp {
return Err(CodegenError::Internal(format!(
"reuse-as body ctor `{body_ctor_name}` field type {vty} != expected {exp}"
)));
}
compiled.push((v, vty));
}
// 4. Refcount-1 dispatch. Header is at offset -8 (see
// runtime/rc.c::header_of). A static / data-segment ptr
// (e.g. a top-level fn's static closure pair) would
// dereference garbage here, but reuse-as on such a
// pointer is meaningless — the linearity check would
// have rejected `<source>` as not naming a heap binder.
let id = self.fresh_id();
let reuse_lbl = format!("reuse.{id}");
let fresh_lbl = format!("fresh.{id}");
let join_lbl = format!("rejoin.{id}");
let hdr_ptr = self.fresh_ssa();
self.body.push_str(&format!(
" {hdr_ptr} = getelementptr inbounds i8, ptr {src_ssa}, i64 -8\n"
));
let refcnt = self.fresh_ssa();
self.body.push_str(&format!(
" {refcnt} = load i64, ptr {hdr_ptr}, align 8\n"
));
let is_one = self.fresh_ssa();
self.body.push_str(&format!(
" {is_one} = icmp eq i64 {refcnt}, 1\n"
));
self.body.push_str(&format!(
" br i1 {is_one}, label %{reuse_lbl}, label %{fresh_lbl}\n"
));
// 5. Reuse arm: overwrite tag and field slots in place. The
// old pointer-typed field values are NOT dec'd here.
//
// Rationale: 18c.4's RC story does not yet emit a dec on
// pattern-bound fields at scope close (see 18c.4's "fn
// parameters / pattern-move debt" — patterns LOAD field
// pointers without inc'ing, treating the load as a
// logical move-out into the binder). Under that model,
// by the time control reaches the reuse-as site, every
// pointer-typed old field that the body actually
// consumes has already been "moved out" of the slot;
// dec'ing it again here would either double-free (when
// the body's evaluation freed it) or, worse, free a box
// the new field value aliases (the canonical case:
// `(reuse-as xs (Cons (+ h 1) (map_inc t)))` returns
// `t`'s in-place-rewritten box as the new tail). A
// self-dec that cascades through B.field[1]=C while C
// is also the new tail produces a use-after-free.
//
// The cost: an old pointer-typed field that the body did
// NOT pattern-move (e.g. `match xs (Cons _ _ → ...)`
// with both fields wildcarded) leaks here. That leak is
// the same shape 18c.4 already accepts for non-trackable
// pattern fields, so 18d.2 doesn't regress the baseline.
// Closing the leak requires the move-aware pattern story
// (queued for 18d.3 / 18e alongside fn-parameter dec).
self.start_block(&reuse_lbl);
// Store new tag at offset 0. Even when the new tag equals the
// old tag (most reuse-as fixtures: Cons → Cons), we emit the
// store unconditionally — codegen doesn't have to special-
// case "same tag", the store is cheap, and the IR is simpler.
self.body.push_str(&format!(
" store i64 {tag}, ptr {src_ssa}, align 8\n",
tag = cref.tag
));
// Store new field values into the slots.
for (i, (v, ty)) in compiled.iter().enumerate() {
let off = 8 + i as i64 * 8;
let addr = self.fresh_ssa();
self.body.push_str(&format!(
" {addr} = getelementptr inbounds i8, ptr {src_ssa}, i64 {off}\n"
));
self.body
.push_str(&format!(" store {ty} {v}, ptr {addr}, align 8\n"));
}
let reuse_end = self.current_block.clone();
self.body.push_str(&format!(" br label %{join_lbl}\n"));
// 6. Fresh arm: standard alloc + tag + field stores; then
// dec the source (whose refcount was > 1, so this is the
// callee's release of its share — may or may not free).
self.start_block(&fresh_lbl);
let size_bytes = 8 + (compiled.len() * 8) as i64;
let newp = self.fresh_ssa();
self.body.push_str(&format!(
" {newp} = call ptr @{}(i64 {size_bytes})\n",
self.alloc.fn_name()
));
self.body.push_str(&format!(
" store i64 {tag}, ptr {newp}, align 8\n",
tag = cref.tag
));
for (i, (v, ty)) in compiled.iter().enumerate() {
let off = 8 + i as i64 * 8;
let addr = self.fresh_ssa();
self.body.push_str(&format!(
" {addr} = getelementptr inbounds i8, ptr {newp}, i64 {off}\n"
));
self.body
.push_str(&format!(" store {ty} {v}, ptr {addr}, align 8\n"));
}
// Dec the source — the user's reuse-hint failed because the
// box was shared. The source's drop fn cascades through any
// boxed children of the OLD ctor (the slot we couldn't reuse),
// matching the cascade lower_ctor would do for any other
// owned binder going out of scope.
self.body
.push_str(&format!(" call void @{src_drop_call}(ptr {src_ssa})\n"));
let fresh_end = self.current_block.clone();
self.body.push_str(&format!(" br label %{join_lbl}\n"));
// 7. Join: phi over the two arms' result pointers.
self.start_block(&join_lbl);
let phi = self.fresh_ssa();
self.body.push_str(&format!(
" {phi} = phi ptr [ {src_ssa}, %{reuse_end} ], [ {newp}, %{fresh_end} ]\n"
));
Ok((phi, "ptr".into()))
}
fn lower_match(
&mut self,
scrutinee: &Term,