15. Actors
Actors are the unit of concurrency, isolation and hot reload. Each actor has its own heap, its own per-actor reference-counted memory manager, values being acyclic (§12) so that plain counts reclaim everything with no tracing collector and no pauses, and a typed message interface. They are the third of Hanki's three layers (§1, The three layers), built from actions: every on handler is an action with an effect row, implicitly [state], and only an action can spawn an actor or send it a message.
actor Counter
state n: i32 = 0
on increment() -> ()
n += 1
end
on get() -> i32
n
end
end
statedeclares fields with their default value. It is a contextual keyword (§2): it opens a field only here, in actor-member position, and is an ordinary identifier everywhere else, handler bodies and parameters included.on name(args) -> Ret [extra effects]declares a message handler.[state]is implicit in everyonblock, and only other effects are listed. Namingstatein any effect row, or declaring aneffect state, is rejected outright, and no plain action can claim the actor-internal authority, which is what the implicit-only rule is for.- Spawning:
c = spawn Counterreturns a typed opaque handle of typeActorRef<Counter>. - A spawn may replace selected state defaults by name:
w = spawn Worker(limit=5i32, mode="fast"). Each keyword must name a declaredstatefield and match its type; omitted fields use their declared defaults, duplicate or unknown names are errors, and positional initialisers are rejected. The initialiser expressions evaluate in written order on the spawning actor, then cross onto the newborn actor's heap under the same copy/move rules as message arguments. A resource field therefore takesfield=move resource; ordinary data is deep-copied. Futures, secrets and resources nested inside another value cannot cross this boundary. The reservedmailbox=andsupervisor=options compose with state initialisers but cannot initialise same-named state fields. Defaults for supplied fields are not evaluated. The combined state is constructed before the initialwhere-invariant pass, which lets restart code preserve validated configuration:spawn Worker(limit=saved_limit). - Declaration order is free. An actor body may
spawn, or otherwise name, an actor declared later in the module. Actor names resolve regardless of source order, as function names do, and two actors that spawn each other need no forward declaration. - Sending blocks by default.
c.increment()is a method call, and the parentheses are required even for a zero-argument handler. It blocks until the actor processes it, the Erlanggen_server:callmodel, and returns the handler'sT. A barec.incrementwith no parens is not a send: anActorRefhas no fields, the compiler rejects the field read (H0565), and it points atc.increment(). Sending is an invocation, and every invocation takes parentheses. - For a deferred reply, prefix with
async:f = async c.get()produces aFuture<T>. Extract the value at the await site withr = await f, or with a deadline,r = actor.await_timeout!(f, ms), which throwsSendFailed::Timeoutpast it (below). - Fire-and-forget is a statement-position
asyncsend.async c.increment()on its own line, and not as the trailing expression that produces the block's value, discards the resultingFuture<()>. To fire-and-forget at the function tail, the send must still sit in statement position: add an explicit()trailing so the block produces unit. Blocks do not auto-coerce a non-unit trailing value to(). - Mailbox values are deep-copied between heaps. Handler arguments are copied from the sender's heap into the receiver's, and the return value of a blocking send, or of an
awaitedFuture, is copied back into the caller's heap. Primitives (i32,bool,f64,string) and actor handles pass by value, and heap-allocated values (List,Option, struct and sum payloads, closures) are walked recursively. Internal aliasing inside a single transferred value is preserved: one heap object referenced twice is a single object after the copy. The walk always terminates, values being acyclic (§12). - A
Secretnever crosses a send. Unlike a resource there is nothing to move, the material would be copied, and aSecret(§4) in a handler parameter, return type orthrowstype is rejected outright (H0635), directly or nested inside an aggregate. A closure type mentioning one is rejected too, and a closure that merely captures one is the documented exception (§4). Hold it in the owning actor'sstate, orrevealit and send only what the handler needs. - Resources move across a send, and never copy. A native resource (
File,TcpStream,TcpReadHalf,TcpWriteHalf,TcpListener,UnixStream,UnixReadHalf,UnixWriteHalf,UnixListener,TlsStream,TlsReadHalf,TlsWriteHalf,TlsListener; §4) is single-owner and cannot be duplicated, and the deep copy that normally moves an argument between heaps would be ill-defined. A resource is moved instead: a directly resource-typed handler parameter is a move-in, and the sender writesmove xat the call site to hand the handle over.
on serve!(s: TcpStream) -> () [net] # move-in parameter
...
end
w = spawn ConnWorker
async w.serve!(move sock) # ownership transfers here; `sock` is consumed
The runtime re-homes the handle in the receiver's heap and tombstones the sender's slot. Passing a resource to a resource-typed parameter without move is a compile error, as is move on a non-resource or anywhere but a send argument. Using a resource after it has been moved is rejected within the same function or handler body, a best-effort check. A move the check cannot see, hidden behind a branch, a call boundary or a captured-resource closure, is backstopped at runtime: the moved-out binding is a husk no operation accepts, and touching it fails the actor safely, never with undefined behaviour, in place of reaching the moved handle. Two things are rejected outright: a resource as a return type or throws type, which would copy back to the sender, and a resource nested inside a struct, sum or collection parameter, only a top-level resource moving. Unwrap it first. Where a moved send fails, on MailboxFull or Died, the in-flight resource is released with the rejected envelope: the sender has given it up, and the connection drops. An actor may also hold a resource in its own state.
- Throws do not cross sends. A handler's effect row is the receiver's and not the sender's, share-nothing isolation (§1) extending to effects. A handler declared
on op() -> T [throws E]that raisesthrow edoes not re-raiseEat the sender. An uncaught handler throw, like any other handler failure, takes the actor down, and the sender observes it only as theDiedvariant of the universally-injected[throws actor.SendFailed](below). The handler's[io],[throws E],[state]and every other effect it declares remain inside the receiving actor and never propagate across a send. To return an error to a caller, a handler returns it as a value,on op() -> Result<T, E>, which the sender matches: data crosses the boundary, and control never does. - Discarding a send
Futurethat would swallowSendFailedis a compile error. Every send picks up[throws actor.SendFailed](below), and aFuturefrom anasyncsend always has it. Statement-positionasync c.op(args)is fine: it demotesSendFailedto the surrounding action's row, the caller declaring it or wrapping the send intry…catch, and the throw still has somewhere to surface. What is rejected is dropping it unreported: a wildcard discard_ = async c.op(args), or a let-boundf = async c.op(args)whose binding is never reached byawait fin its scope, where the drop at scope exit would swallow theSendFailed. To fire-and-forget, use statement position; to observe the result, bind andawaitit, optionally undertry…catch e: actor.SendFailed. - A
Futureis a linear one-shot: await it at most once, and never send it across an actor boundary. A future is the pending reply to a single send, backed by a one-shot cell that empties when the reply is read, and a second await of the same binding hits an already-consumed slot, which the runtime detects and faults on (below) in place of parking forever. Two rules follow, both like themovediscipline above:- Await-at-most-once (affine). A second
await f, oractor.await_timeout!(f, …), whose bounded await counts the same, of the same binding on a straight-line path is a compile error, as is anawaitafter an unconditional earlier one. This is a best-effort same-body check, the mirror of use-after-move. A re-await hidden behind a branch or across a loop's re-entry is not caught statically and falls back to the runtime, where a re-awaitedawait f, or the boundedactor.await_timeout!(f, …), finds the reply slot already consumed and faults the actor witha future was awaited more than once. That is a runtime error, an actor death and program-terminating at the root, and no silent hang, and never a catchableSendFailed::Timeoutatry…catchcould swallow. Both tiers behave identically. Under--deterministicthe plain form's indefinite park is detected as a deadlock, and the bounded form, whose virtual deadline still fires, faults directly on the drained slot. Discard is free: affine means at most once and zero awaits is allowed, and a total future such asFuture<actor.Shutdown>may be dropped. A throwing send future must still be awaited at least once or fired in statement position, per the rule above, and the two rules together make it once and no more. - Non-Sendable. A future is single-owner and tied to its origin actor, and sharing one across actors would let two actors await the same reply slot. Like a resource, it is rejected wherever it would cross a message boundary: a handler parameter, a return type, or a
throwstype. Await the future in the actor that created it and send the resulting value, never the future itself.
- Await-at-most-once (affine). A second
Bounded mailboxes
Every actor has a bounded message mailbox. The default capacity is 1024 envelopes; override at spawn with the reserved mailbox= keyword arg:
def make!() -> ()
c = spawn Counter(mailbox=64) # a 64-envelope mailbox instead of the 1024 default
end
mailbox=N must be an unsuffixed positive integer literal at compile time. Expressions, identifiers and suffixed literals (1024i32) are all rejected. It is a reserved spawn option alongside supervisor=; other keyword args initialise same-named state fields as described above.
Every send picks up [throws actor.SendFailed]. SendFailed is a sum defined in stdlib/core/actor.hk with three variants: MailboxFull, the receiver's mailbox being at capacity; Died(ActorId, DeathCause), the receiver having terminated; and Timeout, an actor.await_timeout! deadline having passed (below). The runtime materialises a SendFailed value at the send site on either of the first two conditions. The caller either declares the throw in the surrounding action's effect row or wraps the send in try … catch e: actor.SendFailed, distinguishing the variants with an inner match:
open actor
actor Server
on op() -> ()
()
end
end
def retry() -> ()
()
end
def drive!(c: ActorRef<Server>) -> () [Crash]
try
c.op()
catch e: actor.SendFailed
match e
MailboxFull -> retry()
Died(_, _) -> crash!("unhandled child death")
Timeout -> retry()
end
end
end
The arms above name the variants bare, and the file needs open actor. The actor keyword, for actor … end, doubles as the actor module name, and the compiler accepts it as a module qualifier wherever a module name is expected: open actor, use actor, actor.SendFailed and actor.MailboxFull all resolve. Without open actor, write the patterns qualified: actor.MailboxFull, actor.Died(_, _).
Where the throw surfaces depends on the send form:
- A blocking send,
c.op, throws at the call site. - A statement-position fire-and-forget,
async c.op(), demotes the throw from the discardedFuture's effect row into the caller's row. It fires synchronously at the statement, while the rest of the handler runs asynchronously. - An expression-position async,
f = async c.op(), folds the throw into theFuture<T, […, throws actor.SendFailed]>, and it surfaces at the matchingawait f, like any handler-side throw.
actor.await_timeout!(f, ms) is the bounded await. A plain await f parks indefinitely, and the bounded form gives up after ms milliseconds (i32, with a negative value read as 0) and throws SendFailed::Timeout at the call site. It types as await f does: the value is the future's T, the future's folded effect row, which already includes throws actor.SendFailed, is raised here, and it counts as the future's await for the discard rule above. A timeout cancels nothing. The handler runs to completion, and its late reply, where one arrives, is abandoned, the one-shot reply cell tolerating a post-timeout write. Under --deterministic the deadline is virtual, on the same gate clock as time.sleep!: a run that waits out the deadline costs no real time, replays per seed, and never counts toward a deadlock. This is a compiler special form and no stdlib fn: a fn-boundary Future<T> parameter cannot declare the future's effect row, and no declarable signature could express it.
actor.SendFailed is written qualified, or open actor brings the bare SendFailed type, and the MailboxFull, Died and Timeout constructors, into scope. See §7 on why the propagation cost is acceptable in this language.
The MailboxFull variant fires where a bounded mailbox is full. The Died variant reaches a sender parked on a reply from an actor that then terminates, and is also surfaced synchronously at the send site where the target is already dead: a send to a terminated actor throws Died and never drops unreported. The Timeout variant is raised only by actor.await_timeout!. See Supervision below.
What an actor costs
An actor is an OS thread, a bounded mailbox, and a wakeup self-pipe of two file descriptors. The descriptor budget bounds the actor count. A thousand actors alive at once need a little over two thousand descriptors, and a 1024-descriptor budget fits roughly five hundred actors. A dead actor releases its pair at death, which makes the bound one on actors alive at a single moment. A program may spawn any number over its lifetime.
A spawn the OS refuses reports the arithmetic and exits RUNTIME_FAULT_EXIT_CODE (§22), in the same words on both tiers: the number of live actors, the two descriptors per actor, and the soft limit in force. Raise the budget with ulimit -n, or spawn fewer actors.
Hot reload
module.reload!(m, path) (§14) swaps a live loaded actor's implementation for freshly recompiled code at the actor's next safe point, between two message dispatches. The in-flight handler finishes under the old code, and every message dequeued afterwards, already queued or newly arrived, runs the new code, the genserver `codechange model. The actor, its ActorRef and its mailbox survive the swap, and the same Module<T>` handle is returned, re-pointed at the new code.
Reloading a function-mode handle, a module that binds function exports in place of an actor (§14), is the degenerate case: the handle's vtable is re-pointed at the recompiled code, with no actor to quiesce and no state to migrate, and it works on both tiers. The failures no migrate hook can fix have their own ModuleLoadError variants, distinct from StateMigrationRequired. The new module must bind in the handle's mode, and a reload whose source switches between actor and function exports throws ModeMismatch; reloading a dead or already-unload!ed handle throws InvalidHandle. Both leave the running code untouched, identically on the two tiers.
State migration works as follows. Where the new code's state layout matches the running one, the live state vector transfers untouched. Where it differs, the new actor must declare a migrate hook; with none, the reload throws module.ModuleLoadError::StateMigrationRequired and the actor retains its old code. A reload is atomic, applying fully or leaving the actor untouched, and a hook that faults or produces state violating the new invariants is rolled back the same way.
actor Counter
state total: i64 = 0
on add!(n: i64) -> ()
total += n
end
migrate(old: CounterV0) # implicit [state], returns ()
total = old.count
end
end
struct CounterV0 # mirrors the PREVIOUS state layout
count: i64
end
The migrate(old: T) hook runs once during a layout-changing reload. The new state is first built from its declared defaults, then migrate overwrites fields from old, a struct value mirroring the previous layout. T's shape must structurally match the running actor's state, the same fields by name and type, in order, and a hook written against a different previous version is rejected with StateMigrationRequired in place of being mis-applied. The hook takes the implicit [state] effect, returns (), and is never a message handler; on migrate is rejected.
A reload is treated as layout-unchanged, with state carried and no hook needed, only where the old and new state agree both structurally and in per-field runtime representation. A same-named field whose type changes representation, string to i32, is a layout change and requires a hook.
v0 scope: actor-mode reload is bytecode tier only, the AOT-embedded loader being unable to spawn a loaded actor yet. Under an AOT host only function-mode handles exist, and those reload fine. There is one previous layout per migrate, and reloading across a skipped version throws StateMigrationRequired. The hook is total and has no throws, running inside the reload coordinator with no surrounding catch context. The layout comparison is a complete, non-lossy type signature of the whole state: two layouts are reload-compatible only where they are structurally identical down to every leaf type's exact name, full type identity: i32 and i64 are distinct, as are f32 and f64, and string and bytes. Any representation change is therefore detected, including one to a scalar nested inside an aggregate state field, a struct field's i32 becoming string, which the render layout's collapsed scalars would miss. The same signature is matched against the migrate hook's old parameter, and a migration across a skipped version is rejected.
Use after unload!
A method dispatched through a Module<T> handle that unload! has already released throws a catchable module.ModuleHandleInvalid, a dedicated type distinct from ModuleLoadError, a use-after-release being no load failure, and a program catches the two separately; its sole variant is Released. Any handle dispatch can land on a released handle, and every Module<T> method call therefore takes [throws module.ModuleHandleInvalid], a call to a pure def method included, the throw being a property of dispatching through a handle that may be gone and no property of the method. The trait's prop members obey the same discipline as everywhere else (§10): a prop is read bare-dot through the handle (m.answer, and never m.answer(), the parenthesized form being H0567), and that read is a handle dispatch too and charges the identical [throws module.ModuleHandleInvalid]. It is a universally-injected throw of the same form as [throws actor.SendFailed] on every send: the caller either declares it in the surrounding action's row or wraps the dispatch in try … catch e: module.ModuleHandleInvalid. Both tiers raise the identical value, and use-after-release has one failure form across bytecode and AOT. On the bytecode tier the scheduler materialises it when the handle's slot is tombstoned, and under AOT the embedded loader recovers the invoking image's throw tag by name and unwinds into that caller's catch. This is a memory-safety guarantee (§22): use-after-release fails safely as a typed, catchable throw, and never as a null-pointer dereference from safe Hanki code.
State invariants
An actor declaration accepts a trailing where block, after all on handlers and before end, listing boolean predicates over its state fields, mirroring the opaque-struct where surface (§9):
actor Counter
state n: i32 = 0
on increment() -> ()
n += 1
end
where
n >= 0 else "count must never go negative"
end
- Each predicate is a single
boolexpression resolving in a scope of the actor's state fields, by bare name (n, and neverself.n), plus the module's pure functions. A predicate may not referenceself, call an action (name!), require an effect, call a method on the actor type, or reference the actor being defined. An optionalelse "…"supplies the human-facing failure description. - Predicates evaluate after each successful handler return and once after initial-state construction. A handler that throws or
crash!es abandons its in-progress mutation and takes the throw or panic path, and the invariant guarantees only that the state observed between message handlers satisfies every predicate. - On a violated predicate the actor terminates and notifies its supervisor, and does not throw to the sender. The runtime delivers
on actor_died(who, cause)withcause = DeathCause::InvariantViolation(predicate, label, fields): the failing predicate's source, its optional label, and adbg!-rendered snapshot of the state fields. Senders parked on a reply when the actor dies receiveSendFailed::Diedcarrying that same cause, and a send to an already-dead actor surfacesDiedcarrying that same structured cause. The tombstone records the fullDeathCause, and a later sender sees the real variant,InvariantViolationfor instance, and no collapsed summary, until the dead actor's slot is recycled by a later spawn, after which a stale reference's send surfacesDied(_, Gone). See Actor identity and slot reuse below. Unsupervised, the death escalates to the root and exits non-zero with a state dump. - Where a spawn's effective initial state (declared defaults after its named overrides) is comptime-known, the initial-state check folds at compile time: a construction that already violates a predicate is a compile error and no spawn-time death (§16). A replaced invalid default is irrelevant; the runtime likewise never evaluates it.
- Implemented on both tiers, bytecode and LLVM AOT: the predicates compile to pure state-reading check functions the scheduler runs at the points above.
Supervision
Every actor has a supervisor link. By default the supervisor is the actor that called spawn; override it with the reserved supervisor= kwarg, whose argument is an ActorRef<T> of an actor T that declares on actor_died (the implicit Supervises bound):
actor Worker
state busy: bool = false
on work() -> ()
busy = true
end
end
actor Boss
on start(root_sup: ActorRef<Boss>) -> ()
c = spawn Counter # supervised by the spawning actor
w = spawn Worker(supervisor=root_sup) # supervised by another actor
w2 = spawn Worker(supervisor=self) # the spawner supervises, explicitly
()
end
# `Boss` supervises `c` above (the default link), so it must declare this.
on actor_died(who: actor.ActorId, cause: actor.DeathCause) -> ()
()
end
end
Inside an actor handler, self is the actor's own ActorRef<ThisActor>. Pass it as a supervisor= argument, directly or by handing it to another actor that spawns on your behalf, store it, or send to it. A blocking self-send deadlocks, the caller waiting on a handler that cannot run until the current one returns, and the type system permits it while the semantics are the usual actor-model consequence.
When an actor dies, from an uncaught throw, an invariant violation, a host panic or an actor.shutdown! (below), the runtime delivers a fire-and-forget cast on actor_died(who: actor.ActorId, cause: actor.DeathCause) to its supervisor. The handler's signature is fixed: parameter names are free, and the types, arity and unit return are not. ActorId is opaque; handle.id() returns it from an ActorRef<T>, and who.name reads the declared actor name, Worker for a spawn Worker. A supervisor stores each child's id() and compares it with who to distinguish two children of the same type. ActorId equality uses the packed slot-plus-generation identity alone. The name is a diagnostic label and does not participate. Slot reuse or a missing old name cannot change the comparison. Its human-facing Display is name plus the full logical identity, Worker #1.0: the slot and generation are already the pair sys.actors! and sys.get_state! print, while the packed raw u64 remains private. This death cast is exempt from the supervisor's mailbox capacity, like an Erlang exit signal, which is not subject to mailbox backpressure. A supervisor sitting at its mailbox= cap still receives every supervised child's actor_died, a child fault is never dropped or turned into a whole-run failure by supervisor backpressure, and the cast is serviced ahead of the supervisor's queued messages. A busy supervisor learns of a child's death promptly and not behind its backlog.
sys.Signal has Terminate, Hangup and Interrupt variants. The fixed handler on signal(sig: sys.Signal) -> () is an ordinary actor action with its own declared effects. sys.watch_signals!<T>(target: ActorRef<T>, signals: List<sys.Signal>, grace_ms: i32) -> Result<(), sys.SignalError> [process] requires a concrete actor type declaring that handler, including when the registration function is stored as a value. Every registration watches SIGTERM and SIGHUP; including Interrupt also watches SIGINT. Empty lists retain SIGTERM/SIGHUP and duplicate entries have no additional effect. One process-wide registration is replaced on success; failure preserves its predecessor. Negative grace returns InvalidGrace; zero provides no guaranteed cleanup. The other SignalError variants are Unsupported, ConflictingDisposition(Signal), Terminating and Other(string). An inherited ignore or host handler rejects registration for the affected signal. Non-Unix hosts report Unsupported.
Terminal restoration precedes actor delivery. The lifecycle message is capacity-exempt and serviced ahead of ordinary mailbox work. Grace starts at the signal event and includes restoration, queueing and handler execution. A busy handler is not preempted. Completion, delivery failure or deadline expiry restores the original signal's default disposition and re-raises it, retaining conventional signal termination. A dead target cannot perform cleanup. With no registration, terminal restoration is followed by immediate re-raise. Both runtime tiers follow this contract. SIGKILL is outside the surface.
The link is ownership in the other direction too: when a supervisor dies, every live child it supervises is shut down, recursively. The runtime tombstones the supervisor, then requests ExplicitShutdown of each direct child before it casts the supervisor's own actor_died or escalates the death to root. Each child winds down through this same protocol and shuts its children down in turn, and no actor remains live with a supervisor link pointing at a dead actor:
actor Worker
on ping() -> ()
()
end
end
actor Service
on start() -> ActorRef<Worker>
spawn Worker # Service owns Worker through this link
end
on fail() -> () [Crash]
crash!("service failed") # Worker is shut down with ExplicitShutdown
end
on actor_died(who: actor.ActorId, cause: actor.DeathCause) -> ()
()
end
end
The cascade applies to every death cause, an explicit shutdown of the supervisor included, and it is asynchronous: the dying supervisor does not wait for its children. Sibling shutdown order and a per-child deadline are unspecified. A spawn naming an already-dead or stale supervisor= cannot create an orphan: the new actor is born with ExplicitShutdown pending and winds down before handling a message. Registration and supervisor death are atomic with respect to this rule. A racing child is therefore either included in the supervisor's scan or born shutting down. A child's pending callers observe Died(_, ExplicitShutdown) as for actor.shutdown!. If shutdown is already pending when the child's death protocol begins, it wins over a handler fault racing it and becomes the child's recorded cause. If the fault began first and its death cast then finds the supervisor dead, the supervisor's downward cascade already owns that descendant death; the failed cast is absorbed and does not re-escalate the fault to root.
An actor that spawns a child without supervisor= becomes that child's supervisor and must declare on actor_died. A spawn that names a different supervisor= imposes no such obligation. Declaring on actor_died is always allowed, and it is what makes an actor a valid supervisor= target: a non-spawning supervisor is fine. The check is enforced at compile time and traces spawns across the static call graph, through directly-called free functions and through struct, trait and inherent method calls, resolved by type-directed dispatch so that two types' same-named methods never conflate. Actor sends are not followed: a handler invoked by a send runs on the receiver's thread, and the obligation falls on the receiver. The scan starts from every body the actor runs on its own thread: its handlers, its state-field defaults, and its migrate hook. A spawn in a state-field default installs the supervisor link at spawn time, before the first message. What remains untraced, a spawn reached only through bounded generic-trait dispatch, where the concrete impl is not fixed until monomorphisation, or a spawning closure whose definition site the actor never reaches through a traced call, is backstopped at runtime and never undefined behaviour. The backstop drops the death cast: the runtime names the dying actor and its supervisor on stderr, and the supervisor goes on serving its mailbox. Both tiers behave alike.
The throw type that travels with this surface is actor.SendFailed, whose Died(ActorId, DeathCause) variant reports the dying actor's identity and the cause. DeathCause is a five-case sum: UncaughtThrow(type_name, formatted), the dying actor's thrown value rendered through Display; InvariantViolation(predicate, label, fields); HostPanic(reason); ExplicitShutdown, an orderly wind-down from an actor.shutdown! of the actor (below), or from a blocking call or awaited async send that reaches a mailbox the scheduler has already closed at shutdown, where the sender gets Died(_, ExplicitShutdown) in place of waiting forever on a reply that cannot come, while casts to a closed mailbox are absorbed; and Gone, the target's slot having been recycled by a later spawn (see Actor identity and slot reuse below). Where parked senders are waiting on a reply slot from a dying actor, each receives SendFailed::Died at its extraction site. Both sums have hand-written Display impls, and a caught failure interpolates directly: MailboxFull renders as mailbox full, Timeout as reply timed out, and Died as actor <name> died: <cause>, the cause in the same words the runtime writes to stderr when a death escalates to root. A supervisor rendering it itself and an unsupervised crash therefore describe one event identically.
Actor identity survives slot reuse. The runtime maintains a slot table of actors, and an ActorRef<T> is a slot index plus a generation. When an actor dies its slot is returned for reuse, and the next spawn recycles it with a bumped generation. A program that spawns and retires actors indefinitely, the flagship actor-per-connection server, uses memory bounded by its peak concurrent actor count and not by its total spawn count, and never exhausts the id space. A stale ActorRef, one whose actor died and whose slot has since been recycled by a later spawn, is detected by the generation mismatch and is never confused with the slot's new occupant: == on it compares actor identity (§22) and reports it unequal to the new actor, a send through it throws SendFailed::Died(_, Gone), and actor.shutdown! of it resolves as already-dead. Gone is distinct from the retained causes because the recycled slot no longer has the original DeathCause; a send to a still-dead actor whose slot has not yet been reused still returns the real cause (above). Two residual bounds are scoped limitations: the table never shrinks, and a burst of concurrent actors that then die occupies those slots until later spawns reuse them; and a single slot's generation wraps after 2^32 reuses, after which a reference 2^32 reuses stale could alias. Both are far beyond the reach of a real program.
main! is the implicit root of the supervision tree, and deaths that reach it terminate the process with a stderr summary and a non-zero exit. That summary includes a crash-time state dump: the dying actor's state rendered as a struct, with field names recovered from its static type and values rendered as dbg! would render them.
hanki-runtime: actor 1 (Crasher) died: handler error: crash: disk offline; no supervisor in chain
state: Crasher(label="boom", pos=Pos(x=3, y=4), nums=[1, 2, 3])
handler error: marks a fault raised while running a handler, as against one raised during the actor's initialisation. The bytecode tier additionally appends the fault's source span, whose spelling is still being settled.
The dump appears only on this unsupervised escalation path. A supervised death flows through on actor_died carrying its DeathCause in-program and prints nothing extra. It works on both tiers and shares the dbg! renderer, and the same limits apply: an un-monomorphised generic field renders positionally, and a function-typed field renders as its raw value in place of a placeholder.
Implemented on both tiers: death detection, the supervisor cast, parked-reply Died, send-time Died to an already-dead target, supervisor= and self, and root escalation. The only death sources are crash! and uncaught throws on either tier. Fixed-width arithmetic wraps and its narrowing truncates, both total; fixed-width divide-by-zero is a compile-time error; and lowercase int, decimal and rational division by zero is total through in-band ±inf and undefined (§3), carried identically by the bytecode interpreter and the AOT hanki_int_*, hanki_dec_* and hanki_rat_* C-ABI. AOT actor death fires through the intrinsic-failure unwind path: crash! raises a reserved host-panic exception that the handler trampoline surfaces as a death in place of aborting the process, and it routes to the supervisor, or escalates to a non-zero process exit where unsupervised. Fundamental faults the compiler should have prevented, dereferencing an invalid heap pointer for instance, still abort process-wide.
Scheduler-side timers and restart patterns
actor.send_after!(target.handler(args), ms) schedules a fire-and-forget cast without parking an actor. It is a compiler special form on the actor module head; no function can declare it. The first argument must be a statically resolved actor-handler invocation. That invocation names the future delivery and performs no blocking call now. The form returns (), has [time, throws actor.SendFailed], and the handler's own effects stay with the receiver as for every send. ms is an i32; a negative value clamps to zero.
actor Worker
on tick(n: i32) -> ()
()
end
end
def schedule!(worker: ActorRef<Worker>) -> () [time, throws actor.SendFailed]
actor.send_after!(worker.tick(1i32), 500i32)
end
The handler arguments cross the actor boundary when the timer is scheduled. Ordinary values are copied then, and move transfers a resource then; firing performs no second transfer. Scheduling reserves one slot in the target's bounded mailbox immediately. The reservation appears in mailbox depth and participates in the mailbox= limit: a dead or full target raises SendFailed::Died or MailboxFull synchronously at send_after!. Once accepted, firing cannot encounter an unreportable full mailbox. At the deadline the reserved envelope joins the regular queue's tail. If the target dies or the program exits first, the scheduler cancels the timer, releases the reservation, and drops its copied values and moved resources. A shutdown-closed target absorbs a racing delayed cast as it absorbs an ordinary fire-and-forget cast.
One scheduler-owned timer wheel and one lazily-started worker serve all delayed casts in a program; no actor thread sleeps for them. Ordinary runs use monotonic wall time. Under --deterministic, deadlines use the gate's virtual clock, cost no wall time, and replay per seed. Timers with the same deadline fire in registration order. A regular message racing a deadline follows the scheduler order of that run; the FIFO guarantee begins once envelopes join the mailbox.
- Periodic. After doing the work, a handler re-arms itself with
actor.send_after!(self.tick(), period). Stop it by checking astateflag before re-arming. Unlike a blocking self-send, the delayed cast does not wait for the current handler. - Backoff. Use the same form with a growing delay between attempts, an exponential
base · 2ⁿcapped at a ceiling. TheRestartPolicymodule computes the schedule. - Sleeping in a handler.
time.sleep!remains the way to delay the continuation of the current action. A message arriving during that sleep waits in the actor's mailbox, and a sender past the cap getsMailboxFull.send_after!leaves the target and caller free instead.
Restart bookkeeping is the supervisor module's job. extra/supervisor remains pure bookkeeping shared across actors: RestartPolicy(max_restarts, base_ms, max_ms) supplies consecutive exponential backoff, capped. A non-positive base or ceiling yields a zero delay, and positive doubling saturates before i32 overflow; the calculation therefore takes at most the width of i32, even when the supplied restart count is extreme. RestartIntensity(max_restarts, within_ms) and RestartWindow.empty() supply a sliding restart window. One window belongs to one logical child slot, or to one strategy group configured to share it. For several same-type children, the supervisor stores each current handle, compares handle.id() with the later who, and updates the matching slot; respawning replaces that handle while the slot's window persists.
intensity.next(window) charges the restart decision immediately. This prevents several deaths in one strategy group from overbooking its allowance. RestartAllowed(next_window) contains the value to store; IntensityExceeded performs the actor's configured escalation, whether that is giving up on one child, shutting down a group, or failing the supervisor. The updated window's epoch travels in the delayed restart message. After that handler verifies the epoch and spawns the replacement, it schedules self.expire_slot(epoch) with actor.send_after! for within_ms; that handler installs window.expired(epoch). An expiry from an older epoch is inert. The last live expiry advances the epoch as it empties the window. window.reset() empties and advances it only when a logical child slot or strategy group is discarded or reassigned, invalidating all of its old timers; an ordinary respawn preserves the window. Every allowed restart ages out on its own schedule; no bucket boundary resets the group. A replacement which runs successfully for the whole interval clears its own charge, and after a full interval with no newer restart the window is empty. Backoff time reserves the charge but does not count as successful child runtime. max_restarts <= 0 permits none; a negative within_ms follows send_after! and clamps to zero.
An ExplicitShutdown is intentional and never calls next: it consumes no restart allowance and is not restarted. Other configured fault causes do. At the exact stability boundary, the window contains the charge until its expiry handler is processed. A death whose priority actor_died cast is already queued is handled before ordinary timer mail; otherwise the scheduler's event order decides which handler runs first. Under --deterministic that order is virtual and reproducible, and equal timer deliveries retain registration FIFO. examples/restart_window_boundary.hk registers the expiry before an equal-deadline failure and pins expiry-first on both tiers. In either order there is no wall-clock read and no bucket race hidden in the pure policy. The end-to-end examples/v0_1/supervised_restart maintains independent windows for two Worker identities, delays restarts without parking its supervisor, lets one replacement become stable, ignores its manual shutdown, and escalates the other child's rapid failures on both tiers.
Terminating an actor: actor.shutdown!
actor.shutdown!(target) winds an actor down. Its deadline-bearing form is actor.shutdown!(target, kill_after=ms), where ms: i32; no other keyword or positional deadline is accepted. It is the in-language counterpart to a supervisor's restart decision, a way to stop a running actor, one parked forever in a blocking call like accept! included, from anywhere with its ActorRef. Like actor.await_timeout! it is a compiler special form, a Future being unable to cross a fn boundary, and no declarable stdlib function.
actor Worker
on ping() -> ()
()
end
end
def main!() -> () [throws actor.SendFailed]
worker = spawn Worker
f = actor.shutdown!(worker, kill_after=500i32) # Future<actor.Shutdown>
outcome = await f # read the outcome once, or drop f for fire-and-forget
end
It is asynchronous and total. shutdown! signals the target and returns immediately with a Future<actor.Shutdown>, and it never blocks the caller. Without kill_after, read the outcome the usual way, await f, or use actor.await_timeout!(f, ms) to bound that separate await; a target that cannot reach a shutdown safe point on its tier can otherwise make it wait like any other future. With kill_after, the deadline begins when the shutdown request is made. A negative value clamps to zero. Under --deterministic it uses virtual time, costs no wall time, and replays per seed. Every outcome is normal, the future therefore has no throw, and unlike a send Future it is freely discardable: fire-and-forget is a statement-position actor.shutdown!(target), with no try…catch needed. It is still a linear one-shot like every future: discarding it is free, and it may be awaited at most once, a second await of the same binding being a compile error. The result type is the sum actor.Shutdown:
Terminated: the target was live, the call wound it down with causeExplicitShutdownand its supervisor'son actor_diedfired, and it is now fully gone.AlreadyDead: the target had already terminated. An idempotent no-op.SelfScheduled: the target is the calling actor. An actor cannot wait on its own death, andactor.shutdown!(self)marks it to exit at the end of the current handler and resolves the future now. This is the clean way for an actor to stop itself, distinct fromcrash!, which reports a fault cause.TimedOut: the target had not reached a shutdown safe point whenkill_afterexpired. The shutdown request remains active and the target may terminate later. The deadline bounds the caller's wait; it does not bound the target's lifetime. The bytecode tier's loop safe point below bounds a pure loop; the AOT tier has no native-loop poll, and straight-line compute or unbounded recursion reaches no loop safe point on either tier. A target death racing the deadline resolves the one-shot future as eitherTerminatedorTimedOut.
It interrupts a parked blocking call. A target blocked in a cancellable OS call, accept!, read!, write!, connect!, stdin read_line!, the timed sys.stdin_read!, a process.run! awaiting a child, or a Child pipe read, write or wait, is woken from the kernel call. Dropping that actor's Child then terminates and reaps the subprocess. One grinding inside a long SQLite statement (§17) is aborted between virtual-machine ops by the connection's in-thread progress handler.
On the bytecode tier, every taken backward Jump or JumpIfFalse is one cooperative reduction. At the 16,384th reduction, the VM reaches a scheduler safe point. The VM stores the count across a synchronous pure intrinsic call; such a call does not hand control to the scheduler, and a loop cannot evade the bound by calling a helper. The safe point first checks the target mailbox's actor-local shutdown signal and the program-wide closing signal. Either one unwinds the current handler normally as ExplicitShutdown, releasing its owned resources and running the ordinary supervision/death protocol. Otherwise the VM yields a resumable CooperativeYield to the scheduler, which hands off the OS thread (or the seeded deterministic run token) and then resumes the same VM and same handler. No second mailbox message enters while that handler is suspended: handler atomicity is unchanged. Bytecode control flow fixes the reduction count and scheduling point; a --deterministic --seed N run therefore accounts them identically on replay. This runnable yield does not sleep or advance virtual time, whose rule remains that the clock jumps only when no actor is runnable. The back-edge op already spends its ordinary --max-steps unit; the scheduling handoff spends no extra language step. REPL cancellation remains the stronger per-op check.
Native AOT loops have no cooperative poll. The required mem2reg countdown on compiler-emitted back-edges and scheduler call regressed tight-loop and allocation-heavy benchmarks before adding the shutdown check and unwind-safe root spill. It failed the project's net-performance-positive gate, and the AOT backend omits it. This documented tier gap makes no parity claim: an AOT actor wedged in native compute still requires reaching another safe point, while a bytecode actor looping forever can be shut down, supervised, and scheduled beside peers. Straight-line infinite compute and unbounded recursion have no back-edge safe point on either tier.
An ExplicitShutdown is an orderly wind-down and no fault. A supervised target's on actor_died receives DeathCause::ExplicitShutdown, and a supervisor can tell an intentional stop from a crash and skip the restart. An unsupervised target dies with no root escalation and no non-zero exit, unlike an uncaught throw or crash!. Pending senders to the target still observe SendFailed::Died(_, ExplicitShutdown). The mailbox, blocking-call and death protocol work on both tiers; pure native-loop interruption has the scoped AOT gap above. The end-to-end example is examples/v0_1/actor_shutdown.
Program exit terminates spawned actors and does not wait for them. When main!, the root actor, returns, the program is over: the runtime terminates every still-live spawned actor, interrupting a parked cancellable OS call (accept!, read!, write!, connect!, read_line!, sys.stdin_read!, process.run!, or a Child pipe operation) and a parked virtual sleep (the deterministic gate marks itself terminating and wakes the sleeper with ExplicitShutdown and does not advance the virtual clock), and aborting an in-flight SQLite statement the way actor.shutdown! does, and dropping any unprocessed mailbox messages and owned resources. Dropping a Child resource terminates and reaps its subprocess. A streaming child cannot outlive the actor that owns it or the program; the explicit process.spawn_detached! operation (§17) is the sole process-library exemption and returns no owned handle. A spawned actor therefore does not keep a program alive past main!'s return, and a fire-and-forget async send whose handler has not finished may not run to completion. To keep work alive, block in main! until it is done, with r = await actor.shutdown!(s), the serve! pattern (§17), or by awaiting a result the actor sends back. Both tiers complete this teardown before exiting. A fatal root fault or an unsupervised actor death also releases the root's and actors' owned resources before exit, preserving the fatal exit code (§22).
Request-scoped workers
An actor is independent of the stack frame that spawned it. A handler that spawns workers, fans work out to them and returns has not stopped them: the workers go on running, and actor.await_timeout! bounds the wait and cancels nothing. The completion boundary is spelled where it happens, and actor.stop_all!(targets, kill_after) spells it:
use io
actor Fetcher
on fetch!(url: string) -> string [io]
url
end
end
actor Server
on request!(first: string, second: string) -> () [io, throws actor.SendFailed]
a = spawn Fetcher
b = spawn Fetcher
workers: List<ActorRef<Fetcher>> = List.empty().append(a).append(b)
fa = async a.fetch!(first)
fb = async b.fetch!(second)
try
head = await fa
tail = await fb
io.print!("#{head} #{tail}\n")
catch e: actor.SendFailed
io.print!("scope failed: #{e}\n")
end
outcomes = actor.stop_all!(workers, 100i32)
io.print!("stopped #{outcomes.length}\n")
end
on actor_died(who: actor.ActorId, cause: actor.DeathCause) -> ()
()
end
end
stop_all! signals every target, then collects every outcome, which puts one deadline over the whole set; stopping targets one at a time takes kill_after per target. It answers one actor.Shutdown per target in the order given. A stopped worker's in-flight handler unwinds as ExplicitShutdown and releases its owned resources before the call returns, which releases a resource moved into that worker.
Four limits bound the pattern:
- The stop runs where it is written, and Hanki has no exit hook. A
returnahead of it, or a throw caught inside the same actor, leaves the workers running, and an operation's failure paths need the stop written on them. A throw that escapes the handler kills the spawning actor, and the cascade under Supervision above then shuts down the workers it supervises without waiting for them. - A failing worker does not stop its siblings. The failure reaches the awaiting scope as
SendFailed::Died, and the siblings run until thestop_all!. - The spawning actor's
on actor_diedfires for every scoped worker that dies, after the current handler returns. A supervisor with a restart policy tells a scoped worker from a long-lived child before restarting anything. TimedOutis a report and no guarantee. A target wedged in native compute on the AOT tier reaches no safe point, its shutdown request remains active, and it may terminate later. A stopped actor's own children wind down through the cascade above, which does not wait either.
An actor is an OS thread and two descriptors (What an actor costs above), which bounds this to a few workers per operation. Per-item parallelism over a large collection is a worker pool the operation sends to.