feat(lang): eliminate the Implicit ownership default — totality + the drop-soundness it demasks (#55)

Deletes `ParamMode::Implicit`. `ParamMode` is now `{Own, Borrow}`:
every fn-type slot on every signature carries an explicit `own` or
`borrow`, no defaulted position survives anywhere (model 0008 §2,
spec 0062). The parser rejects a bare fn-type slot; `borrow-return`
and `borrow-over-value` reject at the signature; the corpus is
migrated to minimal-ownership modes (consumed ⇒ own, read-only-heap
⇒ borrow, value ⇒ trivial-own). The documented `Implicit`-ret-mode
leak is fixed: an owned heap return now drops exactly once (live=0,
acceptance criterion 5).

This was the easy half. Removing the default ACTIVATED a family of
drop paths that `Implicit` had silently skipped — the pre-cutover
language was leaking (and in places mis-dropping) here rather than
crashing, because an Implicit scrutinee turned the drop off. Making
the modes explicit (Own) turned those paths on and exposed two
latent-bug clusters, all fixed RED-first as part of this cutover:

Drop-soundness family (four legs):
  A. lit-sub-pattern double-free — the desugar re-matched the same
     owned scrutinee in the lit fall-through; fixed by grouping
     consecutive same-ctor arms into one match (bind fields once),
     in ailang-core desugar.
  B. Cons-husk leak on non-tail arm bodies — the lit-sub-pattern
     desugar rebound the owned scrutinee via `Let $mp = xs`, which
     bumped consume_count and suppressed the existing fn-return
     partial_drop. Fixed by not rebinding a bare-Var scrutinee
     (one husk-freeing mechanism, not two).
  C. polymorphic `drop_<T>` rc_dec'd monomorphised value fields —
     the per-ADT drop fn was emitted once from the polymorphic
     TypeDef, defaulting type-var fields to ptr and rc_dec'ing
     inline Ints (segfault). Fixed with per-monomorph drop
     functions (new ailang-codegen::dropmono): the drop set is
     collected from the lowered MIR, value-type fields are skipped,
     heap fields still freed once; monomorphic-concrete ADTs keep
     their byte-identical un-suffixed drop symbol.
  D. static Str literal passed to an `(own Str)` param — the
     literal lowers to a header-less rodata constant; the callee's
     now-active rc_dec read its length field as a refcount and
     freed a static address (segfault). Fixed with the missing
     fourth StrRep::Static→Heap promotion in lower_to_mir's App arm,
     gated on Own mode (borrow args stay static, no regression).

over-strict-mode lint over-fired: it suggested `(borrow V)` for
value-typed params (which `borrow-over-value` rejects — own is the
only legal mode there) and fired on `(intrinsic)` bodies (whose
consumption the linearity walk cannot observe). Tightened to skip
both; contract 0008 updated to the narrowed firing scope.

Irreversible step — canonical-form hash reset (model 0008 §6,
acceptance criterion 6). Every signature now carries explicit modes,
so the hashable canonical JSON changed for every module. RATIFY:
the corpus-wide hash-pin reset (hash_pin, prelude_module_hash_pin,
mono_hash_stability, eq_ord_e2e, embed_export_hash_stable, the
ct4/iter*/loop_recur schema-extension pins) and the list ir_snapshot
golden were regenerated once, deliberately, as the intended one-time
consequence of removing the mode elision from the canonical form —
not a regression. Each regenerated hash verified deterministic across
two runs.

Also fixes a pre-existing latent failure surfaced by the verification
gate, unrelated to this cutover: the `every_contract_names_a_resolvable_
ratifying_test` resolver (design_index_pin) could not resolve the
" + " dual-link ratifying-test form (`uniqueness.rs + linearity.rs`)
that the #57 audit-close (dfdc65f) introduced — it shipped red on that
commit. Resolver taught the dual-link form, mirroring its sibling.

Verification: cargo test --workspace = 731 passed, 0 failed (twice,
stable); e2e 102 passed, no binary exits non-zero (corpus crash-free);
grep-clean for Implicit/fn_implicit/mode_eq across crates; every drop
fix confirmed via emitted IR + AILANG_RC_STATS balance on the head==K,
head!=K, and Nil paths. Three BLOCKEDs en route (the unsound first
husk-dec attempt, the over-strict derivation premise, the leg-B fix
direction) were each treated as a real design/spec gap and rediagnosed,
not patched over.

Supersedes #54 (return-position-only leak patch). Precondition #57
(linearity hardening) was already met. Spec docs/specs/0062, plan
docs/plans/0121.

closes #55
This commit is contained in:
2026-06-02 00:03:46 +02:00
parent 05c3c018de
commit 76b21c00eb
342 changed files with 3196 additions and 1503 deletions
+199 -75
View File
@@ -27,7 +27,7 @@
use ailang_core::ast::{ParamMode, Type, TypeDef};
use ailang_mir::{Callee, MTerm};
use std::collections::BTreeSet;
use std::collections::{BTreeMap, BTreeSet};
use super::synth::llvm_type;
use super::{AllocStrategy, Emitter, FnSig, Result};
@@ -76,10 +76,44 @@ impl<'a> Emitter<'a> {
/// is known to grow, recursive cascade everywhere else (cheaper
/// IR, no worklist allocation).
pub(crate) fn emit_drop_fn_for_type(&mut self, td: &TypeDef) {
// Monomorphic / intrinsic ADTs: one drop fn, empty subst, no
// suffix — byte-identical to the pre-leg-C emission.
let key = (self.module_name.to_string(), td.name.clone());
if !self.drop_monos.is_suffixed(&key) {
self.emit_drop_fn_for_instantiation(td, &BTreeMap::new(), "");
return;
}
// Polymorphic ADTs: one drop fn per concrete instantiation
// collected workspace-wide (leg C). Each fn substitutes the
// declared type-vars to the instantiation's concrete args so
// the dec-vs-skip decision is made on the *monomorph* field
// type (a value-type field is an inline scalar → skipped; a
// heap field is dec'd via its own per-monomorph drop symbol).
for args in self.drop_monos.instantiations(&key) {
let subst: BTreeMap<String, Type> =
td.vars.iter().cloned().zip(args.iter().cloned()).collect();
let suffix = self
.drop_monos
.suffix_for(&key, &args)
.unwrap_or_default();
self.emit_drop_fn_for_instantiation(td, &subst, &suffix);
}
}
/// emit one recursive `drop_<m>_<T><suffix>` body. `subst` maps the
/// declared type-vars to the concrete instantiation args (empty for
/// monomorphic ADTs); `sym_suffix` is the `__<...>` mono suffix
/// (empty for monomorphic ADTs).
fn emit_drop_fn_for_instantiation(
&mut self,
td: &TypeDef,
subst: &BTreeMap<String, Type>,
sym_suffix: &str,
) {
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(&format!("define void @drop_{m}_{tname}{sym_suffix}(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).
@@ -101,14 +135,16 @@ impl<'a> Emitter<'a> {
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());
for (j, fty_decl) in ctor.fields.iter().enumerate() {
// Substitute the declared field type to its monomorph.
// A value-type field (`Int`/`Bool`/`Float`/`Unit`)
// lowers to a non-`ptr` scalar → skip the dec (it is
// stored inline, NOT a heap pointer — `rc_dec` on it
// would SIGSEGV). A heap field lowers to `ptr` → dec via
// `field_drop_call` on the *concrete* type, so the
// cascade targets the field's own per-monomorph symbol.
let fty = super::subst::apply_subst_to_type(fty_decl, subst);
let lty = llvm_type(&fty).unwrap_or_else(|_| "ptr".into());
if lty != "ptr" {
continue;
}
@@ -124,7 +160,7 @@ impl<'a> Emitter<'a> {
out.push_str(&format!(
" %v{val_id} = load ptr, ptr %a{addr_id}, align 8\n"
));
let drop_call = self.field_drop_call(fty);
let drop_call = self.field_drop_call(&fty);
// Recursive call into the field's drop fn. If the
// field's type is itself `(drop-iterative)`, that drop
// fn is the worklist variant — recursion stops at one
@@ -227,10 +263,36 @@ impl<'a> Emitter<'a> {
/// worklist entry shape. The mono-typed version captures the
/// stack-overflow-on-long-self-chains problem fully.
pub(crate) fn emit_iterative_drop_fn_for_type(&mut self, td: &TypeDef) {
let key = (self.module_name.to_string(), td.name.clone());
if !self.drop_monos.is_suffixed(&key) {
self.emit_iterative_drop_fn_for_instantiation(td, &BTreeMap::new(), "");
return;
}
for args in self.drop_monos.instantiations(&key) {
let subst: BTreeMap<String, Type> =
td.vars.iter().cloned().zip(args.iter().cloned()).collect();
let suffix = self
.drop_monos
.suffix_for(&key, &args)
.unwrap_or_default();
self.emit_iterative_drop_fn_for_instantiation(td, &subst, &suffix);
}
}
/// emit one iterative `drop_<m>_<T><suffix>` body (worklist variant).
/// `subst` / `sym_suffix` carry the leg-C per-monomorph
/// instantiation, identical in role to
/// [`Self::emit_drop_fn_for_instantiation`].
fn emit_iterative_drop_fn_for_instantiation(
&mut self,
td: &TypeDef,
subst: &BTreeMap<String, Type>,
sym_suffix: &str,
) {
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(&format!("define void @drop_{m}_{tname}{sym_suffix}(ptr %p) {{\n"));
out.push_str("entry:\n");
// Null guard — symmetric with the recursive variant. A null
// payload skips worklist allocation entirely.
@@ -265,8 +327,12 @@ impl<'a> Emitter<'a> {
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() {
let lty = llvm_type(fty).unwrap_or_else(|_| "ptr".into());
for (j, fty_decl) in ctor.fields.iter().enumerate() {
// Substitute to the monomorph (same reasoning as the
// recursive variant): a value-type field is inline and
// must not be dec'd/pushed.
let fty = super::subst::apply_subst_to_type(fty_decl, subst);
let lty = llvm_type(&fty).unwrap_or_else(|_| "ptr".into());
if lty != "ptr" {
continue;
}
@@ -281,7 +347,7 @@ impl<'a> Emitter<'a> {
out.push_str(&format!(
" %v{val_id} = load ptr, ptr %a{addr_id}, align 8\n"
));
if self.field_is_same_type(fty, &td.name) {
if self.field_is_same_type(&fty, &td.name) {
// Same-type field: push onto the worklist —
// continues the iterative cascade. Null-guarding
// is handled inside `ailang_drop_worklist_push`
@@ -295,7 +361,7 @@ impl<'a> Emitter<'a> {
// iterative). `field_drop_call` resolves the
// symbol; its null-guard semantics are the same
// as the recursive variant.
let drop_call = self.field_drop_call(fty);
let drop_call = self.field_drop_call(&fty);
out.push_str(&format!(
" call void @{drop_call}(ptr %v{val_id})\n"
));
@@ -374,7 +440,7 @@ impl<'a> Emitter<'a> {
/// those are not user-defined ADTs and have no per-type drop fn.
pub(crate) fn field_drop_call(&self, fty: &Type) -> String {
match fty {
Type::Con { name, .. } => {
Type::Con { name, args } => {
// Built-in pointer-typed cons: Str. No drop fn —
// shallow `ailang_rc_dec` is the right answer.
// Str has two realisations sharing the consumer
@@ -395,19 +461,13 @@ impl<'a> Emitter<'a> {
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)
// Resolve owner + per-monomorph suffix in one place
// (leg C). A polymorphic non-intrinsic ADT instantiation
// (`Box Int`, `Pair Int Int`) gets the same `__<suffix>`
// the emission loop minted for that instantiation;
// monomorphic / intrinsic ADTs keep the un-suffixed
// symbol byte-for-byte.
self.adt_drop_symbol(name, args)
}
// Type::Fn (closure-typed field) → no per-type drop fn
// exists for closures (each closure has its own per-pair
@@ -431,6 +491,46 @@ impl<'a> Emitter<'a> {
}
}
/// resolve the `drop_<owner>_<T>` symbol for an ADT `Type::Con`,
/// applying the leg-C per-monomorph suffix when the resolved ADT is
/// polymorphic + non-intrinsic. The caller has already excluded
/// `Str` and non-`Con` shapes. `name` is the (possibly qualified)
/// type name; `args` are its concrete instantiation arguments.
///
/// The suffix decision lives in [`dropmono::DropAdtMeta`], shared
/// with the emission loop, so a call symbol matches its definition
/// byte-for-byte (a mismatch is an IR link error).
pub(crate) fn adt_drop_symbol(&self, name: &str, args: &[Type]) -> String {
let key = super::dropmono::resolve_adt_key(
name,
self.module_name,
&self.import_map,
);
let base = format!("drop_{owner}_{bare}", owner = key.0, bare = key.1);
match self.drop_monos.suffix_for(&key, args) {
Some(suffix) => format!("{base}{suffix}"),
None => base,
}
}
/// resolve the `partial_drop_<owner>_<T>` symbol for an ADT
/// `Type::Con`, applying the leg-C per-monomorph suffix. Parallel
/// to [`Self::adt_drop_symbol`]. The caller has already excluded
/// `Str` / non-`Con` shapes.
pub(crate) fn adt_partial_drop_symbol(&self, name: &str, args: &[Type]) -> String {
let key = super::dropmono::resolve_adt_key(
name,
self.module_name,
&self.import_map,
);
let base =
format!("partial_drop_{owner}_{bare}", owner = key.0, bare = key.1);
match self.drop_monos.suffix_for(&key, args) {
Some(suffix) => format!("{base}{suffix}"),
None => base,
}
}
/// predicate the `Term::Let` lowering uses to decide
/// whether a let-binder owns a fresh RC-heap allocation that
/// codegen should `dec` at scope close.
@@ -448,8 +548,8 @@ impl<'a> Emitter<'a> {
/// fn-type carries `ret_mode == Own`. The mode contract states that
/// the callee hands the returned cell's ownership to the caller's
/// frame; the let-scope close is the right place for the caller's
/// dec. Calls whose callee is `Borrow`/`Implicit`-returning are
/// still not trackable — they don't carry that signal.
/// dec. Calls whose callee is `Borrow`-returning are still not
/// trackable — a borrow-return is a view, not an owned ref.
///
/// Other value shapes (vars, literals, matches, …) return `false`
/// here. A `Term::Var` returning an RC-allocated box would already
@@ -467,11 +567,10 @@ impl<'a> Emitter<'a> {
MTerm::App { callee, .. } => {
// a call whose callee carries
// `ret_mode == Own` hands a fresh heap allocation to
// the caller's frame. Trackable. `Borrow` and
// `Implicit` ret-modes do not carry that signal —
// returning by Borrow is a view into the callee's
// owned data (caller does not own it), and Implicit
// is the back-compat lane that 18c.3's debt covers.
// the caller's frame. Trackable. A `Borrow` ret-mode
// does not carry that signal — returning by Borrow is a
// view into the callee's owned data (caller does not
// own it).
self.synth_callee_ret_mode(callee)
.map(|m| matches!(m, ParamMode::Own))
.unwrap_or(false)
@@ -538,15 +637,16 @@ impl<'a> Emitter<'a> {
pub(crate) fn drop_symbol_for_binder(&self, value: &MTerm, val_ssa: &str) -> String {
match value {
MTerm::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)
// The binder's instantiated result type carries the
// concrete args (`Box Int` → args `[Int]`); read them off
// `value.ty()` so the leg-C per-monomorph suffix matches
// the emitted `drop_<m>_Box__Int`. `type_name` alone
// would lose the instantiation.
let args = match value.ty() {
Type::Con { args, .. } => args,
_ => Vec::new(),
};
self.adt_drop_symbol(type_name, &args)
}
MTerm::Lam { .. } => self
.closure_drops
@@ -563,7 +663,7 @@ impl<'a> Emitter<'a> {
// var on an as-yet-unmonomorphised polymorphic call —
// the monomorphised copies will resolve correctly).
MTerm::App { .. } | MTerm::Loop { .. } => {
if let Type::Con { name, .. } = value.ty() {
if let Type::Con { name, args } = value.ty() {
// Symmetric to `field_drop_call`'s Str arm: Str is a
// built-in pointer type with no per-type drop fn. Both
// heap-Str (rc_header at payload-8) and static-Str
@@ -572,15 +672,7 @@ impl<'a> Emitter<'a> {
if name == "Str" {
return "ailang_rc_dec".to_string();
}
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}");
}
return format!("drop_{prefix}_{suffix}");
}
return format!("drop_{m}_{name}", m = self.module_name);
return self.adt_drop_symbol(&name, &args);
}
"ailang_rc_dec".to_string()
}
@@ -649,11 +741,36 @@ impl<'a> Emitter<'a> {
/// cascade points — the unmoved fields go through their own
/// `drop_<m>_<F>` which itself decides recursive vs iterative).
pub(crate) fn emit_partial_drop_fn_for_type(&mut self, td: &TypeDef) {
let key = (self.module_name.to_string(), td.name.clone());
if !self.drop_monos.is_suffixed(&key) {
self.emit_partial_drop_fn_for_instantiation(td, &BTreeMap::new(), "");
return;
}
for args in self.drop_monos.instantiations(&key) {
let subst: BTreeMap<String, Type> =
td.vars.iter().cloned().zip(args.iter().cloned()).collect();
let suffix = self
.drop_monos
.suffix_for(&key, &args)
.unwrap_or_default();
self.emit_partial_drop_fn_for_instantiation(td, &subst, &suffix);
}
}
/// emit one `partial_drop_<m>_<T><suffix>` body. `subst` /
/// `sym_suffix` carry the leg-C per-monomorph instantiation,
/// identical in role to [`Self::emit_drop_fn_for_instantiation`].
fn emit_partial_drop_fn_for_instantiation(
&mut self,
td: &TypeDef,
subst: &BTreeMap<String, Type>,
sym_suffix: &str,
) {
let m = self.module_name;
let tname = &td.name;
let mut out = String::new();
out.push_str(&format!(
"define void @partial_drop_{m}_{tname}(ptr %p, i64 %mask) {{\n"
"define void @partial_drop_{m}_{tname}{sym_suffix}(ptr %p, i64 %mask) {{\n"
));
out.push_str("entry:\n");
out.push_str(" %is_null = icmp eq ptr %p, null\n");
@@ -671,8 +788,9 @@ impl<'a> Emitter<'a> {
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() {
let lty = llvm_type(fty).unwrap_or_else(|_| "ptr".into());
for (j, fty_decl) in ctor.fields.iter().enumerate() {
let fty = super::subst::apply_subst_to_type(fty_decl, subst);
let lty = llvm_type(&fty).unwrap_or_else(|_| "ptr".into());
if lty != "ptr" {
continue;
}
@@ -702,7 +820,7 @@ impl<'a> Emitter<'a> {
out.push_str(&format!(
" %v{val_id} = load ptr, ptr %a{addr_id}, align 8\n"
));
let drop_call = self.field_drop_call(fty);
let drop_call = self.field_drop_call(&fty);
out.push_str(&format!(
" call void @{drop_call}(ptr %v{val_id})\n"
));
@@ -788,21 +906,11 @@ impl<'a> Emitter<'a> {
/// populated for those shapes anyway).
pub(crate) fn partial_drop_symbol_for_type(&self, ty: &Type) -> Option<String> {
match ty {
Type::Con { name, .. } => {
Type::Con { name, args } => {
if name == "Str" {
return None;
}
if name.matches('.').count() == 1 {
let (prefix, suffix) = name.split_once('.').expect("checked");
if let Some(target) = self.import_map.get(prefix) {
return Some(format!("partial_drop_{target}_{suffix}"));
}
return Some(format!("partial_drop_{prefix}_{suffix}"));
}
Some(format!(
"partial_drop_{m}_{name}",
m = self.module_name
))
Some(self.adt_partial_drop_symbol(name, args))
}
_ => None,
}
@@ -879,12 +987,28 @@ impl<'a> Emitter<'a> {
}
};
let cref = self.lookup_ctor_by_type(type_name, ctor_name)?;
// leg C: substitute the declared field types to the binder's
// concrete instantiation (read off `value.ty()`'s args against
// the ADT's declared type-vars), so a value-type field is
// recognised as inline (non-`ptr`, skipped) and a heap field
// routes through its own per-monomorph drop symbol. For a
// monomorphic ADT the subst is empty and this is a no-op.
let inst_subst: BTreeMap<String, Type> =
match (&cref.type_vars, value.ty()) {
(vars, Type::Con { args, .. })
if !vars.is_empty() && vars.len() == args.len() =>
{
vars.iter().cloned().zip(args.into_iter()).collect()
}
_ => BTreeMap::new(),
};
// Per-field dec for non-moved pointer-typed slots. ail_fields
// are the AILang-level field types; field_drop_call resolves
// them to either `drop_<owner>_<T>` (ADTs cascade) or
// `ailang_rc_dec` (Str / closures / vars).
for (idx, fty_ail) in cref.ail_fields.iter().enumerate() {
let lty = llvm_type(fty_ail).unwrap_or_else(|_| "ptr".into());
for (idx, fty_decl) in cref.ail_fields.iter().enumerate() {
let fty_ail = super::subst::apply_subst_to_type(fty_decl, &inst_subst);
let lty = llvm_type(&fty_ail).unwrap_or_else(|_| "ptr".into());
if lty != "ptr" {
continue;
}
@@ -900,7 +1024,7 @@ impl<'a> Emitter<'a> {
self.body.push_str(&format!(
" {v} = load ptr, ptr {addr}, align 8\n"
));
let drop_call = self.field_drop_call(fty_ail);
let drop_call = self.field_drop_call(&fty_ail);
self.body.push_str(&format!(
" call void @{drop_call}(ptr {v})\n"
));