From 298dcaf0f78890866e9d4b623a2e9fc665021d35 Mon Sep 17 00:00:00 2001 From: Brummel Date: Fri, 8 May 2026 10:37:25 +0200 Subject: [PATCH] Iter 18c.4: per-type drop fn + recursive dec cascade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes 18c.3's main scope cut. Codegen now emits a per-ADT and per-closure drop function under --alloc=rc; the Term::Let scope-close emission routes through these drops instead of ailang_rc_dec directly, so a recursive ADT (List, Tree) under --alloc=rc actually frees its tail cells when the outer binder is dec'd. Drop-symbol scheme: - For every Def::Type T in module m: emit one define void @drop__(ptr %p) under --alloc=rc. Body: null-guard, load tag, switch on tag, per-ctor arm dec's every pointer-typed field via field_drop_call, then dec's the outer cell and rets. - For every escaping Term::Lam: emit drop___env(ptr) for the captured-free-vars block + drop___pair(ptr) for the {env, fn-ptr} closure pair. A new closure_drops side table on Emitter keys closure-pair SSAs to their pair-drop symbol. Decision (preempted in dispatch): always emit @drop__ for every ADT, even ones with no boxed children. The body for no-boxed-children ADTs is null-guard + dec + ret. Call sites are uniform; future codegen changes that thread cleanup through the drop seam (atomic dec under threading, etc.) have one canonical entry per type. field_drop_call dispatches on field type: - Type::Con { name, .. } → drop__ (recursion). - Type::Str / Type::Fn / Type::Var → fall back to ailang_rc_dec. These three fall-backs are 18d/18e/monomorphisation debt; they are flagged in-source for grep. Term::Let scope-close emission is the same as 18c.3 modulo the target: Term::Ctor binders → drop__; Term::Lam binders → the closure_drops-recorded pair-drop symbol. All other emission gates from 18c.3 (consume_count == 0, body-tail-not-binder, block-not-terminated, non-escape) are unchanged. Recursion is stack-recursive — drop_IntList's Cons arm calls itself on the tail. For 18c.4's 5-element fixtures the depth is safe; long lists are out of scope until 18e replaces the recursive call with a worklist allocator. The recursive site is commented for grep. Tests: - examples/rc_list_drop.ail.json — 5-element IntList summed via sum_list (xs has consume_count == 1; codegen skips the let- close drop call but the IR shape is locked in by the unit test below). - examples/rc_list_drop_borrow.ail.json — same 5-element IntList, match-only consumption (consume_count == 0). Codegen emits drop__IntList(xs) at scope close; the cascade runs over all 5 cells at runtime. - crates/ail/tests/e2e.rs — alloc_rc_recursive_list_sum + alloc_rc_borrow_only_recursive_list_drop. Both assert --alloc=rc stdout matches --alloc=gc and the binaries exit cleanly. - crates/ailang-codegen/src/lib.rs — unit test asserting the IR shape: define void @drop_rclist_IntList header, recursive self-call inside, outer ailang_rc_dec at the tail; negative- complement under --alloc=gc. Tests: E2E 53 -> 55, codegen unit 11 -> 12. cargo test --workspace green. --- crates/ail/tests/e2e.rs | 61 ++++ crates/ailang-codegen/src/lib.rs | 446 +++++++++++++++++++++++++- examples/rc_list_drop.ail.json | 139 ++++++++ examples/rc_list_drop_borrow.ail.json | 113 +++++++ 4 files changed, 753 insertions(+), 6 deletions(-) create mode 100644 examples/rc_list_drop.ail.json create mode 100644 examples/rc_list_drop_borrow.ail.json diff --git a/crates/ail/tests/e2e.rs b/crates/ail/tests/e2e.rs index fe6aed1..b7f05c7 100644 --- a/crates/ail/tests/e2e.rs +++ b/crates/ail/tests/e2e.rs @@ -1379,3 +1379,64 @@ fn alloc_rc_emits_dec_for_unique_let_bound_box() { assert_eq!(stdout_rc.trim(), "42"); assert_eq!(stdout_gc, stdout_rc, "alloc=rc must match alloc=gc on rc_box_drop"); } + +/// Iter 18c.4: per-type drop fns + recursive `dec` cascade. Builds a +/// 5-element `IntList` and reduces it via `sum_list`. Under +/// `--alloc=rc` the codegen emits `define void @drop__IntList` +/// whose `Cons` arm recursively calls itself on the `tail` field — +/// the first iter where a recursive ADT under RC frees its tail +/// cells when their drop fn is invoked. Stdout must match `--alloc=gc` +/// (`15`) and the binary must exit cleanly. +/// +/// Note: in this fixture the binder `xs` has `consume_count == 1` +/// (passed to `sum_list`), so codegen does NOT emit a drop call at +/// `main`'s let-close — the cells stay leaked at process exit (the +/// caller / fn-param dec story is 18d). The fixture's purpose is to +/// (a) lock in the recursive-ADT IR-shape (covered in the codegen +/// IR-shape test below) and (b) confirm the program still runs +/// correctly. The companion `rc_list_drop_borrow` fixture exercises +/// the dec path itself. +#[test] +fn alloc_rc_recursive_list_sum() { + let example = "rc_list_drop.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(), "15"); + assert_eq!(stdout_rc.trim(), "15"); + assert_eq!( + stdout_gc, stdout_rc, + "alloc=rc must match alloc=gc on rc_list_drop" + ); +} + +/// Iter 18c.4: actually exercise the recursive drop cascade at +/// runtime. The let-binder `xs` of a 5-element `IntList` has +/// `consume_count == 0` (only the match scrutinee, a borrow), so +/// codegen emits `call void @drop__IntList(ptr xs)` at scope +/// close. The drop fn's `Cons` arm recurses on the tail — for a +/// 5-cell list that is 5 nested calls, exactly the recursive shape +/// 18e will replace with an iterative worklist. The depth here is +/// safely below any reasonable stack limit; longer lists are out +/// of scope until 18e. +/// +/// Properties guarded: +/// 1. The binary runs to completion (no segfault from a +/// mishandled recursive cascade or refcount underflow). +/// 2. Stdout matches `--alloc=gc` byte-for-byte (`11` — the head +/// of the 5-element list). +/// 3. The match arm's pattern bindings (`h`, `t`) do NOT trip +/// the dec machinery — `t` was loaded as a borrow inside the +/// arm and must not be dec'd; we rely on `lower_match`'s +/// pattern binding flow not adding a competing dec call. +#[test] +fn alloc_rc_borrow_only_recursive_list_drop() { + let example = "rc_list_drop_borrow.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(), "11"); + assert_eq!(stdout_rc.trim(), "11"); + assert_eq!( + stdout_gc, stdout_rc, + "alloc=rc must match alloc=gc on rc_list_drop_borrow" + ); +} diff --git a/crates/ailang-codegen/src/lib.rs b/crates/ailang-codegen/src/lib.rs index e46c53d..c7257ed 100644 --- a/crates/ailang-codegen/src/lib.rs +++ b/crates/ailang-codegen/src/lib.rs @@ -543,6 +543,15 @@ struct Emitter<'a> { /// table is module-scoped because the inference is whole-fn local; /// no cross-module entries appear. uniqueness: UniquenessTable, + /// Iter 18c.4: per-closure-pair drop-function symbol. Keyed by the + /// closure-pair SSA value (e.g. `%v17`) the most-recent + /// `lower_lambda` call returned. Consulted by the `Term::Let` + /// lowering to emit `call void @(ptr %v17)` instead of the + /// raw `@ailang_rc_dec` when the binder owns a closure pair. + /// Empty under non-`Rc` allocators — a closure under + /// `--alloc=gc`/`--alloc=bump` has no drop fn and is freed by + /// the collector / arena. + closure_drops: BTreeMap, } #[derive(Debug, Clone)] @@ -655,6 +664,7 @@ impl<'a> Emitter<'a> { non_escape: NonEscapeSet::new(), alloc, uniqueness, + closure_drops: BTreeMap::new(), } } @@ -703,9 +713,194 @@ impl<'a> Emitter<'a> { ) })?; } + // Iter 18c.4: per-ADT drop functions. Emitted only under + // `--alloc=rc`. One `void @drop__(ptr)` per + // `Def::Type` in the current module — the call site for + // recursive ADTs (`drop__List` calling itself on the tail) + // requires every ADT to have a uniformly-named drop fn, so we + // emit even for ADTs with no boxed children (those drop fns + // just dec the outer box). Under `Gc`/`Bump` no drop fns + // appear — the IR shape stays byte-identical to pre-18c.4. + if matches!(self.alloc, AllocStrategy::Rc) { + for def in &self.module.defs { + if let Def::Type(td) = def { + self.emit_drop_fn_for_type(td); + } + } + } Ok(()) } + /// Iter 18c.4: emit a `void @drop__(ptr %p)` + /// function that decrements the refcount of every pointer-typed + /// field of every ctor, then frees the outer box. + /// + /// Shape: + /// ```text + /// define void @drop__(ptr %p) { + /// %tag = load i64, ptr %p + /// switch i64 %tag, label %dflt [ + /// i64 0, label %arm0 + /// ... + /// ] + /// arm_i: + /// for each pointer-typed field f_j: + /// %addr = gep ptr %p, i64 (8 + 8*j) + /// %v = load ptr, ptr %addr + /// call void @drop__(ptr %v) ; or @ailang_rc_dec + /// br label %join + /// dflt: + /// unreachable + /// join: + /// call void @ailang_rc_dec(ptr %p) + /// ret void + /// } + /// ``` + /// + /// For ADTs with no boxed children every arm is empty and falls + /// straight through to `join`, which is just the final dec — see + /// the assignment's "always emit drop_X for every ADT" decision + /// (`rc_box_drop`'s `MkBox(Int)` is the canonical example). + /// + /// Recursion: when a ctor field's type is the same ADT (or any + /// ADT in the workspace), the emitted call to + /// `@drop__(field)` is recursive at the IR level and + /// will overflow the stack on long lists. Iter 18e replaces the + /// recursive call with an iterative worklist free; for 18c.4 the + /// recursion is intentional and shallow — the assignment lifts + /// the bound to "5-element list" fixtures only. + fn emit_drop_fn_for_type(&mut self, td: &TypeDef) { + let m = self.module_name; + let tname = &td.name; + let mut out = String::new(); + out.push_str(&format!("define void @drop_{m}_{tname}(ptr %p) {{\n")); + out.push_str("entry:\n"); + // Null guard: a null payload is a no-op (matches + // `runtime/rc.c::ailang_rc_dec`'s null guard). + out.push_str(" %is_null = icmp eq ptr %p, null\n"); + out.push_str(" br i1 %is_null, label %ret, label %live\n"); + out.push_str("live:\n"); + out.push_str(" %tag = load i64, ptr %p, align 8\n"); + + let n_ctors = td.ctors.len(); + // Switch over the tag. Each ctor gets one arm. + out.push_str(" switch i64 %tag, label %dflt [\n"); + for (i, _) in td.ctors.iter().enumerate() { + out.push_str(&format!(" i64 {i}, label %arm_{i}\n")); + } + out.push_str(" ]\n"); + + // Per-ctor arm: iterate fields, dec the boxed ones. + let mut local = 0u64; + for (i, ctor) in td.ctors.iter().enumerate() { + out.push_str(&format!("arm_{i}:\n")); + for (j, fty) in ctor.fields.iter().enumerate() { + // Decide what to call for this field. If the field + // lowers to `ptr` (boxed), we issue a `dec` call. + // For known ADT field types we route through that + // ADT's own drop fn so the recursion cascades; for + // anything else that lowers to `ptr` (Str, fn-typed, + // unresolved Var), fall back to plain `ailang_rc_dec`. + let lty = llvm_type(fty).unwrap_or_else(|_| "ptr".into()); + if lty != "ptr" { + continue; + } + let off = 8 + (j as i64) * 8; + let addr_id = local; + local += 1; + let val_id = local; + local += 1; + out.push_str(&format!( + " %a{addr_id} = getelementptr inbounds i8, ptr %p, i64 {off}\n" + )); + out.push_str(&format!( + " %v{val_id} = load ptr, ptr %a{addr_id}, align 8\n" + )); + let drop_call = self.field_drop_call(fty); + // Iter 18e replaces this recursive call with an + // iterative worklist push. For 18c.4 it stays + // recursive — fine for the shipping fixtures. + out.push_str(&format!( + " call void @{drop_call}(ptr %v{val_id})\n" + )); + } + out.push_str(" br label %join\n"); + } + + // Default arm: unreachable when the typechecker has accepted + // the input — every legal box has one of the ctor tags. + out.push_str("dflt:\n"); + if n_ctors == 0 { + // No ctors at all: a Type with zero ctors cannot be + // instantiated; the drop fn is dead. Still emit a + // br-to-join for IR validity. + out.push_str(" br label %join\n"); + } else { + out.push_str(" unreachable\n"); + } + + // Join: free the outer box. + out.push_str("join:\n"); + out.push_str(" call void @ailang_rc_dec(ptr %p)\n"); + out.push_str(" br label %ret\n"); + out.push_str("ret:\n"); + out.push_str(" ret void\n"); + out.push_str("}\n\n"); + self.body.push_str(&out); + } + + /// Iter 18c.4: pick the drop-fn symbol to call for a single + /// pointer-typed field. Routes ADT fields to their own + /// `drop__` symbol so the recursion cascades through + /// recursive types (List, Tree). Falls back to `ailang_rc_dec` + /// for non-ADT pointer types (Str, fn-typed, unresolved Var) — + /// those are not user-defined ADTs and have no per-type drop fn. + fn field_drop_call(&self, fty: &Type) -> String { + match fty { + Type::Con { name, .. } => { + // Built-in pointer-typed cons: Str. No drop fn — + // shallow `ailang_rc_dec` is the right answer (Str + // payloads are NUL-terminated bytes in static + // memory; nothing to recurse into). + if matches!(name.as_str(), "Str") { + return "ailang_rc_dec".to_string(); + } + // Qualified `module.T` → drop fn lives in `module`. + if name.matches('.').count() == 1 { + let (prefix, suffix) = name.split_once('.').expect("checked"); + if let Some(target) = self.import_map.get(prefix) { + return format!("drop_{target}_{suffix}"); + } + // Fallback: treat the prefix itself as the owner + // module (typechecker would have rejected an + // unimported prefix earlier). + return format!("drop_{prefix}_{suffix}"); + } + // Bare name: declared in the current module. + format!("drop_{m}_{name}", m = self.module_name) + } + // Type::Fn (closure-typed field) → no per-type drop fn + // exists for closures (each closure has its own per-pair + // drop fn keyed by the lam-id, not by type). Iter 18c.4 + // shallow-frees the closure-pair; the env and any + // captured ADT fields leak. A future iter that types + // closure-typed fields with a runtime descriptor pointer + // would close this. For 18c.4's recursive-ADT story this + // is acceptable — the shipping fixtures don't store + // closures inside ADT fields. + Type::Fn { .. } => "ailang_rc_dec".to_string(), + // Type::Var (parameterised ADT field whose type-arg was + // not pinned at declaration): we don't know the field's + // concrete shape from the static decl alone, and the + // generated drop fn is a single symbol per ADT (no + // monomorphisation). Shallow free is the conservative + // choice — boxed children of polymorphic-typed fields + // leak. Iter 18d/18e will revisit this when reuse hints + // and the worklist allocator land. + Type::Var { .. } | Type::Forall { .. } => "ailang_rc_dec".to_string(), + } + } + /// Iter 12b: emit one specialised version of a polymorphic def. /// Substitutes rigid vars in both the type and the body, then /// calls `emit_fn` against a synthetic FnDef whose `name` already @@ -986,6 +1181,40 @@ impl<'a> Emitter<'a> { } } + /// Iter 18c.4: pick the drop-fn symbol to call at the close of a + /// trackable `Term::Let` scope. For a `Term::Ctor` binder the + /// symbol is `drop__` — derived from the ctor's + /// `type_name` (which already encodes the owning module via the + /// `module.T` form when cross-module). For a `Term::Lam` binder + /// the symbol comes from `closure_drops`, populated by + /// [`lower_lambda`] when it emitted the per-pair drop fn. + /// + /// Falls back to `ailang_rc_dec` for any other shape — should be + /// unreachable since `is_rc_heap_allocated` only returns `true` + /// for `Term::Ctor` / `Term::Lam`, but a defensive fallback + /// keeps the IR well-formed even if the predicate ever widens. + fn drop_symbol_for_binder(&self, value: &Term, val_ssa: &str) -> String { + match value { + Term::Ctor { type_name, .. } => { + if type_name.matches('.').count() == 1 { + let (prefix, suffix) = + type_name.split_once('.').expect("checked"); + if let Some(target) = self.import_map.get(prefix) { + return format!("drop_{target}_{suffix}"); + } + return format!("drop_{prefix}_{suffix}"); + } + format!("drop_{m}_{type_name}", m = self.module_name) + } + Term::Lam { .. } => self + .closure_drops + .get(val_ssa) + .cloned() + .unwrap_or_else(|| "ailang_rc_dec".to_string()), + _ => "ailang_rc_dec".to_string(), + } + } + /// Lowers a term to (SSA value string, LLVM type). fn lower_term(&mut self, t: &Term) -> Result<(String, String)> { match t { @@ -1075,7 +1304,7 @@ impl<'a> Emitter<'a> { let r = self.lower_term(body); self.locals.pop(); - // Iter 18c.3: emit `dec(name)` at scope close iff: + // Iter 18c.3: emit a drop call at scope close iff: // - we're tracking this binder (heap RC alloc above), // - the value type is `ptr` (not a primitive), // - uniqueness inference recorded `consume_count == 0` @@ -1087,10 +1316,15 @@ impl<'a> Emitter<'a> { // - the current block is still open (a tail-call / // `unreachable` already exited; nothing to emit). // - // Without per-type drop fns (Iter 18c.4), `dec` is - // shallow — boxed children of the freed cell leak. The - // assignment limits 18c.3 fixtures to single-cell ADTs - // / closures whose lifecycle does not need recursion. + // Iter 18c.4: the drop call is no longer a raw + // `ailang_rc_dec`. For a `Term::Ctor` binder the call + // routes through `@drop__` so any + // boxed children of the cell cascade through their + // own drop fns; for a `Term::Lam` binder the call + // routes through the per-pair drop fn the lambda + // emission recorded in `closure_drops`. Both shapes + // call `ailang_rc_dec` on the outer box internally, + // so the refcount story is unchanged. if trackable && val_ty == "ptr" && !self.block_terminated { let consume_count = self .uniqueness @@ -1102,8 +1336,9 @@ impl<'a> Emitter<'a> { Err(_) => true, // Don't emit on error path either. }; if consume_count == 0 && !body_returns_binder { + let drop_sym = self.drop_symbol_for_binder(value, &val_ssa); self.body.push_str(&format!( - " call void @ailang_rc_dec(ptr {val_ssa})\n" + " call void @{drop_sym}(ptr {val_ssa})\n" )); } } @@ -2329,9 +2564,112 @@ impl<'a> Emitter<'a> { }; self.ssa_fn_sigs.insert(clos.clone(), fs); + // Iter 18c.4: emit per-pair drop fns for heap-allocated + // closures under `--alloc=rc`. `lam_local` closures live on + // the stack and need no drop fn — LLVM `alloca` reclaims the + // pair on fn return. For heap closures we emit two symbols: + // + // - `drop__env(env)` — dec each pointer-typed + // capture, then dec the env block itself. ADT captures + // cascade through the per-type drop fn; closure-typed + // captures fall back to plain `ailang_rc_dec` because we + // don't keep a per-pair drop pointer alongside the env + // cell (yet). Iter 18d/18e revisit this. + // + // - `drop__pair(p)` — load the env from offset 8, + // call `drop__env(env)`, then dec the pair box. + // + // The pair drop is the symbol `Term::Let` lowering calls + // when the binder is a closure; we record it in + // `closure_drops` keyed by the closure-pair SSA. + if matches!(self.alloc, AllocStrategy::Rc) && !lam_local { + let pair_drop = format!("drop_{m}_{thunk_name}_pair", m = self.module_name); + let env_drop = format!("drop_{m}_{thunk_name}_env", m = self.module_name); + let env_text = self.build_env_drop_fn(&env_drop, &cap_meta); + self.deferred_thunks.push(env_text); + let pair_text = self.build_pair_drop_fn(&pair_drop, &env_drop, !cap_meta.is_empty()); + self.deferred_thunks.push(pair_text); + self.closure_drops.insert(clos.clone(), pair_drop); + } + Ok((clos, "ptr".into())) } + /// Iter 18c.4: build the IR text for a closure env's drop fn. + /// Layout: 8 bytes per capture, in declaration order. + /// For each pointer-typed capture, emit a load + drop call; + /// finally `ailang_rc_dec` the env block. + fn build_env_drop_fn( + &self, + sym: &str, + cap_meta: &[(String, String, String, Type, Option)], + ) -> String { + let mut out = String::new(); + out.push_str(&format!("define void @{sym}(ptr %env) {{\nentry:\n")); + out.push_str(" %is_null = icmp eq ptr %env, null\n"); + out.push_str(" br i1 %is_null, label %ret, label %live\n"); + out.push_str("live:\n"); + let mut local = 0u64; + for (i, (_cname, _outer_ssa, lty, ail_ty, _sig)) in cap_meta.iter().enumerate() { + if lty != "ptr" { + continue; + } + let off = (i as i64) * 8; + let addr_id = local; + local += 1; + let val_id = local; + local += 1; + out.push_str(&format!( + " %a{addr_id} = getelementptr inbounds i8, ptr %env, i64 {off}\n" + )); + out.push_str(&format!( + " %v{val_id} = load ptr, ptr %a{addr_id}, align 8\n" + )); + let drop_call = self.field_drop_call(ail_ty); + out.push_str(&format!( + " call void @{drop_call}(ptr %v{val_id})\n" + )); + } + out.push_str(" call void @ailang_rc_dec(ptr %env)\n"); + out.push_str(" br label %ret\n"); + out.push_str("ret:\n"); + out.push_str(" ret void\n}\n\n"); + out + } + + /// Iter 18c.4: build the IR text for a closure pair's drop fn. + /// Layout: { ptr thunk, ptr env } — env at offset 8. + /// Loads env, calls the env drop, then decs the pair box. + fn build_pair_drop_fn( + &self, + sym: &str, + env_drop: &str, + has_env: bool, + ) -> String { + let mut out = String::new(); + out.push_str(&format!("define void @{sym}(ptr %p) {{\nentry:\n")); + out.push_str(" %is_null = icmp eq ptr %p, null\n"); + out.push_str(" br i1 %is_null, label %ret, label %live\n"); + out.push_str("live:\n"); + if has_env { + out.push_str( + " %ea = getelementptr inbounds {{ ptr, ptr }}, ptr %p, i64 0, i32 1\n", + ); + out.push_str(" %env = load ptr, ptr %ea, align 8\n"); + out.push_str(&format!(" call void @{env_drop}(ptr %env)\n")); + } else { + // No env — the env-drop is still emitted (uniform shape) + // but is a no-op on null. Skip the load and call dec + // directly on the pair. + let _ = env_drop; + } + out.push_str(" call void @ailang_rc_dec(ptr %p)\n"); + out.push_str(" br label %ret\n"); + out.push_str("ret:\n"); + out.push_str(" ret void\n}\n\n"); + out + } + /// Iter 8b: walk a Term collecting free-variable names that should /// be captured by an enclosing lambda. Builtins and top-level fns /// are excluded — they're globally accessible. Qualified names @@ -3651,4 +3989,100 @@ mod tests { other => panic!("expected MissingEntryMain, got {other:?}"), } } + + /// Iter 18c.4: under `--alloc=rc`, codegen emits one + /// `define void @drop__` per `Def::Type`. For a recursive + /// ADT — a `IntList` with `Cons(Int, IntList)` — the `Cons` arm + /// of the drop fn loads the tail field and calls the ADT's own + /// drop fn on it (the recursion 18e replaces with an iterative + /// worklist). We assert both shapes here: + /// + /// 1. `define void @drop__IntList(ptr %p)` is present. + /// 2. The fn's body contains a self-recursive call + /// `call void @drop__IntList(ptr %v...)` — proof that + /// the `Cons` arm walked the tail field rather than just + /// decrementing the outer cell. + /// + /// The `Nil` arm has no boxed children and is a `br` to the + /// shared `join` block — implicit in (1). + /// + /// Negative complement: under `--alloc=gc` no drop fn is + /// emitted; the IR shape stays byte-identical to the pre-18c.4 + /// pipeline. + #[test] + fn rc_alloc_emits_recursive_drop_fn_for_recursive_adt() { + let m = Module { + schema: SCHEMA.into(), + name: "rclist".into(), + imports: vec![], + defs: vec![ + Def::Type(TypeDef { + name: "IntList".into(), + vars: vec![], + ctors: vec![ + Ctor { + name: "Nil".into(), + fields: vec![], + }, + Ctor { + name: "Cons".into(), + fields: vec![ + Type::int(), + Type::Con { + name: "IntList".into(), + args: vec![], + }, + ], + }, + ], + doc: None, + }), + Def::Fn(FnDef { + name: "main".into(), + ty: Type::Fn { + params: vec![], + ret: Box::new(Type::unit()), + effects: vec![], + param_modes: vec![], + ret_mode: ParamMode::Implicit, + }, + params: vec![], + body: Term::Lit { lit: Literal::Unit }, + doc: None, + }), + ], + }; + let ws = Workspace { + entry: m.name.clone(), + modules: { + let mut x = BTreeMap::new(); + x.insert(m.name.clone(), m.clone()); + x + }, + root_dir: std::path::PathBuf::from("."), + }; + let ir_rc = lower_workspace_with_alloc(&ws, AllocStrategy::Rc).unwrap(); + assert!( + ir_rc.contains("define void @drop_rclist_IntList(ptr %p)"), + "rc IR missing per-type drop fn header. IR was:\n{ir_rc}" + ); + // The Cons arm loads the tail field and recurses through + // the same drop symbol — proof that the cascade is wired. + assert!( + ir_rc.contains("call void @drop_rclist_IntList(ptr %v"), + "rc IR missing recursive drop call inside drop_rclist_IntList. IR was:\n{ir_rc}" + ); + // The drop fn finishes by dec'ing the outer box. + assert!( + ir_rc.contains("call void @ailang_rc_dec(ptr %p)"), + "rc IR missing outer-box dec inside drop_rclist_IntList. IR was:\n{ir_rc}" + ); + + // Negative complement: no drop fns under `--alloc=gc`. + let ir_gc = lower_workspace_with_alloc(&ws, AllocStrategy::Gc).unwrap(); + assert!( + !ir_gc.contains("@drop_rclist_IntList"), + "gc IR should not declare/define any per-type drop fn. IR was:\n{ir_gc}" + ); + } } diff --git a/examples/rc_list_drop.ail.json b/examples/rc_list_drop.ail.json new file mode 100644 index 0000000..1430303 --- /dev/null +++ b/examples/rc_list_drop.ail.json @@ -0,0 +1,139 @@ +{ + "schema": "ailang/v0", + "name": "rc_list_drop", + "imports": [], + "defs": [ + { + "kind": "type", + "name": "IntList", + "doc": "Recursive Int list. Iter 18c.4 RC fixture: codegen emits `drop_rc_list_drop_IntList` whose `Cons` arm recursively calls itself on the tail field.", + "ctors": [ + { "name": "Nil", "fields": [] }, + { + "name": "Cons", + "fields": [ + { "k": "con", "name": "Int" }, + { "k": "con", "name": "IntList" } + ] + } + ] + }, + { + "kind": "fn", + "name": "sum_list", + "type": { + "k": "fn", + "params": [{ "k": "con", "name": "IntList" }], + "ret": { "k": "con", "name": "Int" }, + "effects": [] + }, + "params": ["xs"], + "doc": "Recursive fold over IntList.", + "body": { + "t": "match", + "scrutinee": { "t": "var", "name": "xs" }, + "arms": [ + { + "pat": { "p": "ctor", "ctor": "Nil", "fields": [] }, + "body": { "t": "lit", "lit": { "kind": "int", "value": 0 } } + }, + { + "pat": { + "p": "ctor", + "ctor": "Cons", + "fields": [ + { "p": "var", "name": "h" }, + { "p": "var", "name": "t" } + ] + }, + "body": { + "t": "app", + "fn": { "t": "var", "name": "+" }, + "args": [ + { "t": "var", "name": "h" }, + { + "t": "app", + "fn": { "t": "var", "name": "sum_list" }, + "args": [{ "t": "var", "name": "t" }] + } + ] + } + } + ] + } + }, + { + "kind": "fn", + "name": "main", + "type": { + "k": "fn", + "params": [], + "ret": { "k": "con", "name": "Unit" }, + "effects": ["IO"] + }, + "params": [], + "doc": "Build a 5-element IntList [1,2,3,4,5] and print its sum (15). Under --alloc=rc the recursive drop_rc_list_drop_IntList fn cascades through the tail at process exit when sum_list returns ownership-implicit (the consume_count of `xs` is 1 so no dec at the outer let; the test's correctness invariant is the byte-identical stdout — leak quantification is 18f's bench).", + "body": { + "t": "let", + "name": "xs", + "value": { + "t": "ctor", + "type": "IntList", + "ctor": "Cons", + "args": [ + { "t": "lit", "lit": { "kind": "int", "value": 1 } }, + { + "t": "ctor", + "type": "IntList", + "ctor": "Cons", + "args": [ + { "t": "lit", "lit": { "kind": "int", "value": 2 } }, + { + "t": "ctor", + "type": "IntList", + "ctor": "Cons", + "args": [ + { "t": "lit", "lit": { "kind": "int", "value": 3 } }, + { + "t": "ctor", + "type": "IntList", + "ctor": "Cons", + "args": [ + { "t": "lit", "lit": { "kind": "int", "value": 4 } }, + { + "t": "ctor", + "type": "IntList", + "ctor": "Cons", + "args": [ + { "t": "lit", "lit": { "kind": "int", "value": 5 } }, + { + "t": "ctor", + "type": "IntList", + "ctor": "Nil", + "args": [] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "body": { + "t": "do", + "op": "io/print_int", + "args": [ + { + "t": "app", + "fn": { "t": "var", "name": "sum_list" }, + "args": [{ "t": "var", "name": "xs" }] + } + ] + } + } + } + ] +} diff --git a/examples/rc_list_drop_borrow.ail.json b/examples/rc_list_drop_borrow.ail.json new file mode 100644 index 0000000..2ab909f --- /dev/null +++ b/examples/rc_list_drop_borrow.ail.json @@ -0,0 +1,113 @@ +{ + "schema": "ailang/v0", + "name": "rc_list_drop_borrow", + "imports": [], + "defs": [ + { + "kind": "type", + "name": "IntList", + "doc": "Recursive Int list. Iter 18c.4 RC fixture: codegen emits a recursive drop fn; this fixture keeps the binder consume_count at 0 so the drop call actually fires at scope close, exercising the recursive cascade at runtime over a 5-element list.", + "ctors": [ + { "name": "Nil", "fields": [] }, + { + "name": "Cons", + "fields": [ + { "k": "con", "name": "Int" }, + { "k": "con", "name": "IntList" } + ] + } + ] + }, + { + "kind": "fn", + "name": "main", + "type": { + "k": "fn", + "params": [], + "ret": { "k": "con", "name": "Unit" }, + "effects": ["IO"] + }, + "params": [], + "doc": "Build a 5-element IntList and print only its head via match. xs has consume_count == 0 (only the match scrutinee, a borrow), so under --alloc=rc the let scope close emits `drop_rc_list_drop_borrow_IntList(xs)` which recursively dec-cascades over the whole 5-cell chain.", + "body": { + "t": "let", + "name": "xs", + "value": { + "t": "ctor", + "type": "IntList", + "ctor": "Cons", + "args": [ + { "t": "lit", "lit": { "kind": "int", "value": 11 } }, + { + "t": "ctor", + "type": "IntList", + "ctor": "Cons", + "args": [ + { "t": "lit", "lit": { "kind": "int", "value": 22 } }, + { + "t": "ctor", + "type": "IntList", + "ctor": "Cons", + "args": [ + { "t": "lit", "lit": { "kind": "int", "value": 33 } }, + { + "t": "ctor", + "type": "IntList", + "ctor": "Cons", + "args": [ + { "t": "lit", "lit": { "kind": "int", "value": 44 } }, + { + "t": "ctor", + "type": "IntList", + "ctor": "Cons", + "args": [ + { "t": "lit", "lit": { "kind": "int", "value": 55 } }, + { + "t": "ctor", + "type": "IntList", + "ctor": "Nil", + "args": [] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "body": { + "t": "match", + "scrutinee": { "t": "var", "name": "xs" }, + "arms": [ + { + "pat": { "p": "ctor", "ctor": "Nil", "fields": [] }, + "body": { + "t": "do", + "op": "io/print_int", + "args": [{ "t": "lit", "lit": { "kind": "int", "value": 0 } }] + } + }, + { + "pat": { + "p": "ctor", + "ctor": "Cons", + "fields": [ + { "p": "var", "name": "h" }, + { "p": "var", "name": "t" } + ] + }, + "body": { + "t": "do", + "op": "io/print_int", + "args": [{ "t": "var", "name": "h" }] + } + } + ] + } + } + } + ] +}