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
+78 -11
View File
@@ -1331,20 +1331,31 @@ fn clone_demo_is_identity_in_18c1() {
assert_eq!(stdout.trim(), "42");
}
/// Iter 18d.1: `Term::ReuseAs` is a schema-floor addition. In 18d.1 the
/// wrapper is identity at codegen — the body is lowered, the source is
/// dropped on the floor — so a program containing `(reuse-as <var>
/// <ctor>)` produces the same stdout under `--alloc=gc` it would have
/// produced without the wrapper. Iter 18d.2 will replace the codegen
/// passthrough with an in-place rewrite under `--alloc=rc`.
/// Iter 18d.2: under `--alloc=rc`, `Term::ReuseAs { source, body =
/// Term::Ctor }` lowers to a runtime refcount-1 dispatch — when the
/// source's box is unique we overwrite it in place (skipping the
/// alloc + cascade-dec round-trip); otherwise we fall back to a
/// fresh allocation and dec the source. Other allocators keep the
/// 18d.1 identity behaviour.
///
/// Properties guarded:
/// (1) The fixture's canonical JSON contains `"t":"reuse-as"` — the
/// schema for the new variant did not regress.
/// (2) `--alloc=gc` produces `9` (1+1 + 2+1 + 3+1) — the typechecker,
/// parser/printer round-trip, and codegen identity all agree.
/// (2) `--alloc=gc` produces `9` (1+1 + 2+1 + 3+1) — the legacy
/// identity codegen still produces correct output.
/// (3) `--alloc=rc` produces the SAME `9` — the in-place rewrite is
/// observably equivalent to the gc path on this fixture.
/// (4) `--alloc=rc` exits cleanly (status 0; no segfault, no
/// refcount underflow). `build_and_run_with_alloc` panics on
/// non-zero exit, so this is implicit but worth naming.
/// (5) The emitted IR for `--alloc=rc` contains both:
/// - `icmp eq i64 %... 1` (the refcount-1 branch test)
/// - a path that does NOT call `@ailang_rc_alloc` for the inner
/// Cons ctor on the reuse arm — verified by the
/// `reuse.<id>:` block label and the absence of
/// `@ailang_rc_alloc` calls inside it.
#[test]
fn reuse_as_demo_is_identity_in_18d1() {
fn reuse_as_demo_under_rc_uses_inplace_rewrite() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let json_path = workspace.join("examples").join("reuse_as_demo.ail.json");
@@ -1354,8 +1365,64 @@ fn reuse_as_demo_is_identity_in_18d1() {
"expected `\"t\":\"reuse-as\"` in canonical JSON; the schema for `(reuse-as ...)` regressed"
);
let stdout = build_and_run_with_alloc("reuse_as_demo.ail.json", "gc");
assert_eq!(stdout.trim(), "9");
// (2) gc baseline.
let stdout_gc = build_and_run_with_alloc("reuse_as_demo.ail.json", "gc");
assert_eq!(stdout_gc.trim(), "9");
// (3) rc matches gc, (4) clean exit (build_and_run_with_alloc
// panics on non-zero status).
let stdout_rc = build_and_run_with_alloc("reuse_as_demo.ail.json", "rc");
assert_eq!(stdout_rc.trim(), "9");
// (5) Inspect the rc IR text. We re-lower via the codegen API
// directly so the assertion runs without depending on the
// filesystem layout of `ail build`'s tmpdir.
let ws = ailang_core::load_workspace(&json_path).expect("load workspace");
let mut lifted_modules = std::collections::BTreeMap::new();
for (mname, m) in &ws.modules {
let desugared = ailang_core::desugar::desugar_module(m);
let lifted = ailang_check::lift_letrecs(&desugared)
.unwrap_or_else(|e| panic!("lift_letrecs in module `{mname}`: {e:?}"));
lifted_modules.insert(mname.clone(), lifted);
}
let lifted_ws = ailang_core::Workspace {
entry: ws.entry.clone(),
modules: lifted_modules,
root_dir: ws.root_dir.clone(),
};
let ir = ailang_codegen::lower_workspace_with_alloc(
&lifted_ws,
ailang_codegen::AllocStrategy::Rc,
)
.expect("lower workspace under rc");
// The refcount-1 branch test: lower_reuse_as_rc emits exactly
// one such instruction per reuse-as site. Search lenient on the
// SSA register name (we don't care which `%v...` was assigned).
assert!(
ir.contains("icmp eq i64") && ir.contains(", 1\n"),
"rc IR missing `icmp eq i64 %... 1` (the refcount-1 branch test). IR was:\n{ir}"
);
assert!(
ir.contains("br i1") && ir.contains("label %reuse"),
"rc IR missing `br i1 ... label %reuse...` (the refcount-1 branch). IR was:\n{ir}"
);
// The reuse arm must NOT call `@ailang_rc_alloc` — that's the
// whole point of the in-place rewrite. We check the substring
// that begins with the reuse label and ends at the next label
// (the label `rejoin.` per `lower_reuse_as_rc`'s scheme).
let reuse_idx = ir.find("reuse.").expect("reuse label present");
// Walk forward to the next block label after the reuse: arm.
// `lower_reuse_as_rc` emits `reuse.<id>:` then code, then
// `br label %rejoin.<id>`. The fresh arm immediately follows
// as `fresh.<id>:` — that's the boundary.
let after_reuse = &ir[reuse_idx..];
let reuse_arm_end = after_reuse
.find("\nfresh.")
.expect("fresh label follows the reuse arm in lower_reuse_as_rc layout");
let reuse_arm = &after_reuse[..reuse_arm_end];
assert!(
!reuse_arm.contains("@ailang_rc_alloc"),
"reuse arm must not call @ailang_rc_alloc (the slot is reused in place). Reuse arm IR was:\n{reuse_arm}"
);
}
/// Iter 18b: extends `alloc_rc_produces_same_stdout_as_gc` to a larger