From 1dfecdd3d518665a1f50c5315d538902db65aef0 Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Sun, 24 May 2026 18:17:35 +0300 Subject: [PATCH] =?UTF-8?q?v2:=20escape=20analysis=20=E2=80=94=20follow=20?= =?UTF-8?q?param=20aliases=20in=20pass-through=20prescan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inter-procedural escape previously only recognised callees whose body returned a parameter literally: fn id[^a](x &^a T) &^a T { return x } Real code often introduces a trivial local alias first, which slipped through: fn id[^a](x &^a T) &^a T { y := x // alias return y // ← was NOT classified as pass-through } The prescan now seeds an alias table with the params themselves, then walks `:=` statements in declaration order and forwards `name -> param_idx` whenever the RHS is a known param-aliased name (or `&` of one). Transitive chains (`b := a; c := b; return c`) are picked up because each alias is recorded before later statements scan it. `mut y := param` is handled via the same LHS unwrapper the locals collector already uses. Bindings to non-aliased RHS (`y := make_bar()`) are deliberately not recorded, so a fn that returns a freshly-built value is still NOT classified as pass-through — covered by a control test. --- vlib/v2/types/checker_escape.v | 94 ++++++++++++++++++++----- vlib/v2/types/checker_escape_test.v | 105 ++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+), 18 deletions(-) diff --git a/vlib/v2/types/checker_escape.v b/vlib/v2/types/checker_escape.v index f9374d0cd..9f606e415 100644 --- a/vlib/v2/types/checker_escape.v +++ b/vlib/v2/types/checker_escape.v @@ -23,7 +23,9 @@ // — closure-capture escape via store/push // * `return passthrough(&local)` — inter-procedural escape via a fn that // returns one of its parameters (`fn passthrough(x &^a T) &^a T { return x }`). -// Also fires for the field-store and array-push forms. +// Also fires for the field-store and array-push forms. The pre-pass +// follows one or more levels of `:=` aliasing inside the callee, so +// `fn f(x &^a T) &^a T { y := x; return y }` is recognised too. // // For closure-capture escape, `[&x]` and `[mut x]` are treated as borrows // (matching the ownership-checker convention); `[x]` is by-value and never @@ -92,6 +94,12 @@ fn (mut c Checker) escape_prescan_passthrough_fns(files []ast.File) { // caller, so a `&local` arg still escapes). Multiple branches that return // different params (`if cond { return a } else { return b }`) yield both // indices. +// +// Also recognises one level of local aliasing: `y := param; return y` and +// `y := ¶m; return y` (or `mut y := ...`) — the alias forwards the same +// borrow, so the call site must still be flagged. Transitive chains +// (`b := a; c := b; return c`) are followed because the alias table is +// populated in declaration order before the return scan. fn escape_collect_returned_param_indices(decl ast.FnDecl) []int { mut param_names := map[string]int{} for i, param in decl.typ.params { @@ -102,8 +110,12 @@ fn escape_collect_returned_param_indices(decl ast.FnDecl) []int { if param_names.len == 0 { return []int{} } + // Seed the alias table with the params themselves so `return param` and + // `return alias_of_param` go through the same lookup path. + mut name_to_idx := param_names.clone() + escape_collect_param_aliases_stmts(decl.stmts, mut name_to_idx) mut out := map[int]bool{} - escape_collect_returned_param_indices_stmts(decl.stmts, param_names, mut out) + escape_collect_returned_param_indices_stmts(decl.stmts, name_to_idx, mut out) mut result := []int{} for idx, _ in out { result << idx @@ -111,60 +123,106 @@ fn escape_collect_returned_param_indices(decl ast.FnDecl) []int { return result } -fn escape_collect_returned_param_indices_stmts(stmts []ast.Stmt, param_names map[string]int, mut out map[int]bool) { +// escape_collect_param_aliases_stmts walks `:=` statements in declaration +// order and forwards `name -> param_idx` whenever the RHS is a known +// param-aliased name (or a `&` of one). Nested blocks are descended too: +// inside the same fn body, an alias declared in any block can still feed a +// later `return alias` at any depth. Statements that aren't decl-assigns are +// ignored — re-assignment can change a binding's contents, but it can't +// retroactively make a non-aliased binding pass-through. +fn escape_collect_param_aliases_stmts(stmts []ast.Stmt, mut name_to_idx map[string]int) { + for stmt in stmts { + escape_collect_param_aliases_stmt(stmt, mut name_to_idx) + } +} + +fn escape_collect_param_aliases_stmt(stmt ast.Stmt, mut name_to_idx map[string]int) { + match stmt { + ast.AssignStmt { + if stmt.op == .decl_assign { + for i, lhs in stmt.lhs { + if i >= stmt.rhs.len { + continue + } + lhs_name := escape_lhs_decl_name(lhs) + if lhs_name.len == 0 { + continue + } + rhs_name := escape_returned_param_name(stmt.rhs[i]) or { continue } + if idx := name_to_idx[rhs_name] { + name_to_idx[lhs_name] = idx + } + } + } + } + ast.BlockStmt { + escape_collect_param_aliases_stmts(stmt.stmts, mut name_to_idx) + } + ast.DeferStmt { + escape_collect_param_aliases_stmts(stmt.stmts, mut name_to_idx) + } + ast.ForStmt { + escape_collect_param_aliases_stmts(stmt.stmts, mut name_to_idx) + } + else {} + } +} + +fn escape_collect_returned_param_indices_stmts(stmts []ast.Stmt, name_to_idx map[string]int, mut out map[int]bool) { for stmt in stmts { - escape_collect_returned_param_indices_stmt(stmt, param_names, mut out) + escape_collect_returned_param_indices_stmt(stmt, name_to_idx, mut out) } } -fn escape_collect_returned_param_indices_stmt(stmt ast.Stmt, param_names map[string]int, mut out map[int]bool) { +fn escape_collect_returned_param_indices_stmt(stmt ast.Stmt, name_to_idx map[string]int, mut out map[int]bool) { match stmt { ast.ReturnStmt { for expr in stmt.exprs { if name := escape_returned_param_name(expr) { - if name in param_names { - out[param_names[name]] = true + if name in name_to_idx { + out[name_to_idx[name]] = true } } } } ast.BlockStmt { - escape_collect_returned_param_indices_stmts(stmt.stmts, param_names, mut out) + escape_collect_returned_param_indices_stmts(stmt.stmts, name_to_idx, mut out) } ast.DeferStmt { - escape_collect_returned_param_indices_stmts(stmt.stmts, param_names, mut out) + escape_collect_returned_param_indices_stmts(stmt.stmts, name_to_idx, mut out) } ast.ForStmt { - escape_collect_returned_param_indices_stmts(stmt.stmts, param_names, mut out) + escape_collect_returned_param_indices_stmts(stmt.stmts, name_to_idx, mut out) } ast.ExprStmt { - escape_collect_returned_param_indices_expr(stmt.expr, param_names, mut out) + escape_collect_returned_param_indices_expr(stmt.expr, name_to_idx, mut out) } else {} } } -fn escape_collect_returned_param_indices_expr(expr ast.Expr, param_names map[string]int, mut out map[int]bool) { +fn escape_collect_returned_param_indices_expr(expr ast.Expr, name_to_idx map[string]int, mut out map[int]bool) { match expr { ast.IfExpr { - escape_collect_returned_param_indices_stmts(expr.stmts, param_names, mut out) - escape_collect_returned_param_indices_expr(expr.else_expr, param_names, mut out) + escape_collect_returned_param_indices_stmts(expr.stmts, name_to_idx, mut out) + escape_collect_returned_param_indices_expr(expr.else_expr, name_to_idx, mut out) } ast.MatchExpr { for br in expr.branches { - escape_collect_returned_param_indices_stmts(br.stmts, param_names, mut out) + escape_collect_returned_param_indices_stmts(br.stmts, name_to_idx, mut out) } } ast.LockExpr { - escape_collect_returned_param_indices_stmts(expr.stmts, param_names, mut out) + escape_collect_returned_param_indices_stmts(expr.stmts, name_to_idx, mut out) } else {} } } -// escape_returned_param_name unwraps `param` and `¶m` (one `&` layer) and +// escape_returned_param_name unwraps `name` and `&name` (one `&` layer) and // returns the Ident name. Used by the pass-through prescan to recognise both -// `return x` and `return &x` as pass-through shapes. +// `return x` and `return &x` as pass-through shapes, and the alias pass to +// chain `y := &x` through to the same lookup. fn escape_returned_param_name(expr ast.Expr) ?string { if expr is ast.Ident { return expr.name diff --git a/vlib/v2/types/checker_escape_test.v b/vlib/v2/types/checker_escape_test.v index fe7e12708..c34d9e458 100644 --- a/vlib/v2/types/checker_escape_test.v +++ b/vlib/v2/types/checker_escape_test.v @@ -591,6 +591,111 @@ fn main() {} assert exit_code == 0, '`return make_copy(&local)` should be allowed (non-pass-through callee)' } +fn test_escape_passthrough_alias_local_errors() { + // Pass-through fn aliases its param to a local before returning. The + // pre-pass forwards `name := param` so this is still recognised. + code := ' +struct Bar { + v int +} + +fn alias_passthrough[^a](p &^a Bar) &^a Bar { + y := p + return y +} + +fn dangling[^a](caller &^a Bar) &^a Bar { + local := Bar{v: 1} + return alias_passthrough(&local) +} + +fn main() {} +' + exit_code, output := run_escape_check(code) + assert exit_code != 0, 'returning alias_passthrough(&local) should fail (aliased param)' + assert output.contains('passed through `alias_passthrough`'), 'got: ${output}' +} + +fn test_escape_passthrough_alias_chain_errors() { + // Transitive aliases (`b := a; c := b; return c`) follow through too, + // since the alias table is populated in declaration order. + code := ' +struct Bar { + v int +} + +fn chain_passthrough[^a](p &^a Bar) &^a Bar { + a := p + b := a + return b +} + +fn dangling[^a](caller &^a Bar) &^a Bar { + local := Bar{v: 1} + return chain_passthrough(&local) +} + +fn main() {} +' + exit_code, output := run_escape_check(code) + assert exit_code != 0, 'returning a chained alias of a param should still fail' + assert output.contains('passed through `chain_passthrough`'), 'got: ${output}' +} + +fn test_escape_passthrough_mut_alias_errors() { + // `mut y := param` parses with a ModifierExpr LHS; the alias collector + // unwraps that the same way the locals collector does. + code := ' +struct Bar { + v int +} + +fn mut_alias_passthrough[^a](p &^a Bar) &^a Bar { + mut y := p + return y +} + +fn dangling[^a](caller &^a Bar) &^a Bar { + local := Bar{v: 1} + return mut_alias_passthrough(&local) +} + +fn main() {} +' + exit_code, output := run_escape_check(code) + assert exit_code != 0, '`mut y := param` alias should still flag the call site' + assert output.contains('passed through `mut_alias_passthrough`'), 'got: ${output}' +} + +fn test_escape_passthrough_non_aliased_local_ok() { + // `y := other_fn(...)` is NOT an alias of any param, so the callee is not + // pass-through and the call site stays unflagged. (Control case to make + // sure the alias detector doesn't over-eagerly classify fns.) + code := ' +struct Bar { + v int +} + +fn make_bar() Bar { + return Bar{v: 1} +} + +fn not_passthrough[^a](p &^a Bar) Bar { + y := make_bar() + return y +} + +fn safe[^a](caller &^a Bar) Bar { + local := Bar{v: 1} + return not_passthrough(&local) +} + +fn main() {} +' + exit_code, _ := run_escape_check(code) + assert exit_code == 0, 'fn returning a fresh local (not a param alias) should not be pass-through' +} + fn test_escape_passthrough_non_optin_call_ignored() { // The callee `helper` does NOT declare `[^a]`, so it is not added to the // pass-through registry. The call site falls through to the existing -- 2.39.5