Skip to content

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


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:

SymbolRole
__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:

  1. Spawn runs X_setup, gets the state pointer.
  2. Each delivered message calls X_handler(actor, msg).
  3. If the receive body has after N => ... and no message arrives before the mailbox timeout, the runtime calls X_timeout(actor).
  4. On crash, the supervisor runs X_destroy(old_state) and then re-runs X_setup to 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.


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.

stateless.jan
actor Heartbeat do
receive do
// every received message is silently consumed
end
end
func main() !i64 do
let _ = spawn Heartbeat()
return 0
end
Terminal window
$ janus build stateless.jan stateless
$ nm stateless | grep -E "Heartbeat|__Heartbeat"
0000000001027170 T Heartbeat_destroy
0000000001027160 T Heartbeat_handler
0000000001027150 T Heartbeat_setup
0000000001027180 T Heartbeat_start_supervised
0000000001027190 T Heartbeat_start_supervised_ref
0000000001027120 T __Heartbeat_loop

The 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.


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.

counter_unconditional.jan
actor Counter do
var count: i64 = 0
receive do
count = count + 1
end
end
func main() !i64 do
let _ = spawn Counter()
return 0
end
Terminal window
$ 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
ret

Counter_setup allocates one slot, initializes it to 0, and returns the state base pointer.

Terminal window
$ 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)
ret

Every 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 like i64, u32, or bool parse 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 { ... }:

counter_dispatch.jan
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,
}
end
end
func main() !i64 do
let _ = spawn Counter()
return 0
end
Terminal window
$ janus build counter_dispatch.jan counter_dispatch
$ objdump -d --disassemble=Counter_handler counter_dispatch | wc -l

The 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.

For simple tag dispatch, receive also accepts bare arms. The parser desugars them to the same match __msg { ... } shape:

counter_bare_arm.jan
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,
end
end
func main() !i64 do
let _ = spawn Counter()
return 0
end

Use 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,
end

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) -> u64
X_start_supervised_ref(system: u64, slot: u64, policy: u32) -> u64

The 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.

supervised_counter.jan
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,
}
end
end
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 0
end
Terminal window
$ janus build supervised_counter.jan supervised_counter
$ ./supervised_counter ; echo "exit: $?"
exit: 0

The exit code encodes which subtest tripped; 0 means the entire spawn → send → crash → restart → shutdown cycle worked. After the simulated crash:

  • Counter_destroy ran on the original state allocation.
  • Counter_setup allocated a fresh state with count = 0 again.
  • cluster.local_ref_child_actor_id returns 2, reflecting the new Actor instance.
  • cluster.local_restart_count is 1.
  • 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 var slots.


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

The compiler does the following at Pass 0 (emitClusterActorTriple in compiler/qtjir/lower.zig):

  1. Scan the actor body for var_stmt children. Build a name → slot_index map: {a: 0, b: 1}.
  2. 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
  3. Emit X_handler(actor, msg) by:
    • calling janus_actor_state_ptr(actor) to recover the state base from Actor.user_data,
    • registering the slot map on the lowering context so lowerIdentifier and lowerBinaryExpr route a / b references through janus_actor_state_slot_load and janus_actor_state_slot_store,
    • lowering each statement in the receive body as if it were a regular block.
  4. Emit X_destroy(state) = janus_actor_state_free(state).
  5. Emit X_timeout(actor) when the receive body has an after N => ... arm. It recovers actor state with janus_actor_state_ptr(actor), lowers the timeout body, and returns 0 by default when the arm body does not terminate.
  6. Emit X_start_supervised(system, slot, policy) by creating function references for X_setup, X_handler, and X_destroy. If a timeout arm exists, the wrapper also references X_timeout and calls cluster_local_start_stateful_actor_with_mailbox_timeout. Otherwise it calls the non-timeout start helper.
  7. Emit X_start_supervised_ref(system, slot, policy) with the same function references. Timeout actors call the _ref timeout bridge; other actors call cluster_local_start_stateful_actor_ref_with_mailbox so 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.


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 as local_try_send and local_ref_try_send send raw tags. Typed ActorRef[Msg] sends lower payload variants to boxed local message envelopes.
  • Payload messages are node-local. message declarations, 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 after is a local mailbox timeout. Direct receive loops and compiler-generated supervised actors both support after N => .... The supervised path registers a generated X_timeout(actor) handler with the runtime mailbox timeout.
  • grain and supervisor declarations shown on the :cluster profile page are aspirational sketches. Phase B covers the local actor surface.

  • 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 __msg routes 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.