Actor
Actor module: messaging helpers for the actor system.
cast/2 is fire-and-forget (async): the message is enqueued and the caller continues immediately without waiting for a response.
call/3 is synchronous: it sends the given message to the target actor and blocks until the actor calls Actor.reply(reply_to, result). Returns Ok(result), or Err(reason) if no reply arrives.
call protocol: Pass a zero-arg sentinel constructor as the call message. Its tag selects which handler receives the call, and the runtime injects the caller (the "reply channel") as that handler's first argument. The handler must call Actor.reply(reply_to, result) to unblock the caller; if it never replies, call returns Err("no reply ...").
So the call handler must be declared FIRST in the actor (tag 0), and
the sentinel must have a name distinct from that handler to avoid a
constructor-name clash.Example:
type GetReq = GetReq -- zero-arg sentinel for the sync call
actor Counter do
state { count : Int }
init { count: 0 }
-- First handler (tag 0) = the call handler; reply_to is the caller.
on GetCount(reply_to) do
Actor.reply(reply_to, state.count)
state
end
on Inc(n : Int) do
{ state with count: state.count + n }
end
end
fn main() do
let pid = spawn(Counter)
Actor.cast(pid, Inc(1))
run_until_idle()
match Actor.call(pid, GetReq, 5000) do
Ok(n) -> println("count = " ++ int_to_string(n))
Err(e) -> println("error: " ++ e)
end
endFunctions
Synchronous call: send msg (a zero-arg sentinel) to the actor and wait for a reply. The runtime injects the caller as the target handler's first argument; that handler must call Actor.reply(reply_to, result) to unblock the caller. Returns Ok(result) or Err(reason). timeout_ms is a real wall-clock deadline on both backends: a reply produced after it has passed is discarded and the call returns Err, so the timeout you write is a bound you can build retry and fallback logic on.
Cancel a pending Actor.send_after timer. Safe to call at any time, including after the timer has already fired or been cancelled already (both are harmless no-ops) — delivers nothing if called before the deadline.
Fire-and-forget: enqueue msg in the actor's mailbox without waiting.
Is pid stopping — accepting no new messages, but not dead yet? is_alive alone cannot express this: a draining actor is still alive.
Every actor alive right now. The enumeration primitive monitoring code needs: mailbox_size(pid) can only be asked about a Pid you already hold, so without this there is no way to discover an unknown hot actor — only ones you can name in advance with whereis.
A snapshot, and inherently racy: an actor can die between this call and
anything you do with the result. That is already the norm for a Pid, and
every consumer handles a dead one.
let hot = List.filter(Actor.list(), fn p -> mailbox_size(p) > 500)
Ordered by spawn sequence, so the list is deterministic rather than ordered
by heap address.Every actor whose mailbox is deeper than threshold, as (pid, depth) pairs in spawn order — the growing-mailbox alarm, polled. An empty result is the healthy case, so a monitor can be if Actor.over_mailbox(500) != Nil.
List.each(Actor.over_mailbox(500), fn hot ->
match hot do (p, d) -> log_slow_actor(p, d) end)
Same snapshot caveats as `top_by_mailbox`. A threshold below zero returns
every live actor.Register pid under name, so holders can re-resolve it after a restart. Returns false if the name is already held by a LIVE actor, or if pid is dead. A registered actor's name is carried forward across supervisor restarts, so callers should hold the name, not the Pid.
Every currently-registered name.
Reply to a synchronous call from within an actor handler. ref_id is the reply channel — the handler's first argument, injected by Actor.call. result is the value to return to the caller.
Schedule msg for delivery to pid after delay_ms milliseconds — a fire-once timer built on the same scheduler primitive that already backs Actor.call's timeout and supervisor restart backoff. Returns a TimerRef usable with cancel_timer. Message delivery goes through the actor's normal mailbox, so msg follows the same "constructor the actor has an on handler for" convention as Actor.cast — cast(pid, Inc(1)) and send_after(pid, Inc(1), 1000) reach the exact same dispatch path, just on different schedules.
A pending timer does not keep run_until_idle() waiting: if nothing else is
happening, run_until_idle() returns even while a send_after is still
pending, and delivers it whenever it's next called after the deadline has
passed. A real long-running process (an IO.Process-style server) is kept
alive by its own non-daemon/actor processes, not by run_until_idle, so this
does not affect whether the message eventually arrives — only whether
waiting for it blocks a test harness.
let pid = spawn(Worker)
let ref = Actor.send_after(pid, Tick, 100)
-- ... later, before it fires ...
Actor.cancel_timer(ref)Bound this actor's mailbox. policy: 0 unbounded (default), 1 drop_new, 2 drop_old, 3 block_sender (compiled backend only: the interpreter's single-threaded eager scheduler cannot park a sender, so under march run policy 3 is refused at this call rather than silently treated as 0). Dropped messages are counted in Scheduler.dropped_messages().
Under a drop policy (1 or 2), a dropped Actor.call request or its reply is
indistinguishable from a lost reply at the caller — both surface as a
timeout Err from Actor.call. Callers that rely on Actor.call against a
bounded actor should prefer the block policy (3) instead, so no request
or reply is ever silently discarded.Stop pid gracefully: it accepts no further messages, works off whatever is already queued, and then dies a NORMAL death (which no restart type restarts). Returns false if it was already dead or already stopping.
This is the deploy story `kill` cannot tell. `kill(pid)` is immediate and
drops the mailbox, so stopping a busy actor loses exactly the requests that
were waiting; `stop` finishes them first.
Waits up to `timeout_ms` for the queue to empty, then gives up and dies with
whatever is left undelivered. A negative timeout waits indefinitely; 0
discards the queue as soon as the in-flight message returns.
Stopping a supervisor stops its children first, in REVERSE declaration
order, each with its own `shutdown` budget from the `supervise` block's
child spec (`Worker w shutdown 5000`), defaulting to 5 seconds.The n deepest mailboxes right now, deepest first, as (pid, depth) pairs. This is the "which actor is behind?" question a monitoring loop actually asks; Scheduler counters only answer "is the system behind?".
match Actor.top_by_mailbox(1) do
Cons((hot, depth), _) -> shed_from(hot, depth)
Nil -> ()
end
A snapshot: it walks `Actor.list()` and asks `mailbox_size` of each, so an
actor can die or drain between the walk and your reaction. Ties keep spawn
order. Cost is one pass over every live actor plus a sort, so call it from a
timer, not from a hot path.Remove a name. Returns false if it was not registered.
Resolve a name to the actor currently holding it. Returns None if the name is unregistered, or if its actor has died — including the window while a supervised actor is waiting out its restart backoff, which is the honest signal that the service is mid-restart.