hanki

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 are acyclic, §12, so plain counts reclaim everything - no tracing collector, 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 (carrying 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
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/handler body (a best-effort check). A move the check can't 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, so touching it fails the actor safely (never UB), rather than reaching the moved handle. What is still rejected outright: a resource as a return type or throws type (it would copy back to the sender), and a resource nested inside a struct/sum/collection parameter (only a top-level resource moves - unwrap it first). If a moved send fails (MailboxFull or Died), the in-flight resource is released with the rejected envelope: the sender has already given it up, so the connection simply drops. An actor may also hold a resource in its own state.

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. mailbox= is the only ctor-style kwarg spawn accepts in v0.1.

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 is at capacity), Died(ActorId, DeathCause) (the receiver has terminated), and Timeout (an actor.await_timeout! deadline passed - below). The runtime materialises a SendFailed value at the send site when either of the first two conditions holds. 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, so the file needs open actor. The actor keyword (for actor … end) doubles as the actor module name; the compiler accepts it as a module qualifier wherever a module name is expected, so 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 shape:

Bounded await: actor.await_timeout!(f, ms). A plain await f parks indefinitely; the bounded form gives up after ms milliseconds (i32; negative is 0) and throws SendFailed::Timeout at the call site instead. It types exactly like await f - the value is the future's T, the future's folded effect row (which already carries throws actor.SendFailed) is raised here - and counts as the future's await for the discard rule above. A timeout does not cancel anything: the handler still runs to completion, and its late reply, if one ever arrives, is simply abandoned (the one-shot reply cell tolerates a post-timeout write). Under --deterministic the deadline is virtual, on the same gate clock as time.sleep! - a waiting-out-the-deadline run costs no real time, replays per seed, and never counts toward a deadlock. This is a compiler special form, not a stdlib fn: a fn-boundary Future<T> parameter cannot carry the future's effect row, so no declarable signature could express it.

actor.SendFailed is written qualified, or open actor brings the bare SendFailed type (and the MailboxFull / Died / Timeout constructors) into scope. See §7 on why the propagation cost is acceptable in this language.

The MailboxFull variant fires when 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 when the target is already dead (a send to a terminated actor throws Died rather than dropping silently); the Timeout variant is raised only by actor.await_timeout!. See "Supervision" below.

Hot reload

module.reload!<T>(m, path) (§22) 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; every message dequeued afterward (already-queued or newly-arrived) runs the new code (the gen_server code_change model). The actor, its ActorRef, and its mailbox survive the swap; the same Module<T> handle is returned, re-pointed at the new code.

Reloading a function-mode handle (a module that binds function exports rather than an actor, §22) is the degenerate case: re-pointing the handle's vtable at the recompiled code - no actor to quiesce, no state to migrate - and it works on both tiers. The failures no migrate hook can fix carry their own ModuleLoadError variants, distinct from StateMigrationRequired: the new module must bind in the handle's mode - a reload whose source switches between actor and function exports throws ModeMismatch - and reloading a dead or already-unload!ed handle throws InvalidHandle. Both leave the running code untouched, identically on the two tiers.

State migration. When the new code's state layout matches the running one, the live state vector carries across untouched. When it differs, the new actor must declare a migrate hook; absent one, the reload throws module.ModuleLoadError::StateMigrationRequired and the actor keeps its old code - a reload is atomic, applying fully or leaving the actor untouched (a hook that faults or produces state that violates 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 (same fields, by name and type, in order), so a hook written against a different previous version is rejected with StateMigrationRequired rather than mis-applied. The hook carries the implicit [state] effect, returns (), and is never a message handler (on migrate is rejected).

A reload is treated as layout-unchanged (state carried, no hook needed) only when the old and new state agree both structurally and in per-field runtime representation; a same-named field whose type changes representation (e.g. stringi32) is a layout change and requires a hook.

v0 scope: actor-mode reload is bytecode tier only (the AOT-embedded loader can't spawn a loaded actor yet, so under an AOT host only function-mode handles exist - and those reload fine); one previous layout per migrate (reloading across a skipped version throws StateMigrationRequired); the hook is total - no throws (it runs 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 when they are structurally identical down to every leaf type's exact name (full type identity - i32 and i64 are distinct, as are f32/f64 and string/bytes), so any representation change is detected, including one to a scalar nested inside an aggregate state field (a struct field's i32string) that the render shape's collapsed scalars would miss. The same signature is matched against the migrate hook's old parameter, so 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 is not a load failure, so a program catches the two separately; its sole variant is Released). Because any handle dispatch can land on a released handle, every Module<T> method call carries [throws module.ModuleHandleInvalid] - even a call to a pure def method, since the throw is a property of dispatching through a handle that may be gone, not 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, never m.answer() - the parenthesized form is H0567), and that read is a handle dispatch too, so it charges the identical [throws module.ModuleHandleInvalid]. It is a universally-injected throw of the same shape 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, so 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; under an AOT host the embedded loader recovers the host's throw tag by name and unwinds into the caller's catch. This is a memory-safety guarantee (§22): use-after-release fails safely as a typed, catchable throw, never as a null-pointer dereference from safe Hanki code.

State invariants

An actor declaration accepts a trailing where block - after all on handlers, 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

actor's state fields (by bare name - n, not self.n) plus the module's pure functions. A predicate may not reference self, call an action (name!), require an effect, call a method on the actor type, or reference the actor being defined. An optional else "…" supplies the human-facing failure description.

after initial-state construction. A handler that throws or crash!es abandons its in-progress mutation and goes down the throw/panic path instead - the invariant guarantees only that the state observed between message handlers satisfies every predicate.

supervisor (it does not throw to the sender): the runtime delivers on actor_died(who, cause) with cause = DeathCause::InvariantViolation(predicate, label, fields)

snapshot of the state fields. Senders parked on a reply when the actor dies receive SendFailed::Died carrying that same cause; a send to an already-dead actor surfaces Died carrying that same structured cause - the tombstone records the full DeathCause, so a later sender sees the real variant (e.g. InvariantViolation), not a collapsed summary - until the dead actor's slot is recycled by a later spawn, after which a stale reference's send surfaces Died(_, Gone) (see Actor identity and slot reuse below). Unsupervised, the death escalates to the root and exits non-zero with a state dump.

initial-state check folds at compile time: a default that already violates a predicate is a compile error, not a spawn-time death (§16).

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 hand it to another actor that spawns on your behalf), store it, or send to it. A blocking self-send deadlocks - you would be waiting on a handler that cannot run until the current one returns - so the type system permits it but 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, but the types, arity, and unit return are not. 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, nor turned into a whole-run failure, by supervisor backpressure - and it is serviced ahead of the supervisor's queued messages, so a busy supervisor learns of a child's death promptly rather than behind its backlog.

Required-handler rule. An actor that spawns a child without supervisor= becomes that child's supervisor and so MUST declare on actor_died. A spawn that names a different supervisor= imposes no such obligation. Declaring on actor_died is always allowed - it is exactly what makes an actor a valid supervisor= target - so 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/inherent method calls (resolved by type-directed dispatch, so two types' same-named methods never conflate). Actor sends are deliberately not followed: a handler invoked by a send runs on the receiver's thread, so the obligation falls on the receiver, not the sender. What stays untraced - a spawn reached only through bounded generic-trait dispatch (the concrete impl isn't fixed until monomorphisation), or a spawning closure whose definition site the actor never reaches through a traced call - is backstopped at runtime, never UB.

The throw type that travels with this surface is actor.SendFailed: its Died(ActorId, DeathCause) variant carries 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 via Display), InvariantViolation(predicate, label, fields), HostPanic(reason), ExplicitShutdown (an orderly wind-down: an actor.shutdown! of the actor, below, or a blocking call / awaited async send that reaches a mailbox the scheduler has already closed at shutdown - the sender gets Died(_, ExplicitShutdown) instead of waiting forever on a reply that cannot come, while casts to a closed mailbox stay silently absorbed), and Gone (the target's slot was recycled by a later spawn - see Actor identity and slot reuse below). When parked senders are waiting on a reply slot from a dying actor, each receives SendFailed::Died at its extraction site.

Actor identity and slot reuse. The runtime holds actors in a slot table and an ActorRef<T> is a slot index plus a generation. When an actor dies its slot is returned for reuse; the next spawn recycles it with a bumped generation. So a program that spawns and retires actors indefinitely (the flagship actor-per-connection server) uses memory bounded by its peak concurrent actor count, not 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 (which compares actor identity, §22) 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 holds the original DeathCause - a send to a still-dead actor whose slot has not yet been reused keeps returning the real cause (above). Two residual bounds are scoped limitations: the table never shrinks (a burst of concurrent actors that then die holds 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 astronomically beyond the reach of a real program.

main! is the implicit root of the supervision tree; 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, field names recovered from its static type and values rendered as dbg! would:

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 opposed to 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, so the same limits hold: an un-monomorphised generic field renders positionally, and a function-typed field renders as its raw value rather than a placeholder.

Implemented on both tiers: death detection, the supervisor cast, parked-reply Died, send-time Died to an already-dead target, supervisor=/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/rational division by zero is total via in-band ±inf / undefined (§3), carried identically by the bytecode interpreter and the AOT hanki_int_*/hanki_dec_*/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 (rather than aborting the process); it routes to the supervisor, or escalates to a non-zero process exit when unsupervised. Fundamental faults that the compiler should have prevented (e.g. dereferencing an invalid heap pointer) still abort process-wide.

Timer and restart patterns

Timers are not primitives - they are plain actor code over time.sleep! (§6). The constraint is deliberate: a handler cannot be passed as a value and spawn takes a static actor name, so these are patterns written per target, not generic library exports. The one reusable piece - the restart decision - does live in a module (supervisor, below).

Restart bookkeeping - the supervisor module. extra/supervisor is pure bookkeeping for the decision a supervisor makes after a child dies: RestartPolicy(max_restarts, base_ms, max_ms) with next(restarts) -> Restart(delay_ms) | GiveUp - exponential backoff, capped. It depends on no actor or clock, so it is unit-tested in isolation and shared across supervisors. The wiring stays per-actor: a supervisor catches a child's death - synchronously as SendFailed::Died at a blocking call, or via its on actor_died cast - consults the policy, time.sleep!s the backoff, and respawns, until GiveUp. The end-to-end example is examples/v0_1/supervised_restart. Restart-per-window policies (at most N restarts per T ms) additionally need clock reads (time.now_ms!) and are a later addition.

Terminating an actor - actor.shutdown!

actor.shutdown!(target) winds an actor down. It is the in-language counterpart to a supervisor's restart decision: a way to stop a running actor - including one parked forever in a blocking call like accept! - from anywhere that holds its ActorRef. Like actor.await_timeout! it is a compiler special form (a Future cannot cross a fn boundary), not a declarable stdlib function.

actor Worker
  on ping() -> ()
    ()
  end
end

def main!() -> () [throws actor.SendFailed]
  worker = spawn Worker
  f = actor.shutdown!(worker)   # Future<actor.Shutdown>
  outcome = await f             # read the outcome once, or drop f for fire-and-forget
end

Asynchronous and total. shutdown! signals the target and returns immediately with a Future<actor.Shutdown> - it never blocks the caller. Read the outcome the usual way (await f, or actor.await_timeout!(f, ms) for a bound); a compute-wedged target simply makes the await wait, exactly like any other future. Every outcome is normal (the actor ends up down either way), so the future carries no throw and - unlike a send Future - is freely discardable: fire-and-forget is a statement-position actor.shutdown!(target), no try…catch needed. (It is still a linear one-shot like every future: discarding it is free, but it may be awaited at most once - a second await of the same binding is a compile error.) The result type is the sum actor.Shutdown:

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!, or a process.run! awaiting a child - is woken from the kernel call; one grinding inside a long SQLite statement (§17) is aborted between VDBE ops by the connection's in-thread progress handler. Either way the call never completes, and the actor dies with ExplicitShutdown rather than waiting for a connection, byte, line, or child exit that may never come - so a server actor started with serve! (§17) can now be stopped and a program that spawned one can terminate cleanly. An interrupted process.run! kills its child (§17). A target merely parked on its mailbox, or running a handler, winds down at its next safe point.

Death semantics. An ExplicitShutdown is an orderly wind-down, not a fault: a supervised target's on actor_died receives DeathCause::ExplicitShutdown (so a supervisor can tell an intentional stop from a crash and skip the restart), and an unsupervised target dies quietly - no root escalation, no non-zero exit (unlike an uncaught throw or crash!). Pending senders to the target still observe SendFailed::Died(_, ExplicitShutdown). Works on both tiers (bytecode and AOT). The end-to-end example is examples/v0_1/actor_shutdown.

Program exit terminates spawned actors - it 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!) and aborting an in-flight SQLite statement the same way actor.shutdown! does, and dropping any unprocessed mailbox messages - rather than awaiting it. 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 simply not run to completion. To keep work alive, block in main! until it is done - e.g. r = await actor.shutdown!(s) (the serve! pattern, §17), or await a result the actor sends back. Both tiers behave identically: the AOT tier exits the process, and the bytecode tier tears its actors down to the same effect.