Stateful Actors
Stateful Actors
Section titled “Stateful Actors”Build an actor that holds private state, dispatches on message values, and survives supervised restart with a fresh state allocation.
Time: 45 minutes
Level: Intermediate
Prerequisites: Basic Janus syntax, Structured Concurrency tutorial
What you’ll learn: Actor declarations, the supervised-actor symbol surface, slot-table state, match __msg dispatch, spawn + restart semantics
Profile: :cluster
Why Actors?
Section titled “Why Actors?”A nursery from the Structured Concurrency tutorial coordinates concurrent tasks under a parent scope. That gives you “no orphan fibers, no leaked errors.” It does not give you a process that lives independently of its caller, holds its own state, and recovers automatically when it crashes.
That is what an actor is.
An actor is a long-lived concurrent entity with private state, a message inbox, and a supervisor that restarts it on failure.
In Janus, every actor X do … end declaration compiles to a
spawn-form loop plus a supervised-actor surface:
| Symbol | Role |
|---|---|
__X_loop() | The message loop, used by spawn X(). |
X_setup() | Allocates and initializes the actor’s state slot table. |
X_handler(actor, msg) | Processes one message at a time using the state table. |
X_timeout(actor) | Runs the after timeout body when the actor has a receive timeout arm. |
X_destroy(state) | Releases the state when the actor is torn down. |
X_start_supervised(system, slot, policy) | Starts the actor under LocalActorSystem without user-written function addresses. |
X_start_supervised_ref(system, slot, policy) | Starts the actor and returns a stable supervised local actor reference. |
The bottom three are the supervised-actor triple. A Janus
supervisor wires them into a ChildSpec so that:
- Spawn runs
X_setup, gets the state pointer. - Each delivered message calls
X_handler(actor, msg). - If the receive body has
after N => ...and no message arrives before the mailbox timeout, the runtime callsX_timeout(actor). - On crash, the supervisor runs
X_destroy(old_state)and then re-runsX_setupto get a fresh allocation, then re-binds the same handler. State is reset on restart by design.
X_start_supervised is the compatibility wrapper over that triple and
returns the transient ActorId. X_start_supervised_ref returns a stable
reference to the supervised child slot. Production Janus code should use
the _ref form for send and observation paths instead of passing
X_setup / X_handler / X_destroy through the raw bridge.
This tutorial walks through both forms with a counter actor.
Step 1: A Minimal Actor (5 min)
Section titled “Step 1: A Minimal Actor (5 min)”The smallest valid actor body has a receive block. Even an empty
receive compiles — the auto-generated triple keeps a stateless
actor lawful inside the supervisor.
actor Heartbeat do receive do // every received message is silently consumed endend
func main() !i64 do let _ = spawn Heartbeat() return 0end$ janus build stateless.jan stateless$ nm stateless | grep -E "Heartbeat|__Heartbeat"0000000001027170 T Heartbeat_destroy0000000001027160 T Heartbeat_handler0000000001027150 T Heartbeat_setup0000000001027180 T Heartbeat_start_supervised0000000001027190 T Heartbeat_start_supervised_ref0000000001027120 T __Heartbeat_loopThe expected symbols are all present. spawn Heartbeat() uses
__Heartbeat_loop. The generated Heartbeat_start_supervised and
Heartbeat_start_supervised_ref wrappers are available for supervised
use even though this example does not invoke them.
Step 2: Add State (10 min)
Section titled “Step 2: Add State (10 min)”Add a var declaration at the top of the actor body to allocate a
state slot. Reads and assignments inside receive automatically
route through the slot table.
actor Counter do var count: i64 = 0
receive do count = count + 1 endend
func main() !i64 do let _ = spawn Counter() return 0end$ janus build counter_unconditional.jan counter$ objdump -d --disassemble=Counter_setup counter | head -10<Counter_setup>: sub $0x8,%rsp mov $0x1,%edi ; slot_count = 1 (one var) call janus_actor_state_alloc xor %edx,%edx ; init value = 0 xor %esi,%esi ; slot = 0 mov %rax,%rdi ; state_base call janus_actor_state_slot_store mov %rax,%rdi ; return state_base add $0x8,%rsp retCounter_setup allocates one slot, initializes it to 0, and
returns the state base pointer.
$ objdump -d --disassemble=Counter_handler counter | head -8<Counter_handler>: mov %rdi,%rsi ; actor handle call janus_actor_state_ptr ; state_base = actor.user_data xor %esi,%esi ; slot = 0 mov %rax,%rdi call janus_actor_state_slot_load add $0x1,%rax ; count + 1 ... call janus_actor_state_slot_store xor %eax,%eax ; return 0 (continue) retEvery message increments count. Identifier load → slot_load,
arithmetic stays on i64, assignment → slot_store. The
compiler does the slot-mapping; you write Janus.
Slot ABI today is
u64. Annotations likei64,u32, orboolparse but the slot is still a 64-bit word. Typed slots are tracked as future work — see the v2026.5.15 release notes for the current limits.
Step 3: Dispatch on Message Value (10 min)
Section titled “Step 3: Dispatch on Message Value (10 min)”A real actor responds differently to different messages. __msg is
the implicit binding for the received i64 message. You can dispatch
explicitly with match __msg { ... }:
actor Counter do var count: i64 = 0
receive do match __msg { 0 => do count = count + 1 end, 1 => do count = count - 1 end, 2 => do count = 0 end, _ => do count = count end, } endend
func main() !i64 do let _ = spawn Counter() return 0end$ janus build counter_dispatch.jan counter_dispatch$ objdump -d --disassemble=Counter_handler counter_dispatch | wc -lThe handler now contains real dispatch — a chain of cmp /
conditional branches that route the message to the right arm body.
Arm 0 increments, arm 1 decrements, arm 2 resets, anything
else is a no-op.
Bare-Arm Shorthand
Section titled “Bare-Arm Shorthand”For simple tag dispatch, receive also accepts bare arms. The parser
desugars them to the same match __msg { ... } shape:
actor Counter do var seen: i64 = 0
receive do 0 => do seen = seen + 1 end, 1 => do seen = seen - 1 end, else => do seen = seen end, endend
func main() !i64 do let _ = spawn Counter() return 0endUse explicit match __msg when the receive body needs setup work
before dispatch or when the full match expression reads more clearly.
Use bare arms when the receive body is only message-tag dispatch. Both
forms are covered by the actor smoke suite.
Bare receive arms also accept guards and direct receive-loop timeouts:
receive do Msg.Set { value } when value >= 0 as u64 => do count += value end, after 30_000 => do count = count end,endStep 4: Supervised Lifecycle (15 min)
Section titled “Step 4: Supervised Lifecycle (15 min)”The spawn X() form runs the actor’s message loop directly — fine
for a worker that you trust to never fail. To get automatic
restart on crash, route the actor through a supervisor.
The compiler emits generated wrappers for each actor:
X_start_supervised(system: u64, slot: u64, policy: u32) -> u64X_start_supervised_ref(system: u64, slot: u64, policy: u32) -> u64The first wrapper returns the transient ActorId. The _ref wrapper
returns a stable local actor reference that addresses the supervised slot
across restarts. If you stop the child and reuse the slot, the old reference is
invalidated instead of aliasing the replacement. Call the _ref wrapper for
production send and observation paths instead of manually threading X_setup /
X_handler / X_destroy addresses through the low-level bridge.
use std.cluster.local as cluster
actor Counter do var count: i64 = 0
receive do match __msg { 0 => do count = count + 1 end, _ => do count = count end, } endend
pub func main() -> i32 do if cluster.local_test_reset_user_actors() != 0 as i32 do return 1 end
// Create a supervisor with one slot. let sys = cluster.local_new( 1 as u64, // node id cluster.STRATEGY_ONE_FOR_ONE, // restart strategy 1 as u64, // child slot count ) if sys == 0 as u64 do return 2 end
// Start the supervised counter at slot 0 and keep the stable ref. let counter = Counter_start_supervised_ref( sys, 0 as u64, cluster.POLICY_PERMANENT, ) if counter == 0 as u64 do return 3 end
// Send a message into the mailbox. if cluster.local_ref_try_send(counter, 0 as i64) != 1 as i32 do return 4 end
// Simulate a crash. The supervisor will: // 1. call Counter_destroy(old_state) // 2. call Counter_setup() to allocate a fresh state // 3. re-bind the same handler // The new ActorId is 2 (incremented on restart). if cluster.local_handle_crash(sys, 0 as u64) != 1 as i32 do return 5 end if cluster.local_ref_child_actor_id(counter) != 2 as i32 do return 6 end if cluster.local_restart_count(sys, 0 as u64) != 1 as u32 do return 7 end if cluster.local_ref_mailbox_len(counter) != 0 as i64 do return 8 end
// Cooperative shutdown. if cluster.local_shutdown(sys) != 1 as i32 do return 9 end if cluster.local_destroy(sys) != 1 as i32 do return 10 end
return 0end$ janus build supervised_counter.jan supervised_counter$ ./supervised_counter ; echo "exit: $?"exit: 0The exit code encodes which subtest tripped; 0 means the entire
spawn → send → crash → restart → shutdown cycle worked. After the
simulated crash:
Counter_destroyran on the original state allocation.Counter_setupallocated a fresh state withcount = 0again.cluster.local_ref_child_actor_idreturns2, reflecting the new Actor instance.cluster.local_restart_countis1.- The stable local actor reference still addresses the same supervised slot after restart.
Public Janus no longer asks callers to manufacture function addresses for actor
entry points. Use generated X_start_supervised_ref wrappers or typed-callable
helpers in std.cluster.local; the runtime-entry integer is bridge plumbing.
Repeated abnormal exits also produce classifier-visible tombstone patterns:
let matches = cluster.local_tombstone_classify_match_count( sys, 4000000000 as i64, 3 as u32, 4000000000 as i64,)let deadly = cluster.local_tombstone_classify_deadly( sys, 4000000000 as i64, 3 as u32, 4000000000 as i64,)Use the classifier for diagnostics and replay suppression decisions. It does not expose actor-local state.
State is intentionally reset on restart. This is the actor model’s lifecycle guarantee, not a bug. If you need persistence across restarts, the state belongs in a database (see the Persistent Transparency Ledger tutorial), not in actor
varslots.
Step 5: How the Compiler Builds the Supervised Surface (5 min)
Section titled “Step 5: How the Compiler Builds the Supervised Surface (5 min)”For an actor declaration:
actor X do var a: i64 = 1 var b: i64 = 42
receive do // body endendThe compiler does the following at Pass 0 (emitClusterActorTriple
in compiler/qtjir/lower.zig):
- Scan the actor body for
var_stmtchildren. Build aname → slot_indexmap:{a: 0, b: 1}. - Emit
X_setup():state = janus_actor_state_alloc(2)janus_actor_state_slot_store(state, 0, 1)janus_actor_state_slot_store(state, 1, 42)return state - Emit
X_handler(actor, msg)by:- calling
janus_actor_state_ptr(actor)to recover the state base fromActor.user_data, - registering the slot map on the lowering context so
lowerIdentifierandlowerBinaryExprroutea/breferences throughjanus_actor_state_slot_loadandjanus_actor_state_slot_store, - lowering each statement in the receive body as if it were a regular block.
- calling
- Emit
X_destroy(state)=janus_actor_state_free(state). - Emit
X_timeout(actor)when the receive body has anafter N => ...arm. It recovers actor state withjanus_actor_state_ptr(actor), lowers the timeout body, and returns0by default when the arm body does not terminate. - Emit
X_start_supervised(system, slot, policy)by creating function references forX_setup,X_handler, andX_destroy. If a timeout arm exists, the wrapper also referencesX_timeoutand callscluster_local_start_stateful_actor_with_mailbox_timeout. Otherwise it calls the non-timeout start helper. - Emit
X_start_supervised_ref(system, slot, policy)with the same function references. Timeout actors call the_reftimeout bridge; other actors callcluster_local_start_stateful_actor_ref_with_mailboxso the caller receives the stable supervised local actor reference.
No new IR ops were introduced. The surface is plain
QTJIR — Call nodes against named runtime helpers, Argument /
Constant / Return nodes for the function shapes. Existing
lowerMatch carries the dispatch for arm bodies.
Current Limits
Section titled “Current Limits”Phase B opens the surface. These rough edges are tracked for future sprints:
- Slots are
u64. Type annotations parse but every slot is a 64-bit word. Pointer-typed state, struct-typed state, and heterogeneous tables are future work. - Raw bridge messages are still
i64. Low-level helpers such aslocal_try_sendandlocal_ref_try_sendsend raw tags. TypedActorRef[Msg]sends lower payload variants to boxed local message envelopes. - Payload messages are node-local.
messagedeclarations,actor Name(msg: Msg),ActorRef[Msg], payload-bearing local sends, and guarded receive-body destructuring are live. Distributed payload wire formats remain future work. receive afteris a local mailbox timeout. Direct receive loops and compiler-generated supervised actors both supportafter N => .... The supervised path registers a generatedX_timeout(actor)handler with the runtime mailbox timeout.grainandsupervisordeclarations shown on the:clusterprofile page are aspirational sketches. Phase B covers the localactorsurface.
What You Built
Section titled “What You Built”- A stateless actor (Step 1) — the compiler still emits the loop, supervised triple, and start wrapper; the supervisor can adopt it.
- A counter that updates on every message (Step 2) — slot table allocation, slot load/store automatic.
- A dispatching counter (Step 3) —
match __msgroutes messages to per-arm bodies. - A supervised counter (Step 4) — spawn, send, crash, restart, and the auto-generated ref wrapper wired into the supervisor.
You also used the parser desugaring that turns compact
receive do pattern => body end arms into explicit match __msg
dispatch.
Next step: read the v2026.5.15 release notes
for the full ABI summary, or jump to Structured
Concurrency if you have not seen how
nurseries underlie spawn.