From 8b7fdf5e551b2f5f6e5596a27558823639605620 Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Sun, 24 May 2026 17:06:04 +0300 Subject: [PATCH] =?UTF-8?q?v2:=20ownership=20checker=20=E2=80=94=20reject?= =?UTF-8?q?=20moves=20out=20of=20owned=20`=5F=5Fglobal`s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `__global g = OwnedType{}` is program-wide mutable state; the checker can't see across function boundaries to know who else still observes the binding, so any move out of it would leave the global in an undefined state. We therefore reject every move-shaped use of an owned global and direct the user to either `&g` (borrow) or `g.clone()` (copy). Move sites guarded: * `local := g` — assignment * `f(g)` — function-call argument * `g.consume_self()` — value-receiver method call * `match g { x { ... } }` — pattern-match destructuring * `fn [g] () { ... }` — by-value closure capture * `return g` — function return `&g` and `mut g` keep working at every site. Non-`Owned` globals are left alone — V's auto-copy semantics still apply to them. A new `ownership_prescan_owned_globals` pass walks every module scope at prescan time and registers globals whose declared type implements `Owned` (or is an Rc/Arc wrapper). All move sites consult the new `ownership_owned_globals` map via `ownership_reject_global_move`. 9 new tests in checker_ownership_test.v cover each site plus the non-`Owned` global control case. --- vlib/v2/types/checker.v | 6 + vlib/v2/types/checker_ownership.v | 114 ++++++++++++++- vlib/v2/types/checker_ownership_test.v | 186 +++++++++++++++++++++++++ 3 files changed, 304 insertions(+), 2 deletions(-) diff --git a/vlib/v2/types/checker.v b/vlib/v2/types/checker.v index 934cfca8b..78e830b42 100644 --- a/vlib/v2/types/checker.v +++ b/vlib/v2/types/checker.v @@ -611,6 +611,12 @@ mut: // visible, consulted at every call site to move the receiver of an owned // var. See checker_ownership.v. ownership_value_receiver_methods map[string]bool + // Owned-global registry: `global_name` -> display type name. Populated by + // `ownership_prescan_owned_globals` from `__global` decls whose declared + // type implements `Owned` (or is an Rc/Arc wrapper). Used to reject moves + // out of program-wide mutable state, where the compiler can't see across + // function boundaries to know who else might still hold the binding. + ownership_owned_globals map[string]string } pub fn Checker.new(prefs &pref.Preferences, file_set &token.FileSet, env &Environment) &Checker { diff --git a/vlib/v2/types/checker_ownership.v b/vlib/v2/types/checker_ownership.v index 08f0d14f4..ac9d9c514 100644 --- a/vlib/v2/types/checker_ownership.v +++ b/vlib/v2/types/checker_ownership.v @@ -57,6 +57,10 @@ fn (mut c Checker) ownership_check_ident(name string, pos token.Pos) { fn (mut c Checker) ownership_check_assign(lhs_name string, rhs ast.Expr, assign_pos token.Pos) { // Check if RHS is an ident that is owned rhs_name := ownership_expr_ident_name(rhs) + // Reject `local := g_owned` — moving out of an owned global is never allowed. + if rhs_name.len > 0 { + c.ownership_reject_global_move(rhs_name, assign_pos, lhs_name, false) + } if rhs_name.len > 0 && rhs_name in c.owned_vars { // Cannot move a variable that is currently borrowed if rhs_name in c.borrowed_vars { @@ -98,6 +102,10 @@ fn (mut c Checker) ownership_check_assign(lhs_name string, rhs ast.Expr, assign_ fn (mut c Checker) ownership_consume_expr(expr ast.Expr, pos token.Pos, target string) { name := ownership_expr_ident_name(expr) + // Reject pattern-match destructuring of an owned global into a binding. + if name.len > 0 { + c.ownership_reject_global_move(name, pos, target, false) + } if name.len == 0 || name !in c.owned_vars { return } @@ -174,6 +182,10 @@ fn (mut c Checker) ownership_check_call_args(expr ast.CallExpr) { } // Regular argument — moves ownership arg_name := ownership_expr_ident_name(arg) + // Reject `f(g_owned)` for any owned global — must borrow/clone instead. + if arg_name.len > 0 { + c.ownership_reject_global_move(arg_name, expr.pos, fn_name, true) + } if arg_name.len > 0 && arg_name in c.owned_vars { // Cannot move a variable that is currently borrowed if arg_name in c.borrowed_vars { @@ -216,6 +228,11 @@ fn (mut c Checker) ownership_check_return(stmt ast.ReturnStmt) { } for expr in stmt.exprs { name := ownership_expr_ident_name(expr) + // Reject `return g_owned` — handing a global out of a function moves it. + // ReturnStmt has no pos field, so we use the expression's position. + if name.len > 0 { + c.ownership_reject_global_move(name, ownership_expr_pos(expr), c.ownership_cur_fn, true) + } if name.len > 0 && name in c.owned_vars { // This function returns an owned value — mark the function c.ownership_fns[c.ownership_cur_fn] = true @@ -589,6 +606,10 @@ fn (mut c Checker) ownership_prescan_fn_bodies() { // Pass 3: Find methods declared with a by-value receiver — calls to these // methods on owned vars MOVE the receiver (consuming-self semantics). c.ownership_prescan_value_receivers() + // Pass 4: Find `__global` decls whose declared type implements `Owned` + // (or is an Rc/Arc wrapper). Used to reject moves out of program-wide + // mutable state. + c.ownership_prescan_owned_globals() } // ownership_prescan_returns_owned checks if a function body creates a .to_owned() value @@ -994,6 +1015,27 @@ fn ownership_expr_ident_name(expr ast.Expr) string { return '' } +// ownership_expr_pos extracts a position from an expression. Returns an empty +// `token.Pos{}` for expressions without a direct position. Used where we need +// to point at the offending expression but the surrounding statement (e.g. +// `ReturnStmt`) doesn't carry its own position. +fn ownership_expr_pos(expr ast.Expr) token.Pos { + match expr { + ast.Ident { + return expr.pos + } + ast.SelectorExpr { + return expr.pos + } + ast.CallExpr { + return expr.pos + } + else { + return token.Pos{} + } + } +} + // ownership_receiver_short_name extracts the short type name from a receiver // type expression. Returns empty string if the receiver isn't a recognisable // named type. Handles plain idents, generic instantiations (`Foo[T]` via @@ -1070,7 +1112,22 @@ fn (mut c Checker) ownership_check_method_call(expr ast.CallExpr) { } sel := expr.lhs as ast.SelectorExpr recv_name := ownership_expr_ident_name(sel.lhs) - if recv_name.len == 0 || recv_name !in c.owned_vars { + if recv_name.len == 0 { + return + } + // Reject `g_owned.consume_self()` — value-receiver calls on a global would + // move it. The static-type check below means we only fire on Owned types, + // matching the consuming-self semantics for plain locals. + if recv_name in c.ownership_owned_globals { + method_name := sel.rhs.name + short_type := c.ownership_owned_globals[recv_name].all_after_last('__') + key := '${short_type}__${method_name}' + if key in c.ownership_value_receiver_methods { + c.ownership_reject_global_move(recv_name, expr.pos, method_name, true) + } + return + } + if recv_name !in c.owned_vars { return } type_display := c.owned_var_types[recv_name] or { return } @@ -1112,6 +1169,53 @@ fn (mut c Checker) ownership_check_method_call(expr ast.CallExpr) { } } +// ownership_prescan_owned_globals walks every module scope and registers each +// `__global` whose declared type implements `Owned` (or is an Rc/Arc wrapper). +// These globals are then rejected at every move site so program-wide mutable +// state can never be left in a half-moved condition — the compiler can't see +// across function boundaries to know who else might still hold the binding. +fn (mut c Checker) ownership_prescan_owned_globals() { + rlock c.env.scopes { + for _, mod_scope in c.env.scopes { + for _, obj in mod_scope.objects { + if obj is Global { + g := obj as Global + if c.is_owned_type(g.typ) { + c.ownership_owned_globals[g.name] = ownership_type_display(g.typ) + } + } + } + } + } +} + +// ownership_reject_global_move emits the "cannot move out of global" diagnostic +// and exits. Returns silently if `name` is not a tracked owned global. +// * `target` — what the global is being moved into (var name / `closure` / fn name) +// * `is_call` — set when the move is a function-call argument (changes the help text) +fn (mut c Checker) ownership_reject_global_move(name string, pos token.Pos, target string, is_call bool) { + if name !in c.ownership_owned_globals { + return + } + type_name := c.ownership_owned_globals[name] + if pos.id > 0 { + file := c.file_set.file(pos) + errors.error('cannot move out of global `${name}`', errors.details(file, + file.position(pos), 2), .error, file.position(pos)) + } else { + eprintln('error: cannot move out of global `${name}`') + } + eprintln(' --> global `${name}` has type `${type_name}`, which does not implement the `Copy` interface') + if is_call { + eprintln(' --> moving a global into `${target}` would leave the global in an undefined state') + eprintln('help: pass a borrow (`&${name}`) or clone (`${name}.clone()`) instead') + } else { + eprintln(' --> moving the global into `${target}` would leave the global in an undefined state') + eprintln('help: borrow with `&${name}` or copy with `${name}.clone()` instead') + } + exit(1) +} + // ownership_check_closure_captures fires when a closure literal (`fn [x, // mut y] () { ... }`) is constructed. The Go-style explicit capture list // is interpreted as: @@ -1134,7 +1238,13 @@ fn (mut c Checker) ownership_check_closure_captures(expr ast.FnLiteral) { continue } name := ownership_expr_ident_name(cv) - if name.len == 0 || name !in c.owned_vars { + if name.len == 0 { + continue + } + // Reject `fn [g_owned] () { ... }` — borrow form `[&g]` / `[mut g]` is + // fine (skipped above), but a by-value capture would move the global. + c.ownership_reject_global_move(name, expr.pos, 'closure', false) + if name !in c.owned_vars { continue } if name in c.borrowed_vars { diff --git a/vlib/v2/types/checker_ownership_test.v b/vlib/v2/types/checker_ownership_test.v index e67fa7ba8..101f98ec0 100644 --- a/vlib/v2/types/checker_ownership_test.v +++ b/vlib/v2/types/checker_ownership_test.v @@ -1366,3 +1366,189 @@ fn main() { assert exit_code != 0, 'consume after capture should fail' assert output.contains('use of moved value: `c`'), 'got: ${output}' } + +// === Owned globals: moves out of `__global` of an Owned type are rejected === +// +// `__global` declarations of types implementing `Owned` cannot be moved out of +// because the compiler can't see across function boundaries to know who else +// might still observe the binding. The escape hatches are `&g_x` (borrow) or +// `g_x.clone()` (copy). Plain V values keep their auto-copy semantics. + +fn test_owned_global_assign_to_local_fails() { + code := ' +struct Conn implements Owned { +mut: + socket int +} + +__global g_conn = Conn{socket: 7} + +fn main() { + local := g_conn + println(local.socket) +} +' + exit_code, output := run_ownership_check(code) + assert exit_code != 0, 'should reject move out of owned global' + assert output.contains('cannot move out of global `g_conn`'), 'got: ${output}' + assert output.contains('has type `Conn`'), 'should name the type, got: ${output}' +} + +fn test_owned_global_fn_call_arg_fails() { + code := ' +struct Conn implements Owned { +mut: + socket int +} + +__global g_conn = Conn{socket: 7} + +fn take(c Conn) { + _ = c +} + +fn main() { + take(g_conn) +} +' + exit_code, output := run_ownership_check(code) + assert exit_code != 0, 'should reject moving global into fn call' + assert output.contains('cannot move out of global `g_conn`'), 'got: ${output}' + assert output.contains('into `take`'), 'should name the target fn, got: ${output}' +} + +fn test_owned_global_borrow_arg_ok() { + // Passing `&g_conn` is the explicit escape hatch — must compile. + code := ' +struct Conn implements Owned { +mut: + socket int +} + +__global g_conn = Conn{socket: 7} + +fn inspect(c &Conn) { + println(c.socket) +} + +fn main() { + inspect(&g_conn) +} +' + exit_code, output := run_ownership_check(code) + assert exit_code == 0, 'borrowing a global must succeed, got: ${output}' +} + +fn test_owned_global_consuming_method_fails() { + code := ' +struct Conn implements Owned { +mut: + socket int +} + +fn (c Conn) close() { + _ = c.socket +} + +__global g_conn = Conn{socket: 7} + +fn main() { + g_conn.close() +} +' + exit_code, output := run_ownership_check(code) + assert exit_code != 0, 'should reject consuming-self call on global' + assert output.contains('cannot move out of global `g_conn`'), 'got: ${output}' +} + +fn test_owned_global_borrow_method_ok() { + // `&self` receiver is a borrow — calling it on a global is fine. + code := ' +struct Conn implements Owned { +mut: + socket int +} + +fn (c &Conn) port() int { + return c.socket +} + +__global g_conn = Conn{socket: 7} + +fn main() { + println(g_conn.port()) +} +' + exit_code, output := run_ownership_check(code) + assert exit_code == 0, 'borrow-receiver method on global must succeed, got: ${output}' +} + +fn test_owned_global_closure_capture_fails() { + code := ' +struct Conn implements Owned { +mut: + socket int +} + +__global g_conn = Conn{socket: 7} + +fn main() { + cb := fn [g_conn] () { + println(g_conn.socket) + } + cb() +} +' + exit_code, output := run_ownership_check(code) + assert exit_code != 0, 'should reject by-value closure capture of global' + assert output.contains('cannot move out of global `g_conn`'), 'got: ${output}' + assert output.contains('into `closure`'), 'should mention closure as the target, got: ${output}' +} + +fn test_owned_global_return_fails() { + code := ' +struct Conn implements Owned { +mut: + socket int +} + +__global g_conn = Conn{socket: 7} + +fn give() Conn { + return g_conn +} + +fn main() { + c := give() + println(c.socket) +} +' + exit_code, output := run_ownership_check(code) + assert exit_code != 0, 'should reject returning an owned global' + assert output.contains('cannot move out of global `g_conn`'), 'got: ${output}' +} + +fn test_plain_global_unaffected() { + // Non-Owned globals keep V auto-copy semantics — moving them must not + // trip the ownership checker. + code := ' +struct Plain { +mut: + x int +} + +__global g_plain = Plain{x: 1} + +fn take(p Plain) { + _ = p.x +} + +fn main() { + local := g_plain + take(local) + take(g_plain) +} +' + exit_code, output := run_ownership_check(code) + assert exit_code == 0, 'non-Owned global must keep current semantics, got: ${output}' +} -- 2.39.5