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

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:

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

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.

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:

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:

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.