feat(obs): precise return-borrow summaries and owned-local escape check

Each `-> &T` / `-> &mut T` function now records which parameter it borrows (a parameter index, `self`, or unknown) in a return-borrow registry, keyed by symbol and by `Class.method`. A reference initialized from a call registers only the summarized argument (the receiver for methods), and `return f(...)` checks only that argument; callees without a summary keep the conservative "all borrowed arguments" fallback. Borrowing a compiler-owned (`own`) local cannot escape the frame any more: `_OwnerOutlivesFrame` rejects `own`/slife bindings and only accepts reference parameters, `self`, statics, and non-owned heap pointers, so `return id(&ownLocal)` and `return refBinding` are rejected while `return id(&heapPtr)` still passes. The LSP mirrors the owned-local rule.
This commit is contained in:
TermiNexus committed 2026-11-05 17:55:54 +08:00
1 parent d4c1e3f535
commit b78cddc401
5 files changed
+188 -19

No files matched your search

+100 -17
View File
@@ -1768,16 +1768,23 @@ novirtual class AssignGenMixin:
self.Gen.TypeGenErr.fatal_error(value, "cannot return this call from a reference-returning function: the callee's return type cannot be verified (no resolvable `.vpi` entry)", "LifetimeError")
return 0
# The callee returns a reference to one of its borrowed arguments: that argument must
# outlive this frame (a reference parameter / `self` / a heap-owned value).
# outlive this frame (a reference parameter / `self` / a static or non-owned heap
# value). A precise summary checks only that argument; otherwise check them all.
cargs: list[ast.AST*]* = (ast.Call*)(value).args
if cargs is not None:
rb_k: int = self.Gen.CallGenBase.ret_borrow_of_call(trans, ast.Call*(value))
if rb_k >= 0 and cargs is not None and rb_k < int(len(cargs)):
arg_k: ast.AST* = cargs.get(sizet(rb_k))
if self._BorrowSourceReturnOk(trans, fd, arg_k) == 0:
self.Gen.TypeGenErr.fatal_error(arg_k, f"cannot return this call: argument {rb_k + 1} borrows a value that dies with this frame; pass a reference parameter, `self`, or a static/non-owned heap value", "LifetimeError")
return 0
elif rb_k != -2 and cargs is not None:
ai: int = 0
while ai < int(len(cargs)):
arg: ast.AST* = cargs.get(sizet(ai))
ai += 1
ok_arg: int = self._BorrowSourceReturnOk(trans, fd, arg)
if ok_arg == 0:
self.Gen.TypeGenErr.fatal_error(arg, f"cannot return this call: argument {ai} borrows a value that dies with this frame; pass a reference parameter, `self`, or a heap-owned value", "LifetimeError")
self.Gen.TypeGenErr.fatal_error(arg, f"cannot return this call: argument {ai} borrows a value that dies with this frame; pass a reference parameter, `self`, or a static/non-owned heap value", "LifetimeError")
return 0
return 0
if value.kind() == ast.ASTKind.Name:
@@ -1808,8 +1815,9 @@ novirtual class AssignGenMixin:
if par_o is not None and self.Gen.TypeGenResolve.ref_kind(par_o.annotation) != 0: return 0
else:
slot_o: mlirlite.Value* = self.Gen.VarGen.lookup_var(trans.SymTab, own_r)
if slot_o is not None and (self.Gen.VarGen._SlifeOwned(slot_o) != 0 \
or self.Gen.VarGen.get_var_life(trans.SymTab, own_r) == GenTypes.LIFE_HEAP):
if slot_o is not None and self.Gen.VarGen._SlifeOwned(slot_o) == 0 \
and self.Gen.VarGen.get_var_life(trans.SymTab, own_r) == GenTypes.LIFE_HEAP:
# A plain (non-owned) heap object is not freed when this frame ends.
return 0
self.Gen.TypeGenErr.fatal_error(value, f"cannot return `{nm.id}`: it lives in this frame, and a reference result may only borrow a reference parameter or `self` (borrow elision)", "LifetimeError")
return 0
@@ -1831,6 +1839,17 @@ novirtual class AssignGenMixin:
self.Gen.TypeGenErr.fatal_error(value, "cannot return this expression from a reference-returning function: a reference result must borrow a reference parameter, `self`, or another reference", "LifetimeError")
return 0
# _BorrowOwnerForArg - borrowed owner of a call argument (`&x`, `&*p`, or a reference
# binding); None when the argument is not a borrow.
def _BorrowOwnerForArg(self, trans: HT.Translator*, arg: ast.AST*) -> str:
if arg is None: return None
ob: str = self._BorrowOwnerName(arg)
if ob is None and arg.kind() == ast.ASTKind.Name:
an: ast.Name* = ast.Name*(arg)
if an.id is not None and self.Gen.VarGen.var_ref_kind(trans.SymTab, an.id) != 0:
ob = self.Gen.StmtGen._BorrowOwnerOf(trans, an.id)
return ob
# _BorrowSourceReturnOk - 1 = a borrow argument stays valid past this frame, 0 = it does not,
# -1 = the expression is not a borrow (nothing to check).
def _BorrowSourceReturnOk(self, trans: HT.Translator*, fd: ast.FunctionDef*, expr: ast.AST*) -> int:
@@ -1881,9 +1900,11 @@ novirtual class AssignGenMixin:
if par is not None and self.Gen.TypeGenResolve.ref_kind(par.annotation) != 0: return 1
return 0
slot: mlirlite.Value* = self.Gen.VarGen.lookup_var(trans.SymTab, name)
if slot is not None and (self.Gen.VarGen._SlifeOwned(slot) != 0 \
or self.Gen.VarGen.get_var_life(trans.SymTab, name) == GenTypes.LIFE_HEAP):
return 1
if slot is not None:
# An `own`/slife binding is released when this frame ends: borrowing it must not
# escape (the pointer would dangle after the free). A non-owned heap pointer survives.
if self.Gen.VarGen._SlifeOwned(slot) != 0: return 0
if self.Gen.VarGen.get_var_life(trans.SymTab, name) == GenTypes.LIFE_HEAP: return 1
return 0
# _IsStaticName - 1 for module globals / nonlocal storage (lifetime = whole program).
@@ -1894,6 +1915,59 @@ novirtual class AssignGenMixin:
if GenApi.is_nonlocal_name(trans, name) != 0: return 1
return 0
# ret_borrow_index - which parameter a `-> &T` function returns a borrow of:
# >=0 parameter index (including `self` for methods), -2 = `self`, -3 = unknown/mixed,
# -1 = the function does not return a reference.
def ret_borrow_index(self, fd: ast.FunctionDef*) -> int:
if fd is None or self.Gen.TypeGenResolve.ref_kind(fd.returns) == 0: return -1
found: int = -3
cnt: int = 0
ok: int = self._ScanRetBorrows(fd, fd.children, &found, &cnt, 0)
if ok == 0: return -3
if cnt == 0: return -3
return found
# _ScanRetBorrows - walk a statement list (and nested blocks) collecting returned borrows.
def _ScanRetBorrows(self, fd: ast.FunctionDef*, stmts: list[ast.AST*]*, found: int*,
cnt: int*, depth: int) -> int:
if stmts is None or depth > 12: return 1
i: int = 0
while i < int(len(stmts)):
st: ast.AST* = stmts.get(sizet(i))
i += 1
if st is None: continue
if st.kind() == ast.ASTKind.Return:
rt: ast.Return* = ast.Return*(st)
idx: int = self._ReturnBorrowIndex(fd, rt.value)
if idx == -3: return 0
if cnt[0] == 0: found[0] = idx
elif found[0] != idx: return 0
cnt[0] += 1
continue
if st.children is not None:
if self._ScanRetBorrows(fd, st.children, found, cnt, depth + 1) == 0: return 0
return 1
# _ReturnBorrowIndex - parameter borrowed by one return value (-3 = cannot tell).
def _ReturnBorrowIndex(self, fd: ast.FunctionDef*, value: ast.AST*) -> int:
if value is None: return -3
if value.kind() == ast.ASTKind.Name:
nm: ast.Name* = ast.Name*(value)
if nm.id is None: return -3
if string.strcmp(nm.id, "self") == 0: return -2
pi: int = self._ParamIndexByName(fd, nm.id)
if pi >= 0:
par: ast.Arg* = self._ParamAt(fd, pi)
if par is not None and self.Gen.TypeGenResolve.ref_kind(par.annotation) != 0: return pi
return -3
if value.kind() == ast.ASTKind.Attribute:
at: ast.Attribute* = ast.Attribute*(value)
if at.value is not None and at.value.kind() == ast.ASTKind.Name:
bn: ast.Name* = ast.Name*(at.value)
if bn.id is not None and string.strcmp(bn.id, "self") == 0: return -2
return -3
return -3
# _ParamAt - parameter node at a 0-based position (None when out of range).
def _ParamAt(self, fd: ast.FunctionDef*, idx: int) -> ast.Arg*:
if fd is None or idx < 0: return None
@@ -3024,22 +3098,31 @@ novirtual class AssignGenMixin:
if o_ref is not None: owner_b = o_ref
if owner_b is not None and src_rk != 0:
self.Gen.StmtGen._BorrowAdd(trans, owner_b, nm.id, src_rk, aa.value)
# A call returning `&T` borrows one of its arguments; until a precise
# return-borrow summary exists, every borrowed argument is treated as borrowed
# by the result (conservative: `del` of any of them is blocked).
# A call returning `&T` borrows one of its arguments. When the callee has a
# return-borrow summary only that argument is registered; otherwise every
# borrowed argument is (conservative).
if aa.value is not None and aa.value.kind() == ast.ASTKind.Call \
and self.Gen.TypeGenResolve.ref_expr_kind(aa.value) == 0:
c_args: list[ast.AST*]* = (ast.Call*)(aa.value).args
if c_args is not None:
rb_k: int = self.Gen.CallGenBase.ret_borrow_of_call(trans, ast.Call*(aa.value))
if rb_k >= 0 and c_args is not None and rb_k < int(len(c_args)):
arg_k: ast.AST* = c_args.get(sizet(rb_k))
ob_k: str = self._BorrowOwnerForArg(trans, arg_k)
if ob_k is not None:
self.Gen.StmtGen._BorrowAdd(trans, ob_k, nm.id, src_rk, arg_k)
elif rb_k == -2:
# The result borrows the receiver: `c.get(...)` -> `c`.
f_k: ast.AST* = (ast.Call*)(aa.value).func
if f_k is not None and f_k.kind() == ast.ASTKind.Attribute:
ob_r: str = self._BorrowOwnerName((ast.Attribute*)(f_k).value)
if ob_r is not None:
self.Gen.StmtGen._BorrowAdd(trans, ob_r, nm.id, src_rk, aa.value)
elif c_args is not None:
ai_b: int = 0
while ai_b < int(len(c_args)):
arg_b: ast.AST* = c_args.get(sizet(ai_b))
ai_b += 1
ob: str = self._BorrowOwnerName(arg_b)
if ob is None and arg_b is not None and arg_b.kind() == ast.ASTKind.Name:
an_b: ast.Name* = ast.Name*(arg_b)
if an_b.id is not None and self.Gen.VarGen.var_ref_kind(trans.SymTab, an_b.id) != 0:
ob = self.Gen.StmtGen._BorrowOwnerOf(trans, an_b.id)
ob: str = self._BorrowOwnerForArg(trans, arg_b)
if ob is not None:
self.Gen.StmtGen._BorrowAdd(trans, ob, nm.id, src_rk, arg_b)
# A call returning `own T` transfers ownership to this binding.
+69
View File
@@ -114,6 +114,11 @@ novirtual class CallGenBase:
# byte-per-parameter '0' (none) / '1' (read-only) / '2' (mutable).
RefParams: hashtable.HashTable* = None
# --- return-borrow summary (`-> &T` returning a borrow of which parameter):
# key = symbol name or "Class.method" -> decimal code (>=0 param index incl. self,
# -2 = borrows `self`, -3 = unknown/mixed). Missing = no summary.
RetBorrows: hashtable.HashTable* = None
def InitCallGen(self) -> int:
"""Initialize the call mixin fields (inlined by subclass constructors since NoVTable does not forward inherited symbols)"""
if self is None: return 1
@@ -225,6 +230,70 @@ novirtual class CallGenBase:
if i != idx: return 0
return int(kinds[idx]) - 48
# _register_ret_borrow - store a function's return-borrow code (PermPool-persistent).
def _register_ret_borrow(self, key: str, code: int) -> int:
if key is None: return 0
perm: memhub.MemBuddy* = CompilerFactory.factory.PermPool
if perm is None: return 0
if self.RetBorrows is None:
self.RetBorrows = hashtable.HashTable(perm)
if self.RetBorrows is None: return 0
k: char* = perm.alloc(string.strlen(key) + 1)
if k is None: return 0
string.strcpy(k, key)
v: char* = perm.alloc(16)
if v is None: return 0
viperlib.snprintf(v, 16, "%d", code)
self.RetBorrows[k] = v
return 1
# _ret_borrow_code - stored return-borrow code for a key (-1 when absent).
def _ret_borrow_code(self, key: str) -> int:
if self.RetBorrows is None or key is None: return -1
v: rawptr = self.RetBorrows.__getitem__(key)
if v is None: return -1
s: str = rawptr(v)
if s is None: return -1
sign: int = 1
p: str = s
if p[0] == '-':
sign = -1
p += 1
n: int = 0
while p[0] != 0:
c: int = int(p[0])
if c < 48 or c > 57: break
n = n * 10 + (c - 48)
p += 1
return sign * n
# ret_borrow_of_call - which argument a reference result borrows:
# >=0 argument index (receiver excluded), -2 = borrows the receiver/`self`,
# -1 = no summary (caller should fall back to a conservative rule).
def ret_borrow_of_call(self, trans: HT.Translator*, call: ast.Call*) -> int:
if trans is None or call is None or call.func is None: return -1
f: ast.AST* = call.func
if f.kind() == ast.ASTKind.Name:
fname: str = (ast.Name*)(f).id
return self._ret_borrow_code(fname)
if f.kind() == ast.ASTKind.Attribute:
at: ast.Attribute* = ast.Attribute*(f)
if at.attr is None or at.value is None: return -1
if at.value.kind() != ast.ASTKind.Name: return -1
cls: str = self._receiver_class(trans, at.value)
if cls is None: return -1
n_key: sizet = string.strlen(cls) + string.strlen(at.attr) + 2
buf: str = trans.Pool.alloc(n_key)
if buf is None: return -1
viperlib.snprintf(buf, n_key, "%s.%s", cls, at.attr)
code: int = self._ret_borrow_code(buf)
if code == -2: return -2
if code >= 0:
# Method summary indexes include `self` at 0; call arguments exclude it.
return code - 1 if code > 0 else 0
return -1
return -1
# _register_nullable_params - store per-parameter nullability flags for a callee symbol name.
def _register_nullable_params(self, name: char*, flags: char*, count: int) -> int:
"""Store per-parameter '0'/'1' nullable flags (PermPool-persistent) keyed by callee symbol name."""
+8
View File
@@ -776,6 +776,14 @@ novirtual class ClassGenVTable(ClassGenBase.ClassGenBase):
rc_m += 1
ref_buf_m[rc_m] = char(0)
self.Gen.CallGenBase._register_ref_params(mangled_name, ref_buf_m, rc_m)
# Return-borrow summary keyed by `Class.method` (index includes `self`).
rb_m: int = self.Gen.AssignGen.ret_borrow_index(fd)
if rb_m != -1 and class_name is not None and method_name is not None:
rb_key: sizet = string.strlen(class_name) + string.strlen(method_name) + 2
rb_buf: str = pool.alloc(rb_key)
if rb_buf is not None:
viperlib.snprintf(rb_buf, rb_key, "%s.%s", class_name, method_name)
self.Gen.CallGenBase._register_ret_borrow(rb_buf, rb_m)
# *args variadic: set IsVarArg=1 so the signature carries the ... suffix (the body
# pipeline allocates the va_list buffer, same as a module-level function).
+6
View File
@@ -107,6 +107,12 @@ novirtual class FuncGenDef(CallGenBase.CallGenBase):
if fd.name is not None and string.strcmp(fd.name, name) != 0:
self.Gen.CallGenBase._register_ref_params(fd.name, ref_buf, rc_n)
stdlib.free(ref_buf)
# Return-borrow summary: which parameter a `-> &T` result borrows.
rb: int = self.Gen.AssignGen.ret_borrow_index(fd)
if rb != -1:
self.Gen.CallGenBase._register_ret_borrow(name, rb)
if fd.name is not None and string.strcmp(fd.name, name) != 0:
self.Gen.CallGenBase._register_ret_borrow(fd.name, rb)
return 1
# ============================================================
+5 -2
View File
@@ -1703,8 +1703,11 @@ def _CheckRefReturn(pool: memhub.MemManager*, doc: LspDocuments.LspDocument*,
if uo.op != ast.OpKind.AddrOf and uo.op != ast.OpKind.AddrOfMut: continue
if uo.operand is None or uo.operand.kind() != ast.ASTKind.Name: continue
det_a: str = _SymDetailAt(pool, a, uo.operand)
if _RefDetail(det_a) != 0 or _DetailIsOwn(det_a) != 0: continue
if det_a is not None and string.strchr(det_a, '*') is not None: continue
if _RefDetail(det_a) != 0: continue
# A non-owned pointer (e.g. `new heap hlife`) survives the frame; an `own`
# binding is released at exit, so a borrow of it must not escape.
if det_a is not None and string.strchr(det_a, '*') is not None \
and _DetailIsOwn(det_a) == 0: continue
_EmitTypeError(pool, doc, arg,
f"cannot return this call: argument {ai} borrows a value that dies with this frame; pass a reference parameter, `self`, or a heap-owned value", errors)
return