feat: spawn / send / actor M — the unified actor surface (arc T3)

- language: `spawn Cls { fields }` expression (ctor semantics — fields
  MOVE; result is the address); `actor M` parametric field type
  (contextual like multi/map — actor stays a legal identifier); `send`
  is a builtin free-fn name, not a keyword (shadowing rule applies)
- typing: M inferred from Cls's receive(msg: M); WO-E221 when receive
  is missing, mis-armed, or M is not a class/record/union; send checks
  addr is `actor M` and the message IS an M (silent when underivable);
  ctor half of spawn delegates to the Ctor arm (completeness, ?T, E207)
- ownership: send's message TRANSFERS (sender's later use = WO-E301,
  corpus-pinned); spawn's fields move via the ctor machinery; an
  address is Copy
- emit: spawn lowers to ctor + LOADK receive's method index + BUILTIN
  68; send is BUILTIN 69 with the message excluded from fresh-arg drops
  (the runtime owns it now)
- runtime: wo_actor (moved-in instance, receive idx, growable FIFO
  mailbox, one delivery fiber at a time); delivery reuses the fiber
  context across messages and re-queues per message (fairness — an
  actor never monopolizes); the runtime drops each message after its
  receive returns; actor state/queued/in-flight messages are GC roots;
  teardown drops everything (main-return reap included); loader knows
  the two arities
- corpus: run/actor-echo (typed spawn/send, one-at-a-time delivery
  interleaved with main by budget — output exact, ASan-clean),
  compile-fail/spawn-no-receive (WO-E221), send-after-move (WO-E301)
- battery green: oop-e2e 92/0, woc-test, wovm-test, log-watcher 7/0,
  employee 8/0, web-app 21/0, deps-accept 8/0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
shoney.arickathil 2026-08-20 07:21:24 +02:00
parent 4f1b04d02d
commit 77c9090ce5
20 changed files with 454 additions and 23 deletions

View file

@ -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<K, V>`. They lower to exactly what `multi_new()`/`map_new()`

View file

@ -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 } ->

View file

@ -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

View file

@ -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 =

View file

@ -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

View file

@ -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

View file

@ -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) }

View file

@ -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

View file

@ -19,6 +19,7 @@ type typ =
| TMulti of typ (* multi T *)
| TMap of typ * typ (* map<K, V> *)
| 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 =

View file

@ -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);

View file

@ -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,

View file

@ -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) : {

View file

@ -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

View file

@ -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

View file

@ -0,0 +1 @@
WO-E301

View file

@ -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;
}

View file

@ -0,0 +1 @@
WO-E221

View file

@ -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;
}

View file

@ -0,0 +1,4 @@
log: first #1
log: second #2
log: third #3
main done

View file

@ -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;
}