diff --git a/compiler/src/ast.ml b/compiler/src/ast.ml index 840f2f0..728584c 100644 --- a/compiler/src/ast.ml +++ b/compiler/src/ast.ml @@ -73,6 +73,9 @@ type field_ty = `ref` — NOT a stored column; reading it scans C's index on f. Types as multi C. *) | Nullable of field_ty (* ?T wrapper *) + | Actor of string (* actor M: a typed actor address (arc, 8+11); + M is the receive-message class. A copyable + scalar word at runtime. *) (* Parameter passing convention (spec section 3, rule 2): default is an immutable borrow; `mut` is an exclusive borrow; `take` moves @@ -252,6 +255,10 @@ and expr_kind = uses either, so neither is grammar here (YAGNI, recorded in the task report). *) | Switch of expr * switch_arm list + (* the concurrency arc: `spawn Cls { fields }` — construct the actor's + state (exactly a ctor literal, fields MOVE in) and start it; the + result is an `actor M` address, M inferred from Cls's receive. *) + | Spawn of string * (string * expr) list (* Container literals, the driving workload's own spelling for a fresh container: `[]` / `[a, b, c]` for a `multi T`, `{}` for an empty `map`. They lower to exactly what `multi_new()`/`map_new()` diff --git a/compiler/src/dump.ml b/compiler/src/dump.ml index 78f0b97..ba05fc7 100644 --- a/compiler/src/dump.ml +++ b/compiler/src/dump.ml @@ -51,6 +51,7 @@ let kind_label (k : Token.kind) : string = | Token.KwInsert -> "KW_INSERT" | Token.KwSelect -> "KW_SELECT" | Token.KwUse -> "KW_USE" + | Token.KwSpawn -> "KW_SPAWN" | Token.KwPub -> "KW_PUB" | Token.KwBreak -> "KW_BREAK" | Token.KwContinue -> "KW_CONTINUE" @@ -151,6 +152,7 @@ let rec field_ty_str : Ast.field_ty -> string = function | Ast.Multi s -> Printf.sprintf "multi %s" s | Ast.Map (k, v) -> Printf.sprintf "map<%s, %s>" k v | Ast.Backlink (c, f) -> Printf.sprintf "backlink %s.%s" c f + | Ast.Actor m -> Printf.sprintf "actor %s" m | Ast.Nullable t -> "?" ^ field_ty_str t let param_str (p : Ast.param) : string = Printf.sprintf "%s%s: %s" (conv_str p.conv) p.name (field_ty_str p.ty) @@ -242,6 +244,9 @@ let rec expr_str (e : Ast.expr) : string = | Ast.MapLit -> "{}" | Ast.NilLit -> "nil" | Ast.As (inner, ty) -> Printf.sprintf "%s as %s" (expr_str inner) (field_ty_str ty) + | Ast.Spawn (cn, fields) -> + Printf.sprintf "spawn %s{%s}" cn + (String.concat ", " (List.map (fun (n, v) -> n ^ ": " ^ expr_str v) fields)) (* Like SWITCH above: a one-line summary, not a full unparse of the catch arm's statements. *) | Ast.Try { body; ename; handler } -> diff --git a/compiler/src/emit.ml b/compiler/src/emit.ml index 4a28fac..64f9432 100644 --- a/compiler/src/emit.ml +++ b/compiler/src/emit.ml @@ -786,6 +786,8 @@ let b_db_delete = 63 let b_db_scan = 64 let b_db_get_field = 65 let b_db_probe = 66 +let b_spawn = 68 (* arc: spawn(instance, receive_method_idx) -> actor address *) +let b_send = 69 (* arc: send(addr, msg) — msg moves to the runtime *) (* iteration 9b: `d.staff` where staff is `backlink Employee.dept` reads by probing Employee's index on its `dept` column. Resolve to (source cid, @@ -950,7 +952,9 @@ let is_builtin_name (n : string) = (* systems stdlib *) "len"; "byte_at"; "print_err"; "starts_with"; "ends_with"; "index_of"; "last_index_of"; "substr"; "trim"; "to_lower"; "char_of"; "parse_int"; "split"; "split_ws"; "join"; "slice"; - "pop"; "shift"; "sort"; "reverse"; "remove"; "key_at"; "val_at" ] + "pop"; "shift"; "sort"; "reverse"; "remove"; "key_at"; "val_at"; + (* the concurrency arc *) + "send" ] (* ---- unions and variants (haxe-parity Task 4) ------------------------ @@ -1118,6 +1122,17 @@ let rec ty_of_expr (p : pctx) (f : fstate) (e : Ast.expr) : Ast.field_ty option | Eq | Ne | Lt | Le | Gt | Ge | And | Or -> Some (Scalar "Bool") | Add | Sub | Mul | Div | Mod -> ( match ty_of_expr p f l with Some t -> Some t | None -> Some (Scalar "Int"))) | Ctor (cn, _) -> Some (Scalar cn) + (* arc: a spawn's value is the typed actor address (a scalar word) *) + | Spawn (cn, _) -> ( + match Types.StringMap.find_opt cn p.p_syms.Types.classes with + | Some cls -> ( + match + List.find_opt (fun (m : Types.method_info) -> m.Types.name = "receive") cls.Types.methods + with + | Some { Types.params = [ (_, pty, _) ]; _ } -> ( + match pty with Ast.Scalar mname -> Some (Ast.Actor mname) | _ -> None) + | _ -> None) + | None -> None) | Insert _ -> Some (Scalar "Int") | Delete _ -> Some (Scalar "Int") | Query q -> @@ -1395,6 +1410,7 @@ let field_class_meta (p : pctx) (ty : Ast.field_ty) : int = | Some n -> ( match class_of_name p n with Some cid -> cid | None -> wob_none) | None -> wob_none) | Ast.Ref n -> ( match class_of_name p n with Some cid -> cid | None -> wob_none) + | Ast.Actor _ -> wob_none (* an address word: no per-class drop metadata *) | Ast.Backlink _ | Ast.Nullable _ -> wob_none let field_elem_meta (p : pctx) (ty : Ast.field_ty) : int = @@ -1730,6 +1746,26 @@ let rec emit_expr (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e put f (ins_abc op_neg dst b 0) | Binary (op, l, r) -> emit_binary p f v ~dst op l r | Ctor (cn, fields) -> emit_ctor p f v ~dst e cn fields + | Spawn (cn, fields) -> + (* build the actor's state exactly as a ctor, then hand instance + + receive's method index to the runtime; dst gets the address word. + Reserve dst like emit_ctor does — in tail position dst can sit at + f_temp and the two-slot argument window would clobber it. *) + let outer = f.f_temp in + if f.f_temp <= dst then f.f_temp <- dst + 1; + let t = alloc_temps p f e.pos 2 in + emit_ctor p f v ~dst:t e cn fields; + (match SM.find_opt (cn ^ ".receive") p.p_method_id with + | Some midx -> + put f (ins_abx op_loadk (t + 1) (check_bx p f e.pos "constant" (const_int p midx))); + sync_mask p f v e.id; + f.f_cur_line <- e.pos.line; + put f (ins_abc op_builtin dst t b_spawn) + | None -> + err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos + ~message:(Printf.sprintf "`spawn %s`: no `receive` method (typecheck should have refused)" cn); + put f (ins_abx op_loadk dst (const_int p 0))); + f.f_temp <- outer | Insert (cn, fields) -> emit_insert p f v ~dst e cn fields | Delete target -> ( match ty_of_expr p f target with @@ -3320,6 +3356,7 @@ and emit_builtin (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e : then 1 else if id = b_multi_push || id = b_multi_get || id = b_map_get || id = b_map_has + || id = b_send (* systems stdlib, two arguments *) || id = b_byte_at || id = b_starts_with || id = b_ends_with || id = b_index_of || id = b_last_index_of || id = b_split || id = b_join || id = b_map_remove @@ -3359,7 +3396,7 @@ and emit_builtin (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e : dangle the value just read) and the stores, which either copy (Text, handled by copied_container_call) or take ownership (OWNED/GCREF). *) let reader = List.mem name [ "get"; "latest"; "key_at"; "val_at" ] in - (if not (List.mem name [ "push"; "set" ]) then + (if not (List.mem name [ "push"; "set"; "send" ]) then List.iteri (fun i (a : Ast.expr) -> (* a reader's result points into arg0 (the container) — dropping @@ -3389,6 +3426,7 @@ and emit_builtin (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e : List.iteri (fun i (a : Ast.expr) -> if i > 0 then drop_fresh_text p f (base + i) a) args in match name with + | "send" -> fixed b_send (* arc: msg (arg1) moved to the runtime — never dropped here *) | "now" -> fixed b_now | "print" -> fixed b_print | "print_int" -> fixed b_print_int diff --git a/compiler/src/gcinfer.ml b/compiler/src/gcinfer.ml index f589b47..8ac6b97 100644 --- a/compiler/src/gcinfer.ml +++ b/compiler/src/gcinfer.ml @@ -29,6 +29,7 @@ let rec refs_of_ty (classes : Types.class_info SMap.t) (t : Ast.field_ty) : | Ast.Map (k, v) -> List.filter (fun n -> SMap.mem n classes) [ k; v ] | Ast.Nullable ft -> refs_of_ty classes ft | Ast.Ref _ | Ast.Backlink _ -> [] (* id / virtual inverse: no pointer edge *) + | Ast.Actor _ -> [] (* an address word — the runtime owns actors, never a pointer edge *) let edges (classes : Types.class_info SMap.t) (ci : Types.class_info) : string list = diff --git a/compiler/src/lexer.ml b/compiler/src/lexer.ml index 74cb335..49a78cb 100644 --- a/compiler/src/lexer.ml +++ b/compiler/src/lexer.ml @@ -125,6 +125,7 @@ let keyword_kind = function | "true" -> Some Token.KwTrue | "false" -> Some Token.KwFalse | "use" -> Some Token.KwUse + | "spawn" -> Some Token.KwSpawn | "pub" -> Some Token.KwPub | "break" -> Some Token.KwBreak | "continue" -> Some Token.KwContinue diff --git a/compiler/src/owner.ml b/compiler/src/owner.ml index 6b83673..0b32329 100644 --- a/compiler/src/owner.ml +++ b/compiler/src/owner.ml @@ -421,6 +421,7 @@ let oclass_of (ctx : ctx) (ft : Ast.field_ty) : oclass = | Some u -> if u.Types.u_has_payload then Owned else Copy | None -> Copy (* unknown type: WO-E225 already reported by types.ml *)) | Ref _ -> Copy + | Actor _ -> Copy (* an address is a copyable word; the runtime owns actors *) | Backlink _ -> Copy (* a virtual collection of row ids read on demand *) | Multi _ | Map _ -> Owned | Nullable _ -> Copy (* unreachable: unwrapped above *) @@ -505,6 +506,17 @@ let rec expr_ty (ctx : ctx) (e : Ast.expr) : Ast.field_ty option = field/parameter it is built into) decides. *) | ListLit (first :: _) -> ( match expr_ty ctx first with Some (Scalar n) -> Some (Multi n) | _ -> None) + (* arc: a spawn's value is a typed address — a copyable scalar word *) + | Spawn (cn, _) -> ( + match Types.StringMap.find_opt cn ctx.syms.Types.classes with + | Some cls -> ( + match + List.find_opt (fun (m : Types.method_info) -> m.Types.name = "receive") cls.Types.methods + with + | Some { Types.params = [ (_, pty, _) ]; _ } -> ( + match pty with Ast.Scalar mname -> Some (Ast.Actor mname) | _ -> None) + | _ -> None) + | None -> None) | ListLit [] | MapLit -> None (* haxe-parity Task 6: `nil` is the zero word — contextual on its destination, and never something this frame owns. *) @@ -1124,6 +1136,9 @@ let rec read_expr (ctx : ctx) (e : Ast.expr) : unit = read_place_parts ctx e | Call (callee, args) -> analyze_call ctx e callee args | Ctor (cn, fields) -> analyze_ctor ctx cn fields + (* arc: spawn's ctor half moves fields exactly as a ctor literal does; + the result (an address) is Copy, so no drop for the spawn itself *) + | Spawn (cn, fields) -> analyze_ctor ctx cn fields | Insert (_, fields) -> (* iteration 9 Task 3: the engine copies every field value at the row API (the two-worlds bulkhead), so an insert BORROWS its values — @@ -1323,6 +1338,9 @@ and analyze_call (ctx : ctx) (call_e : Ast.expr) (callee : Ast.expr) (args : Ast | Ident "push" -> i = 1 && Types.StringMap.find_opt "push" ctx.syms.Types.free_fns = None | Ident "set" -> (i = 1 || i = 2) && Types.StringMap.find_opt "set" ctx.syms.Types.free_fns = None + (* arc: send(addr, msg) MOVES the message to the runtime — the sender's + binding dies (compile-time move, iteration 8's criterion) *) + | Ident "send" -> i = 1 && Types.StringMap.find_opt "send" ctx.syms.Types.free_fns = None | _ -> false in List.iteri @@ -1336,8 +1354,13 @@ and analyze_call (ctx : ctx) (call_e : Ast.expr) (callee : Ast.expr) (args : Ast record_move ctx p (MvArg pname)) else if container_store_slot i && place_class ctx p = Owned && not (stores_by_copy ctx p) then - (if transfer ctx p ~what:"cannot be stored in a container" then - record_move ctx p (MvArg "element")) + (if + transfer ctx p + ~what: + (match callee.kind with + | Ident "send" -> "cannot be sent — a message moves to the receiver" + | _ -> "cannot be stored in a container") + then record_move ctx p (MvArg "element")) ) args; record_drop ctx ~node:call_e.id ~pos:call_e.pos ~kind:DLiveMask diff --git a/compiler/src/parser.ml b/compiler/src/parser.ml index 1974441..1510472 100644 --- a/compiler/src/parser.ml +++ b/compiler/src/parser.ml @@ -395,6 +395,11 @@ let parse_field_ty (st : state) : Ast.field_ty = expect st Token.Dot "'.'"; let fld = expect_ident st "backlink source field" in Ast.Backlink (cls, fld) + | Token.Ident "actor" -> + (* the concurrency arc: `actor M` — a typed actor address, + contextual like multi/map (actor stays a legal identifier) *) + ignore (advance st); + Ast.Actor (expect_ident st "actor message type") | Token.Ident "map" -> ignore (advance st); expect st Token.Lt "'<'"; @@ -1239,6 +1244,16 @@ and parse_primary (st : state) : Ast.expr = "only the empty map literal `{}` is an expression — build entries with `set(m, k, v)`"; ignore (advance st); { Ast.id; pos; kind = Ast.MapLit } + | Token.KwSpawn -> + (* arc: `spawn Cls { fields }` — exactly a ctor literal behind the + keyword; fields MOVE in, result is the actor address *) + let pos = peek_pos st in + let id = fresh_id st in + ignore (advance st); + let lit = parse_ctor_literal st in + (match lit.Ast.kind with + | Ast.Ctor (cn, fields) -> { Ast.id; pos; kind = Ast.Spawn (cn, fields) } + | _ -> unexpected st "a class constructor after `spawn`") | Token.Ident _ when looks_like_ctor st -> parse_ctor_literal st | Token.Ident s -> let pos = peek_pos st in @@ -1966,6 +1981,8 @@ let rec subst_expr (consts : Ast.expr StringMap.t) (bound : StringSet.t) (e : As { e with Ast.kind = Ast.Binary (op, subst_expr consts bound l, subst_expr consts bound r) } | Ast.Ctor (cn, fields) -> { e with Ast.kind = Ast.Ctor (cn, List.map (fun (n, v) -> (n, subst_expr consts bound v)) fields) } + | Ast.Spawn (cn, fields) -> + { e with Ast.kind = Ast.Spawn (cn, List.map (fun (n, v) -> (n, subst_expr consts bound v)) fields) } | Ast.Insert (cn, fields) -> { e with Ast.kind = Ast.Insert (cn, List.map (fun (n, v) -> (n, subst_expr consts bound v)) fields) } | Ast.Delete t -> { e with Ast.kind = Ast.Delete (subst_expr consts bound t) } diff --git a/compiler/src/token.ml b/compiler/src/token.ml index 6c54168..2dc098d 100644 --- a/compiler/src/token.ml +++ b/compiler/src/token.ml @@ -63,6 +63,9 @@ type kind = field-name collision to dodge (see lexer.ml's module doc for why those other names stayed idents). *) | KwUse + (* the concurrency arc (iterations 8+11): `spawn Cls { ... }`. `send` + is deliberately NOT a keyword — it is a builtin free-fn name. *) + | KwSpawn | KwPub (* haxe-parity Task 2 (small control surface): break/continue/do-while, const values, and/or booleans, and inline-fn rejection (the haxe diff --git a/compiler/src/types.ml b/compiler/src/types.ml index 94931f4..27c5959 100644 --- a/compiler/src/types.ml +++ b/compiler/src/types.ml @@ -19,6 +19,7 @@ type typ = | TMulti of typ (* multi T *) | TMap of typ * typ (* map *) | TRef of string (* ref T — ID link *) + | TActor of string (* actor M — a typed actor address (arc 8+11); a copyable scalar word *) | TVoid (* no return *) (* .wob field kinds (docs/plan/oop-vm/00-wob-format.md) *) @@ -325,6 +326,7 @@ let rec field_ty_of_typ (t : typ) : field_ty option = | TMulti (TScalar n) -> Some (Multi n) | TMap (TScalar k, TScalar v) -> Some (Map (k, v)) | TNullable inner -> ( match field_ty_of_typ inner with Some ft -> Some (Nullable ft) | None -> None) + | TActor m -> Some (Actor m) | TMulti _ | TMap _ | TVoid -> None let rec typ_of_field_ty (ft : field_ty) : typ = @@ -334,6 +336,7 @@ let rec typ_of_field_ty (ft : field_ty) : typ = | Multi inner_name -> TMulti (TScalar inner_name) | Map (k_name, v_name) -> TMap (TScalar k_name, TScalar v_name) | Backlink (c, _) -> TMulti (TScalar c) (* reads as a collection of C *) + | Actor m -> TActor m | Nullable inner -> TNullable (typ_of_field_ty inner) (* wob_kind_of_typ: maps internal typ to .wob field kind *) @@ -368,6 +371,7 @@ let wob_kind_of_typ (syms : symbols) (t : typ) : wob_kind = | TMulti _ -> WO_K_MULTI | TMap _ -> WO_K_MAP | TRef _ -> WO_K_SCALAR + | TActor _ -> WO_K_SCALAR (* an address is a copyable word; the runtime owns actors *) | TVoid -> WO_K_SCALAR in kind_of t @@ -389,6 +393,7 @@ let invalid_builtin_code = Diag.types_prefix ^ "09" let module_not_imported_code = Diag.types_prefix ^ "10" let nullable_used_without_check_code = Diag.types_prefix ^ "11" let nullable_assign_mismatch_code = Diag.types_prefix ^ "12" +let spawn_no_receive_code = Diag.types_prefix ^ "21" (* WO-E221: spawn target lacks fn receive(msg: M); E219/E220 are taken on the language-surface-strictness branch *) let missing_nil_check_code = Diag.types_prefix ^ "13" (* haxe-parity Task 1 (modules). module_not_imported_code (WO-E210, @@ -603,7 +608,7 @@ let rec scalar_name_of (ft : field_ty) : string option = match ft with | Scalar name -> Some name | Nullable inner -> scalar_name_of inner - | Ref _ | Multi _ | Map _ | Backlink _ -> None + | Ref _ | Multi _ | Map _ | Backlink _ | Actor _ -> None (* Checked once per field declaration (not at every access/use site), so the diagnostic lands at the field's own declaration position and @@ -854,6 +859,7 @@ let rec typ_label (t : typ) : string = | TMulti _ -> "multi" | TMap _ -> "map" | TRef name -> "ref " ^ name + | TActor m -> "actor " ^ m | TVoid -> "void" (* A builtin call's own confident return type, for when it appears as @@ -1173,6 +1179,16 @@ let typecheck_program ~file ~(module_of : string -> string) | None -> None)) | _ -> None)) | _ -> None) + | Spawn (cn, _fields) -> ( + match StringMap.find_opt cn syms.classes with + | Some cls -> ( + match List.find_opt (fun (m : method_info) -> m.name = "receive") cls.methods with + | Some { params = [ (_, pty, _) ]; _ } -> ( + match typ_of_field_ty pty with + | TScalar mname -> Some (TActor mname) + | _ -> None) + | _ -> None) + | None -> None) | Ctor (class_name, _fields) -> (* `Ctor`'s class name is never a placeholder -- unlike `Ident`/`Field`/`Index`/`Call`, there is no fallback path @@ -1433,6 +1449,40 @@ let typecheck_program ~file ~(module_of : string -> string) "variant `%s` of `%s` takes %d payload argument(s), given %d" vi.vi_name u.u_name want got) ()) + | None when name = "send" -> + (* arc: send(addr, msg) — bespoke shape: addr is an + `actor M`, msg must BE an M (exactly; moves are checked + by the owner pass). Silent when underivable, the + standing contract. *) + (if List.length args <> 2 then + Diag.Collector.add collector + (Diag.error ~code:bad_arity_code ~file ~line:e.pos.line ~col:e.pos.col + ~message: + (Printf.sprintf "`send` takes 2 arguments (address, message), given %d" + (List.length args)) + ()) + else + match args with + | [ a; m ] -> ( + match confident_typ cenv a with + | Some (TActor want) -> ( + match confident_typ cenv m with + | Some (TScalar got) when got <> want -> + Diag.Collector.add collector + (Diag.error ~code:type_mismatch_code ~file ~line:m.pos.line + ~col:m.pos.col + ~message: + (Printf.sprintf + "this actor receives `%s` — the message is a `%s`" want got) + ()) + | _ -> ()) + | Some _ -> + Diag.Collector.add collector + (Diag.error ~code:type_mismatch_code ~file ~line:a.pos.line + ~col:a.pos.col + ~message:"`send`'s first argument must be an `actor M` address" ()) + | None -> ()) + | _ -> ()) | None -> let confident_types = List.map (confident_typ cenv) args in check_builtin_call ~file collector name e.pos args confident_types) @@ -1563,6 +1613,36 @@ let typecheck_program ~file ~(module_of : string -> string) let ir = typecheck_expr env cenv inner in if is_nullable ir.typ || ir.is_nil then e211 inner.pos (expr_label inner); { typ = TScalar "Text"; is_nil = false } + | Spawn (cn, fields) -> + (* the ctor half checks exactly as a ctor literal (completeness, + field types, ?T boundaries) — delegate, then type the address *) + let _ = typecheck_expr env cenv { e with kind = Ctor (cn, fields) } in + let bad why = + Diag.Collector.add collector + (Diag.error ~code:spawn_no_receive_code ~file ~line:e.pos.line ~col:e.pos.col + ~message: + (Printf.sprintf + "`spawn %s { ... }`: %s — an actor is a class with `fn receive(msg: M)` where M is a class, record, or union" + cn why) + ()); + { typ = TScalar "Int"; is_nil = false } + in + (match StringMap.find_opt cn syms.classes with + | Some cls -> ( + match List.find_opt (fun (m : method_info) -> m.name = "receive") cls.methods with + | Some { params = [ (_, pty, _) ]; _ } -> ( + match typ_of_field_ty pty with + | TScalar mname + when StringMap.mem mname syms.classes + || StringMap.mem mname syms.unions -> + { typ = TActor mname; is_nil = false } + | TScalar mname -> bad (Printf.sprintf "receive's message type `%s` is not a declared class, record, or union" mname) + | _ -> bad "receive's parameter must be a plain class, record, or union type") + | Some _ -> bad "its `receive` must take exactly one parameter" + | None -> bad (Printf.sprintf "`%s` has no `receive` method" cn)) + | None -> + (* unknown class: the delegated Ctor check already reported E207 *) + { typ = TScalar "Int"; is_nil = false }) | Ctor (class_name, fields) -> (try let cls = StringMap.find class_name syms.classes in @@ -2448,7 +2528,7 @@ and walk_expr (bound : StringSet.t) (visit : StringSet.t -> expr -> unit) (e : e | Binary (_, l, r) -> walk_expr bound visit l; walk_expr bound visit r - | Ctor (_, fields) | Insert (_, fields) -> + | Ctor (_, fields) | Insert (_, fields) | Spawn (_, fields) -> List.iter (fun (_, v) -> walk_expr bound visit v) fields | Interp inner -> walk_expr bound visit inner | ListLit items -> List.iter (walk_expr bound visit) items @@ -2728,6 +2808,7 @@ let rec field_ty_str (ft : field_ty) : string = | Multi s -> "multi " ^ s | Map (k, v) -> "map<" ^ k ^ ", " ^ v ^ ">" | Backlink (c, f) -> "backlink " ^ c ^ "." ^ f + | Actor m -> "actor " ^ m | Nullable t -> "?" ^ field_ty_str t let dump_symbols (syms : symbols) : string = diff --git a/runtime/src/builtin.c b/runtime/src/builtin.c index b3b35f1..b0c83b6 100644 --- a/runtime/src/builtin.c +++ b/runtime/src/builtin.c @@ -72,6 +72,18 @@ int wo_builtin(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) { if (C >= WO_B_SYS_FIRST && C <= WO_B_PROC_RUN) return wo_builtin_sys(vm, R, ins, msg); if (C >= WO_B_DB_INSERT && C <= WO_B_DB_PROBE) return wo_builtin_db(vm, R, ins, msg); switch (C) { + case WO_B_SPAWN: { + /* arc: R[B] = the moved-in instance, R[B+1] = receive's method + * index (a compile-time constant); R[A] = the actor address */ + int rc = wo_vm_actor_spawn(vm, R[B], (uint32_t)R[B + 1], &R[A], msg); + return rc; + } + case WO_B_SEND: { + int rc = wo_vm_actor_send(vm, R[B], R[B + 1], msg); + if (rc) return rc; + R[A] = 0; + return 0; + } case WO_B_NOW: { /* wall-clock milliseconds */ struct timespec ts; clock_gettime(CLOCK_REALTIME, &ts); diff --git a/runtime/src/loader.c b/runtime/src/loader.c index eb04e19..cf27bec 100644 --- a/runtime/src/loader.c +++ b/runtime/src/loader.c @@ -48,6 +48,7 @@ static const uint8_t b_arity[WO_B_MAX + 1] = { [WO_B_DB_GET_FIELD] = 3, [WO_B_DB_PROBE] = 3, [WO_B_STR_LT] = 2, + [WO_B_SPAWN] = 2, [WO_B_SEND] = 2, /* arc: (instance, midx) / (addr, msg) */ [WO_B_NOW] = 0, [WO_B_PRINT] = 1, [WO_B_PRINT_INT] = 1, [WO_B_WORDS] = 1, [WO_B_MULTI_NEW] = 0, [WO_B_MULTI_PUSH] = 2, [WO_B_MULTI_GET] = 2, [WO_B_COUNT] = 1, [WO_B_LATEST] = 1, diff --git a/runtime/src/vm.c b/runtime/src/vm.c index 86ad58c..17e5cb1 100644 --- a/runtime/src/vm.c +++ b/runtime/src/vm.c @@ -28,7 +28,24 @@ int wo_vm_init(wo_vm *vm, const wo_module *mod, size_t heap_cap) { return wo_rt_init(&vm->rt, heap_cap, mod->classes, mod->class_cnt); } -void wo_vm_destroy(wo_vm *vm) { wo_rt_destroy(&vm->rt); } +void wo_vm_destroy(wo_vm *vm) { + /* actors first — dropping their state and queued messages needs the + * runtime alive */ + wo_actor *a = vm->actors; + while (a) { + wo_actor *nx = a->next_all; + if (a->instance) wo_drop_obj(&vm->rt, (wo_hdr *)(uintptr_t)a->instance); + for (uint32_t i = 0; i < a->mlen; i++) { + uint64_t m = a->msgs[(a->mhead + i) % a->mcap]; + if (m) wo_drop_obj(&vm->rt, (wo_hdr *)(uintptr_t)m); + } + free(a->msgs); + free(a); + a = nx; + } + vm->actors = NULL; + wo_rt_destroy(&vm->rt); +} /* ---- the run queue (stage 1 Task 2) ---------------------------------- */ @@ -78,6 +95,12 @@ static void fib_reap(wo_vm *vm, wo_fiber *fb) { vm->cur = fb; vm_unwind(vm, 0); vm->cur = save; + if (fb->actor) { + /* the in-flight message is the runtime's to drop */ + if (fb->cur_msg) wo_drop_obj(&vm->rt, (wo_hdr *)(uintptr_t)fb->cur_msg); + fb->cur_msg = 0; + fb->actor->active = NULL; + } if (fb != &vm->f0) { vm->nfibers--; free(fb); @@ -90,6 +113,90 @@ static void fib_reap_all(wo_vm *vm) { while ((fb = fib_dequeue(vm)) != NULL) fib_reap(vm, fb); } +/* ---- actors (arc stage 1 Task 3) -------------------------------------- */ + +static uint64_t actor_pop(wo_actor *a) { + uint64_t m = a->msgs[a->mhead]; + a->mhead = (a->mhead + 1) % a->mcap; + a->mlen--; + return m; +} + +static int actor_push(wo_actor *a, uint64_t m) { + if (a->mlen == a->mcap) { + uint32_t ncap = a->mcap ? a->mcap * 2 : 8; + uint64_t *nm = malloc((size_t)ncap * 8u); + if (!nm) return -1; + for (uint32_t i = 0; i < a->mlen; i++) nm[i] = a->msgs[(a->mhead + i) % a->mcap]; + free(a->msgs); + a->msgs = nm; + a->mhead = 0; + a->mcap = ncap; + } + a->msgs[(a->mhead + a->mlen) % a->mcap] = m; + a->mlen++; + return 0; +} + +/* Mailbox nonempty, no delivery fiber: start one on the next message. + * receive borrows both self and the message; the runtime keeps ownership + * of the message (fiber->cur_msg) and drops it when the call returns. */ +static int actor_activate(wo_vm *vm, wo_actor *a) { + uint64_t m = actor_pop(a); + uint64_t args[2] = { a->instance, m }; + wo_fiber *fb = wo_vm_spawn_fiber(vm, a->method, args, 2); + if (!fb) return -1; + fb->actor = a; + fb->cur_msg = m; + a->active = fb; + return 0; +} + +int wo_vm_actor_spawn(wo_vm *vm, uint64_t instance, uint32_t method_idx, + uint64_t *out_addr, const char **msg) { + if (method_idx >= vm->mod->method_cnt + || vm->mod->methods[method_idx].arg_cnt != 2) { + *msg = "spawn: receive must take (self, msg)"; + return WO_T_BOUNDS; + } + if (!instance) { + *msg = "spawn: nil instance"; + return WO_T_BOUNDS; + } + wo_actor *a = calloc(1, sizeof(*a)); + if (!a) { + *msg = "out of memory"; + return WO_T_OOM; + } + a->instance = instance; + a->method = method_idx; + a->next_all = vm->actors; + vm->actors = a; + *out_addr = (uint64_t)(uintptr_t)a; + return 0; +} + +int wo_vm_actor_send(wo_vm *vm, uint64_t addr, uint64_t msg_val, const char **msg) { + wo_actor *a = (wo_actor *)(uintptr_t)addr; + if (!a) { + *msg = "send: nil actor address"; + return WO_T_BOUNDS; + } + if (!msg_val) { + *msg = "send: nil message"; + return WO_T_BOUNDS; + } + if (actor_push(a, msg_val) != 0) { + *msg = "out of memory"; + return WO_T_OOM; + } + if (!a->active && actor_activate(vm, a) != 0) { + *msg = "out of memory"; + return WO_T_OOM; + } + return 0; +} + /* The drop-table entry governing instruction [pc]: the last one recorded * at or before it. NULL = nothing live there. */ static const wo_dropent *vm_dropent(const wo_methodrec *me, uint32_t pc) { @@ -165,6 +272,17 @@ static void vm_gc_roots(wo_vm *vm) { vm_gc_roots_fiber(vm, vm->cur); for (const wo_fiber *fb = vm->qhead; fb; fb = fb->next) vm_gc_roots_fiber(vm, fb); + /* actors: moved-in state, queued messages, and the in-flight message + * are runtime-owned — none sits in any frame's masks */ + for (const wo_actor *a = vm->actors; a; a = a->next_all) { + if (a->instance) wo_gc_scan_root(&vm->rt, (wo_hdr *)(uintptr_t)a->instance); + for (uint32_t i = 0; i < a->mlen; i++) { + uint64_t m = a->msgs[(a->mhead + i) % a->mcap]; + if (m) wo_gc_scan_root(&vm->rt, (wo_hdr *)(uintptr_t)m); + } + if (a->active && a->active->cur_msg) + wo_gc_scan_root(&vm->rt, (wo_hdr *)(uintptr_t)a->active->cur_msg); + } } /* One safepoint: start a cycle when the trigger says so (snapshot the @@ -514,20 +632,48 @@ dispatch: * wrapper returns nothing owned — Task 3's contract). The queue cannot be * empty when a spawned fiber ends: main never parks in stage 1, so it is * either live or queued. */ -#define FIBER_DONE(rv) \ - do { \ - if (vm->cur == &vm->f0) { \ - fib_reap_all(vm); \ - *ret = (rv); \ - return 0; \ - } \ - wo_fiber *dead = vm->cur; \ - vm->cur = fib_dequeue(vm); \ - vm->nfibers--; \ - free(dead); \ - vm->budget = vm->budget0; \ - RELOAD(); \ - NEXT(); \ +#define FIBER_DONE(rv) \ + do { \ + if (vm->cur == &vm->f0) { \ + fib_reap_all(vm); \ + *ret = (rv); \ + return 0; \ + } \ + wo_fiber *dead = vm->cur; \ + if (dead->actor) { \ + wo_actor *a = dead->actor; \ + if (dead->cur_msg) { \ + wo_drop_obj(&vm->rt, (wo_hdr *)(uintptr_t)dead->cur_msg); \ + dead->cur_msg = 0; \ + } \ + if (a->mlen) { \ + /* next message: REUSE this context, re-queued for \ + * fairness (one message per turn, never a monopolist) */ \ + uint64_t m_ = actor_pop(a); \ + const wo_methodrec *sme_ = &vm->mod->methods[a->method]; \ + dead->depth = 1; \ + dead->ncatch = 0; \ + dead->frames[0].method = a->method; \ + dead->frames[0].pc = 0; \ + dead->frames[0].base = 0; \ + dead->regs[0] = a->instance; \ + dead->regs[1] = m_; \ + memset(dead->regs + 2, 0, (size_t)(sme_->reg_cnt - 2) * 8u); \ + dead->cur_msg = m_; \ + fib_enqueue(vm, dead); \ + vm->cur = fib_dequeue(vm); \ + vm->budget = vm->budget0; \ + RELOAD(); \ + NEXT(); \ + } \ + a->active = NULL; \ + } \ + vm->cur = fib_dequeue(vm); \ + vm->nfibers--; \ + free(dead); \ + vm->budget = vm->budget0; \ + RELOAD(); \ + NEXT(); \ } while (0) CASE(RET) : { diff --git a/runtime/src/vm.h b/runtime/src/vm.h index aaf898d..e40035b 100644 --- a/runtime/src/vm.h +++ b/runtime/src/vm.h @@ -59,8 +59,25 @@ typedef struct wo_fiber { wo_err caught; wo_fib_state state; struct wo_fiber *next; /* intrusive FIFO link (run queue) */ + /* arc actors: when this fiber is an actor's delivery fiber, `actor` + * points at it and `cur_msg` is the message the current receive call + * borrows — the RUNTIME owns it and drops it after the call returns. */ + struct wo_actor *actor; + uint64_t cur_msg; } wo_fiber; +/* An actor: moved-in state, its receive method, a FIFO mailbox, and at + * most one delivery fiber at a time (one message at a time — the actor + * guarantee). Actors live until program end (v1: no actor death). */ +typedef struct wo_actor { + uint64_t instance; /* the moved-in state object (runtime-owned) */ + uint32_t method; /* receive's method index (self + msg = 2 args) */ + uint64_t *msgs; /* FIFO ring, growable */ + uint32_t mhead, mlen, mcap; + wo_fiber *active; /* the delivery fiber, NULL when idle */ + struct wo_actor *next_all; /* the vm's all-actors list */ +} wo_actor; + typedef struct wo_vm { const wo_module *mod; wo_rt rt; @@ -70,8 +87,14 @@ typedef struct wo_vm { uint32_t nfibers; /* live fibers besides main */ int64_t budget0; /* reductions per slice (WO_REDUCTIONS, default 4000) */ int64_t budget; /* countdown for the live fiber */ + wo_actor *actors; /* every spawned actor (torn down at destroy) */ } wo_vm; +/* arc: the spawn/send builtins' runtime halves (vm.c owns the scheduler). */ +int wo_vm_actor_spawn(wo_vm *vm, uint64_t instance, uint32_t method_idx, + uint64_t *out_addr, const char **msg); +int wo_vm_actor_send(wo_vm *vm, uint64_t addr, uint64_t msg_val, const char **msg); + /* Spawn a fiber that will run method_idx(args) — the runtime half the * `spawn` expression lowers onto (stage 1 Task 3); Task 2's tests drive it * directly. The fiber is RUNNABLE and queued; it runs when the scheduler diff --git a/runtime/src/wob.h b/runtime/src/wob.h index 9f49e6a..b578f1d 100644 --- a/runtime/src/wob.h +++ b/runtime/src/wob.h @@ -337,10 +337,13 @@ enum { WO_B_DB_SCAN = 64, WO_B_DB_GET_FIELD = 65, WO_B_DB_PROBE = 66, - WO_B_STR_LT = 67, /* (a, b) text -> 1 if a < b by content, else 0 (query + WO_B_STR_LT = 67, /* (a, b) text -> 1 if a < b by content, else 0 (query * order-by on a Text key; scalars use the LT opcode) */ + WO_B_SPAWN = 68, /* (instance, receive_method_idx) -> actor address (arc) */ + WO_B_SEND = 69, /* (address, msg) — msg moves to the runtime (arc) */ }; -#define WO_B_MAX 67u + +#define WO_B_MAX 69u /* ids at or above this one live in sysio.c, not builtin.c */ #define WO_B_SYS_FIRST WO_B_FS_EXISTS diff --git a/tests/corpus/compile-fail/send-after-move/fixture.code b/tests/corpus/compile-fail/send-after-move/fixture.code new file mode 100644 index 0000000..189d29f --- /dev/null +++ b/tests/corpus/compile-fail/send-after-move/fixture.code @@ -0,0 +1 @@ +WO-E301 diff --git a/tests/corpus/compile-fail/send-after-move/fixture.wo b/tests/corpus/compile-fail/send-after-move/fixture.wo new file mode 100644 index 0000000..4e65139 --- /dev/null +++ b/tests/corpus/compile-fail/send-after-move/fixture.wo @@ -0,0 +1,20 @@ +-- a message MOVES on send: the sender's binding is dead afterwards + +class Note { + text: Text +} + +class Sink { + pad: Int + fn receive(msg: Note) { + print(msg.text); + } +} + +fn main() -> Int { + let a = spawn Sink { pad: 0 }; + let n = Note { text: "gone" }; + send(a, n); + print(n.text); + return 0; +} diff --git a/tests/corpus/compile-fail/spawn-no-receive/fixture.code b/tests/corpus/compile-fail/spawn-no-receive/fixture.code new file mode 100644 index 0000000..58664ce --- /dev/null +++ b/tests/corpus/compile-fail/spawn-no-receive/fixture.code @@ -0,0 +1 @@ +WO-E221 diff --git a/tests/corpus/compile-fail/spawn-no-receive/fixture.wo b/tests/corpus/compile-fail/spawn-no-receive/fixture.wo new file mode 100644 index 0000000..f1245cb --- /dev/null +++ b/tests/corpus/compile-fail/spawn-no-receive/fixture.wo @@ -0,0 +1,10 @@ +-- spawn's target must be an actor: a class with fn receive(msg: M) + +class Silent { + n: Int +} + +fn main() -> Int { + let a = spawn Silent { n: 1 }; + return 0; +} diff --git a/tests/corpus/run/actor-echo/fixture.out b/tests/corpus/run/actor-echo/fixture.out new file mode 100644 index 0000000..8fb2a52 --- /dev/null +++ b/tests/corpus/run/actor-echo/fixture.out @@ -0,0 +1,4 @@ +log: first #1 +log: second #2 +log: third #3 +main done diff --git a/tests/corpus/run/actor-echo/fixture.wo b/tests/corpus/run/actor-echo/fixture.wo new file mode 100644 index 0000000..d600e84 --- /dev/null +++ b/tests/corpus/run/actor-echo/fixture.wo @@ -0,0 +1,34 @@ +-- the arc: spawn/send/actor M — an actor is a class with receive(msg: M); +-- messages MOVE; delivery is one message at a time; main's return reaps +-- idle actors clean (ASan gate covers this fixture too). + +class Note { + text: Text +} + +class Logger { + prefix: Text + seen: Int + fn receive(msg: Note) { + self.seen = self.seen + 1; + print("${self.prefix}${msg.text} #${self.seen}"); + } +} + +fn main() -> Int { + let a: actor Note = spawn Logger { prefix: "log: ", seen: 0 }; + send(a, Note { text: "first" }); + send(a, Note { text: "second" }); + let n = Note { text: "third" }; + send(a, n); + -- the scheduler drains actor messages when main yields or ends; a + -- backward-jump loop hands turns over under the default budget? No: + -- default budget is 4000 — force turns with a tiny spin so delivery + -- interleaves before main returns + let i = 0; + while i < 20000 { + i = i + 1; + } + print("main done"); + return 0; +}