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 (runs as written: "Try it" on the docs page, or natively):
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(io : Cap(IO)) 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
endcount = 1Types
Why Actor.inspect_state could not answer: InspectTimeout: the actor did not get to the request in time (a long handler, a backlog ahead of it, or it was inside a nested receive); InspectDead: the pid is not alive; InspectSelf: an actor asked about itself (it would wait on itself); InspectFailed: the actor could not render its state (the reason).
Functions
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.
match Actor.call(pid, GetReq, 5000) do
Ok(n) -> println("count = " ++ int_to_string(n))
Err(e) -> println("no answer: " ++ e)
endSee the module header for a complete program.
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.
let ref = Actor.send_after(pid, Tick, 1000)
Actor.cancel_timer(ref) -- Tick is never delivered
Actor.cancel_timer(ref) -- a second cancel is a no-opFire-and-forget: enqueue msg in the actor's mailbox without waiting. msg is a constructor the actor has an on handler for. Delivery is subject to the actor's mailbox policy (see set_queue_limit).
Actor.cast(pid, Inc(1))
run_until_idle() -- let the actor process itMint the debug capability from the root capability: mint it once in main and forward it only to code that may read actor state.
fn main(io : Cap(IO)) do
let d = Actor.debug(io)
match Actor.inspect_state(d, pid, 1000) do ... end
endThe actor's current state, rendered as text (the same rendering to_string gives a record). The request queues behind every message already sent to the actor and is answered between two handlers on the actor's own thread, so the state is never seen half-updated; a full or bounded mailbox does not drop it. An actor inside a nested receive() answers InspectTimeout at once, and its receive() still gets its real message. Within timeout_ms or Err(InspectTimeout).
match Actor.inspect_state(d, pid, 1000) do
Ok(text) -> println(text)
Err(Actor.InspectTimeout) -> println("busy")
Err(e) -> println("cannot inspect")
endMint the introspection capability from the root capability. The only way to obtain a Cap(Actor.Introspect): mint it once in main and forward it to the code that needs it.
fn main(io : Cap(IO)) do
let c = Actor.introspect(io)
match Actor.whereis(c, "counter") do ... end
endIs pid stopping — accepting no new messages, but not dead yet? is_alive alone cannot express this: a draining actor is still alive. false for a live actor that is not stopping and for a dead one.
Actor.stop(pid, 5000)
Actor.is_draining(pid) -- true until the queue is worked offEvery 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(c), 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(c, 500) != Nil.
List.each(Actor.over_mailbox(c, 500), fn hot ->
match hot do (p, d) -> log_slow_actor(p, d) end)Depth is mailbox_backlog, as for top_by_mailbox. Same snapshot caveats. A threshold below zero returns every live actor.
Turn a spawn index (what pid_to_int returns, or what get_actor_field reads out of a supervisor's state) back into a Pid. An unknown index resolves to a safe already-dead sentinel, so is_alive on it is false and send to it returns None.
let c = Actor.introspect(io)
let p = Actor.pid_from_int(c, pid_to_int(worker)) -- the same actor as workerRegister 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.
let pid = spawn(Counter)
Actor.register(pid, "counter") -- true
Actor.register(spawn(Counter), "counter") -- false: held by a live actorEvery currently-registered name, as a list of strings. A snapshot, like list.
List.each(Actor.registered(c), fn name -> println(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.
on GetCount(reply_to) do
Actor.reply(reply_to, state.count)
state
endSchedule 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.
Actor.set_queue_limit(pid, 1000, 2) -- keep the newest 1000 messagesStop 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.
Actor.stop(pid, 5000) -- true: drain for up to 5 s, then die normally
Actor.stop(pid, 5000) -- false: already stoppingThe 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(c, 1) do
Cons((hot, depth), _) -> shed_from(hot, depth)
Nil -> ()
endThe depth is the actor's waiting work, mailbox_backlog: its queued messages plus any that an Actor.call it is blocked in has set aside until the reply. An actor stuck in a slow call with work piling up is exactly the one this should find, and mailbox_size alone reads 0 for it.
A snapshot: it walks Actor.list(c) and asks mailbox_backlog 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. Needs no capability: releasing a name reveals nothing.
Examples
march> Actor.unregister("doc_never_registered")
falseResolve 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. Registering is not authority; resolving a name you were never handed is, so this takes the cap.
match Actor.whereis(c, "counter") do
Some(pid) -> Actor.cast(pid, Inc(1))
None -> () -- not running, or mid-restart
end