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 eachvardeclaration’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) -> u64starts the actor underLocalActorSystemwithout user-written function-address plumbing.X_start_supervised_ref(system: u64, slot: u64, policy: u32) -> u64starts the actor and returns a stable supervised local actor reference for send and observation paths.__X_loop()is the spawn-form actor loop, used byspawn X().std.cluster.localexposes 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, andlocal_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.
What Shipped
Section titled “What Shipped”Runtime helpers (runtime/cluster_bridge.zig)
Section titled “Runtime helpers (runtime/cluster_bridge.zig)”Five C-ABI exports back the compiler-emitted triple:
| Symbol | Signature | Purpose |
|---|---|---|
janus_actor_state_alloc | (slot_count: u64) -> u64 | Heap-allocate a zero-initialized [slot_count]u64; returns the base address as u64. |
janus_actor_state_free | (state_addr: u64) -> i64 | Free a state allocation. |
janus_actor_state_ptr | (actor_addr: u64) -> u64 | Read Actor.user_data from a supervised actor handle. |
janus_actor_state_slot_load | (state_base: u64, slot: u64) -> u64 | Load slot slot from the state table. |
janus_actor_state_slot_store | (state_base: u64, slot: u64, value: u64) -> u64 | Store 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.
Compiler (compiler/qtjir/lower.zig)
Section titled “Compiler (compiler/qtjir/lower.zig)”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:
lowerIdentifierchecks the actor-state slot map before falling back to scope lookup. Identifier loads emitjanus_actor_state_slot_load.lowerBinaryExprchecks the slot map at the assignment and compound-assignment arms. Stores emitjanus_actor_state_slot_storewith 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,endThe 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.
Canonical Actor Shape
Section titled “Canonical Actor Shape”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, } endend
func main() !i64 do let counter = spawn Counter() return 0endInside 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, } endendActorRef[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.
Verification
Section titled “Verification”./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 exampleCurrent Limits
Section titled “Current Limits”- Slot type is
u64. All actorvars becomeu64slots regardless of source annotation. Heterogeneous typed state and pointer-typed slots remain future work. - Message ABI is
i64.messagedeclarations,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. grainandsupervisordeclarations remain unimplemented. Phase B covers theactorsurface only; the wider:clusterstory (grains, supervision trees as first-class declarations) is later sprint scope.
Remaining Work
Section titled “Remaining Work”Typed payload envelopes —Shipped v2026.5.18.message Msg { Variant1, Variant2 { value: T } }payload destructuring in receive bodies.- Heterogeneous typed state — slots carrying anything other than
u64-shaped values. - janus-docs: extend
learn/profiles/cluster.mdto reflect the shipped surface instead of the aspirational sketch. This Phase B update is scoped; the full:clusterpage refresh is its own sprint.