Skip to content

v2026.5.15 - :cluster Phase B: Actor State Lowering

v2026.5.15 - :cluster Phase B: Actor State Lowering

Section titled “v2026.5.15 - :cluster Phase B: Actor State Lowering”

Release date: 2026-05-15 Profiles affected: :cluster Status: Compiler feature closure

Phase B closes the supervised-actor triple emission that Phase A stubbed out. Every actor X do var ... receive do ... end end declaration now compiles to the spawn-form loop plus a real supervised actor surface instead of a sentinel-return triple:

  • X_setup() allocates the state slot table and runs each var declaration’s initializer into its slot.
  • X_handler(actor: u64, msg: i64) resolves identifiers and assignments against the slot table and dispatches messages.
  • X_destroy(state: u64) releases the slot table.
  • X_start_supervised(system: u64, slot: u64, policy: u32) -> u64 starts the actor under LocalActorSystem without user-written function-address plumbing.
  • X_start_supervised_ref(system: u64, slot: u64, policy: u32) -> u64 starts the actor and returns a stable supervised local actor reference for send and observation paths.
  • __X_loop() is the spawn-form actor loop, used by spawn X().
  • std.cluster.local exposes scalar local-status accessors for supervisor state, child lifecycle, task state, last exit reason, and mailbox pressure. These are observation-only and do not expose actor state.
  • local_stop_child(system, slot, reason) stops a child without applying restart policy. Failure reasons still produce tombstones; shutdown and normal reasons do not.
  • Tombstone hot-index classification is observable from Janus through local_tombstone_classify_match_count, local_tombstone_classify_deadly, and local_tombstone_classify_oldest_sequence.

The supervisor wires X_setup / X_handler / X_destroy into the ChildSpec lifecycle through X_start_supervised, so a supervised restart re-runs setup, gets a fresh state allocation, and re-binds the same handler — closing the state lifetime loop the supervisor was designed for.

Runtime helpers (runtime/cluster_bridge.zig)

Section titled “Runtime helpers (runtime/cluster_bridge.zig)”

Five C-ABI exports back the compiler-emitted triple:

SymbolSignaturePurpose
janus_actor_state_alloc(slot_count: u64) -> u64Heap-allocate a zero-initialized [slot_count]u64; returns the base address as u64.
janus_actor_state_free(state_addr: u64) -> i64Free a state allocation.
janus_actor_state_ptr(actor_addr: u64) -> u64Read Actor.user_data from a supervised actor handle.
janus_actor_state_slot_load(state_base: u64, slot: u64) -> u64Load slot slot from the state table.
janus_actor_state_slot_store(state_base: u64, slot: u64, value: u64) -> u64Store value into slot slot.

The flat slot table sits well under :cluster‘s i64-unified message ABI: every actor var is one u64 slot, regardless of source-type annotation. Heterogeneous typed slots remain future work.

emitClusterActorTriple scans the actor body for var_stmt children, builds a per-actor name→slot map, and threads it into the setup, handler, destroy, and start-wrapper lowering contexts.

In the handler context, two lowering paths route var references through the slot table:

  • lowerIdentifier checks the actor-state slot map before falling back to scope lookup. Identifier loads emit janus_actor_state_slot_load.
  • lowerBinaryExpr checks the slot map at the assignment and compound-assignment arms. Stores emit janus_actor_state_slot_store with the lowered RHS value.

The handler binds the runtime actor parameter to a state-base janus_actor_state_ptr call once, then every slot helper threads that base.

The generated X_start_supervised wrapper emits function references for X_setup, X_handler, and X_destroy, then calls cluster_local_start_stateful_actor. This keeps the bridge explicit while removing @intFromPtr(...) from normal actor source.

The generated X_start_supervised_ref wrapper uses the same function references but calls cluster_local_start_stateful_actor_ref_with_mailbox. The returned scalar local actor reference encodes the live LocalActorSystem handle, child slot, and slot generation rather than the transient ActorId, so it continues to address the same supervised child after a restart without aliasing a later replacement in the same slot. std.cluster.local exposes local_ref_try_send, local_ref_child_actor_id, local_ref_child_lifecycle, local_ref_child_task_state, local_ref_child_last_exit, local_ref_mailbox_len, local_ref_mailbox_capacity, and local_ref_stop_child over that reference.

The same ref operations are also available through explicit ClusterLocalCap wrappers (local_actor_ref_cap, local_ref_try_send_cap, local_ref_child_actor_id_cap, local_ref_child_lifecycle_cap, local_ref_child_task_state_cap, local_ref_child_last_exit_cap, local_ref_mailbox_len_cap, local_ref_mailbox_capacity_cap, and local_ref_stop_child_cap) for callers that keep capability passage visible at the Janus API boundary.

Parser hardening — match-arm do…end bodies

Section titled “Parser hardening — match-arm do…end bodies”

parseMatchStatement now accepts do … end arm bodies as proper block_stmt nodes (mirroring the existing { … } arm). Pre-fix, a do-block arm body fell through to parseExpression and was mis-parsed as a malformed func_decl with overlapping edge ranges, producing AST DAGs that crashed downstream walkers (type_resolution_validator.walkBodyForTypeAnnotations was the canary stack overflow).

type_resolution_validator.walkBodyForTypeAnnotations is also hardened with a visited HashMap cycle gate as defense-in-depth.

Parser hardening — bare-arm receive bodies desugar

Section titled “Parser hardening — bare-arm receive bodies desugar”

receive do pattern => body … end previously parsed loosely and silently lowered to a handler that unconditionally ran the first arm’s body for every message — pattern check dropped, second arm dropped, no diagnostic.

The parser now peeks for an arrow_fat token at depth 0 before the receive’s terminating end and synthesizes an explicit match __msg node:

receive do
0 => do
count += 1
end,
1 => do
return 0
end,
else => do
count = count
end,
end

The generated match uses the existing match-lowering path, so bare receive arms and explicit match __msg { … } share the same runtime semantics. See the Stateful Actors tutorial for the explicit form.

actor Counter do
var count: i64 = 0
receive do
match __msg {
0 => do
count = count + 1
end,
1 => do
return 0
end,
_ => do
count = count
end,
}
end
end
func main() !i64 do
let counter = spawn Counter()
return 0
end

Inside receive, __msg is the implicit binding for the received message (an i64 under the current :cluster ABI). Use receive payload-name do … for an explicit alias.

The actor surface now also accepts protocol headers and mailbox capacity:

message CounterMsg {
Tick,
Stop,
}
@mailbox(capacity: 4)
actor Counter(msg: CounterMsg) do
receive do
match __msg {
0 => do count += 1 end,
_ => do count = count end,
}
end
end

ActorRef[CounterMsg] is available for direct spawned actors. The compiler checks typed local bindings, direct ref.send(CounterMsg.Tick) calls, and direct return spawn Actor() expressions. Unit variants lower to their i64 tag. Payload-carrying actor messages remain future work because the local actor mailbox ABI is still i64.

Terminal window
./scripts/zb # baseline
./scripts/zb test-cluster-actor-decl-jan # supervised lifecycle
./scripts/zb test-cluster-actor-start-supervised-jan # generated start/ref wrappers
./scripts/zb test-cluster-bare-receive-arm-jan # receive-arm sugar
./scripts/zb test-cluster-typed-actor-protocol-jan # typed actor surface
./scripts/zb test-cluster-typed-actor-protocol-reject-jan
./scripts/zb test-cluster-bridge # runtime contract
./scripts/zb test-cluster-stateful-actor-jan # stateful smoke
./scripts/zb test-qtjir-std # stdlib regression
./zig-out/bin/janus build examples/hello_actor.jan # canonical example
  • Slot type is u64. All actor vars become u64 slots regardless of source annotation. Heterogeneous typed state and pointer-typed slots remain future work.
  • Message ABI is i64. message declarations, actor Name(msg: Msg), ActorRef[Msg], and unit-variant sends are wired. Payload-bearing actor messages with receive-body destructuring shipped in v2026.5.18.
  • grain and supervisor declarations remain unimplemented. Phase B covers the actor surface only; the wider :cluster story (grains, supervision trees as first-class declarations) is later sprint scope.
  • Typed payload envelopes — message Msg { Variant1, Variant2 { value: T } } payload destructuring in receive bodies. Shipped v2026.5.18.
  • Heterogeneous typed state — slots carrying anything other than u64-shaped values.
  • janus-docs: extend learn/profiles/cluster.md to reflect the shipped surface instead of the aspirational sketch. This Phase B update is scoped; the full :cluster page refresh is its own sprint.