Tokio Actors
The OTP-faithful actor runtime for Rust: supervision, lifecycle, and crash semantics traced to Erlang/OTP, running as zero-ceremony Tokio tasks.
Tokio Actors is a lightweight actor framework for Rust developers who want Erlang-grade failure handling without the ceremony. Every actor runs as a dedicated tokio::task on your runtime -- no custom schedulers, no macros, no hidden magic. What sets it apart is what happens when things go wrong: supervision, restarts, and cleanup follow Erlang/OTP's documented semantics, not an approximation of them.
Why OTP Fidelity Matters
The actor model's production value was never the mailbox API -- it is the failure semantics. Erlang/OTP's "let it crash" philosophy works because the runtime makes hard guarantees: every process death produces an exit signal that cannot be lost, supervisors stop and restart children in a documented order, cleanup (terminate/2) runs even when the process is dying from an exception, and a supervisor that exhausts its restart budget escalates with a reason its own parent knows how to interpret. Most Rust actor crates copy the API shape -- spawn, send, mailbox -- but not these failure semantics: panics slip past supervision, death notices ride best-effort channels, and restart logic is left as an exercise. Tokio Actors is built for OTP fidelity: every supervision and lifecycle semantic is traced to Erlang/OTP's documented behavior (gen_server, supervisor), and every deliberate deviation is documented as a deviation. When the docs here say a child restarts, that holds under a real crash -- a panicking handler, not just a polite Err.
How It Compares
Three Rust actor frameworks, three different answers to "what happens when a handler panics?"
| Failure semantic | tokio-actors | ractor | Actix |
|---|---|---|---|
| Panic in a handler | Caught at the callback boundary; the actor stops with StopReason::Failure(ActorError::Panic) and on_stopped still runs with that reason (gen_server terminate-on-exception parity) |
Caught once around the whole message loop; the payload is stringified into ActorErr::Failed |
Not caught; the panic unwinds the task and the actor dies silently (senders find out via send errors) |
| Death notification | A runtime watcher on the child's task delivers ChildStopped through an awaited send -- the report cannot be silently dropped |
The dying actor's own task notifies fire-and-forget over an unbounded port; the send result is discarded, so a dead receiver means a lost event | None; the actor's death is observable only through send errors |
| Automatic restart | Runtime-managed: OneForOne, OneForAll, RestForOne, SimpleOneForOne | Not in core ("supervision is presently left to the implementor"); the default supervisor callback stops itself | Supervisor restarts only after a graceful stop; a panic kills the supervisor task too |
| Group restart ordering | OTP ordering: affected children stop in reverse start order, then restart in start order | n/a | n/a |
| Manual child management | terminate_child / restart_child / delete_child / stop_child with OTP semantics: spec kept, manual stops never charge the budget |
n/a | n/a |
| Restart budget escalation | Sliding-window budget; exhaustion stops the supervisor with the OTP shutdown reason (StopReason::ParentRequest), so a grandparent's Transient policy does not restart it |
n/a | n/a |
| Forced termination | Shutdown::Timeout -> Kill -> task abort ladder; every stop path is bounded except documented Infinity |
n/a | n/a |
Competitor cells come from a source-level review of ractor v0.15.13 and Actix master (2026-07). n/a means no counterpart exists in that framework's supervision core (ractor leaves restart to the implementor; Actix restarts only after graceful stops). Credit where due: ractor's in-task catch is the right skeleton, and tokio-actors adopts a per-callback version of it -- the watcher layer and the restart machinery are where the designs diverge. The behaviors in the tokio-actors column are exercised by the test suite (see Testing).
Honest limits. Panic capture requires unwinding: panic = "abort" builds have no in-process supervision (the process dies -- the same caveat ractor documents). A handler stuck at an .await point is force-killable, but a non-yielding busy loop is beyond even task abort; it surfaces as a typed SupervisionError::ChildUnresponsive after a bounded wait instead of hanging the supervisor. Shutdown::Infinity children can stall a group restart indefinitely, matching OTP's own infinity semantics. And there is no distribution layer by design: tokio-actors is local-first (see Non-Goals) -- in-process fidelity is the product, not a stepping stone to a cluster framework.
Feature Highlights
OTP-Style Supervision
Supervision is crash-visible. A panic in a handler or lifecycle hook is caught and becomes StopReason::Failure(ActorError::Panic), and supervisors detect every child death through a runtime watcher on the child's task -- the death report cannot be silently dropped. Restart strategies come from Erlang/OTP:
- OneForOne: Restart only the failed child
- OneForAll: Restart all children when any one fails
- RestForOne: Restart the failed child and all children started after it
- SimpleOneForOne: Dynamic children sharing a single factory
Group strategies (OneForAll/RestForOne) follow OTP ordering: affected children are stopped in reverse start order, then restarted in start order. Children keep their ActorId and ActorConfig across restarts. Each child has a RestartType (Permanent/Transient/Temporary), and a sliding-window restart budget prevents restart storms. When the budget is exhausted, the supervisor stops with StopReason::ParentRequest -- its own supervisor's Transient policy will NOT restart it, matching OTP shutdown semantics.
Lifecycle Observability
Query actor status anytime via the system channel -- even when the mailbox is full:
let status = handle.get_status.await?;
println!;
Strongly Typed
Message and response types are enforced at compile time. No runtime type casting, no Any trait abuse.
Bounded Mailboxes = Natural Backpressure
Every actor has a bounded mailbox (default: 64). When full, senders wait automatically -- no OOM crashes from runaway queues.
Timer Drift Handling (MissPolicy)
Recurring timers have three drift strategies to handle system lag:
- Skip: Jump to next aligned tick
- CatchUp: Send all missed messages immediately
- Delay: Reset timer from now
This is the kind of edge-case thinking production systems need.
Quick Start
Counter: The Basics
use ;
;
async
Spawning Actors
Every spawn starts with .spawn() and chains options via SpawnBuilder:
use ;
// Anonymous (UUID auto-id)
let h = my_actor.spawn.await?;
// Named (registered in default system)
let h = my_actor.spawn.named.await?;
// Named with custom mailbox
let config = default.with_mailbox_capacity;
let h = my_actor.spawn.named.with_config.await?;
// On a specific system
let sys = create?;
let h = my_actor.spawn.named.on_system.await?;
// Supervisor (children restart via OneForOne, 3 restarts / 5s)
let h = my_actor.spawn.named.supervisor.await?;
// Supervisor with custom strategy
let sup = one_for_all.max_restarts;
let h = my_actor.spawn.named.with_supervision.await?;
Actor Registry (ActorSystem)
use ;
// Default system (lazy singleton)
let sys = default;
// Custom system with config
let sys = create_with?;
// Named lookup (OTP whereis/1)
let handle = sys.;
// Stop/kill by name
sys.stop.await?; // Graceful (vetoable)
sys.kill.await?; // Force (bypasses all hooks)
// Coordinated shutdown with timeout escalation
sys.shutdown.await;
Core Concepts
Supervision
Supervisors spawn children through their ActorContext and automatically handle restarts:
use ;
;
// Launch with OneForAll strategy and custom budget
let sup = one_for_all.max_restarts;
let handle = MySupervisor.spawn.named.with_supervision.await?;
The runtime handles the restart loop: evaluate strategy, check budget, invoke the factory, wire the new child in -- all non-blocking. If the budget is exhausted, the supervisor itself stops with StopReason::ParentRequest.
When a child must be stopped (group restart, parent shutdown, or a manual stop), its Shutdown policy bounds the whole exchange: Shutdown::Timeout(d) requests a cooperative stop, escalates to Kill at expiry, and backs the Kill with a task abort after a short grace window. A child stuck at an .await point IS killable -- abort cancels it at the next yield, and its drops still run. Only a handler spinning in a non-yielding busy loop is beyond reach; the supervisor waits a bounded time, then returns a typed SupervisionError::ChildUnresponsive instead of hanging.
Managing Children
Supervisors can also drive child lifecycles manually, mirroring OTP's supervisor module. Manual stops are not failures: they never charge the restart budget and never trigger sibling (OneForAll/RestForOne) restarts.
| Call | What it does |
|---|---|
ctx.terminate_child(id).await |
Stop WITHOUT restart (OTP terminate_child). The child spec is kept (unless Temporary) so the child can be revived later. Budget untouched. |
ctx.restart_child(id) |
Revive a terminated child from its stored spec -- same ActorId, same config (OTP restart_child). Errors with ChildRunning/ChildRestarting if the child is not down. |
ctx.delete_child(id) |
Remove the child spec (OTP delete_child). The child must not be running or restarting. |
ctx.stop_child(id).await |
Bounce per restart policy: Permanent children restart budget-free; Transient/Temporary stay down. |
// Inside any supervising actor:
async
Crash Semantics
Panics are crashes, not errors. A panic in handle or a lifecycle hook is caught at the callback boundary: the actor stops with StopReason::Failure(ActorError::Panic) (running on_stopped for post-init crashes, like gen_server's terminate-on-exception), and its supervisor (if any) restarts it per strategy. On the send path the caller sees the panic directly. AskError is flat in v0.7.0 (previously the transport error was nested as Send(SendError)):
use ;
match worker.send.await
On the notify path, Err and panic are different animals: a handler that returns Err for a notify-dispatched message goes to handle_failure() and the actor continues; a handler that panics stops the actor and triggers a restart, exactly as on the send path.
Known limitations: panic = "abort" builds have no unwinding, so panic capture cannot exist there (the process dies). The default panic hook still prints to stderr even when supervision handles the crash -- set your own hook to silence it. A child with Shutdown::Infinity that refuses to stop can stall a group restart, matching OTP infinity semantics -- Infinity is the single unbounded case; every other stop path is bounded by the Timeout -> Kill -> abort ladder. A handler stuck at an .await point IS force-killable (the abort backstop cancels the task after a short grace); only a non-yielding busy loop remains beyond reach, and it surfaces as a typed ChildUnresponsive error after a bounded wait instead of hanging the supervisor.
3-Tier Termination
use StopReason;
handle.stop.await?; // Tier 1: pre_stop can veto
handle.stop.await?; // Tier 2: non-vetoable, on_stopped runs
handle.stop.await?; // Tier 3: bypasses ALL lifecycle hooks
Lifecycle Hooks
async
async
Message Passing: notify vs send
// Fire-and-forget (async until mailbox accepts)
handle.notify.await?;
// Request-response (wait for actor to process)
let response = handle.send.await?;
// Non-blocking attempt (returns immediately)
handle.try_notify?;
Error Handling Nuance:
notifyerrors -> actor callshandle_failure()and continues processingsenderrors -> actor stops (caller expects a response, failure is critical)- Panics are not errors: a panic on either path stops the actor (see Crash Semantics above)
This asymmetry reflects real-world semantics.
Timers with Drift Control
use Duration;
use MissPolicy;
// One-shot after delay
ctx.schedule.after.await?;
// Recurring -- default MissPolicy::Skip
ctx.schedule.every.await?;
// Recurring with explicit drift strategy
ctx.schedule.every
.on_miss
.await?;
Edge Case: Scheduling in the past? The message fires immediately. No panics, no silent failures.
Mailbox Monitoring
if handle.mailbox_available < 10
if !handle.is_alive
// System channel bypasses the mailbox, works even when full
let status = handle.get_status.await?;
Deep Rust Patterns
Why Sync is Required for Recurring Timer Messages
Recurring timers clone the message each tick via an internal move || msg.clone() closure held in an Arc across tasks. Rust's Send future rules require the captured msg to be Sync.
In practice this is a non-issue. Enum message types are Sync by default. Only types with unsynchronized interior mutability (Cell, RefCell) aren't Sync, and those also fail Send.
ActorHandle Equality
Handles implement PartialEq based on ActorId, not channel identity:
let actor1 = MyActor.spawn.named.await?;
let actor2 = actor1.clone;
assert_eq!; // Same actor ID
let actor3 = MyActor.spawn.named.await?;
assert_ne!; // Different actor ID
This allows handles to be used in HashSet and HashMap for deduplication and routing.
Bounded Mailbox Backpressure
When the mailbox is full:
notify().awaitblocks until space is availabletry_notify()returnsTrySendError::Fullimmediatelysend().awaitblocks (same as notify, just with response)
During timer catch-up (MissPolicy::CatchUp), we use try_notify to avoid blocking the timer task on a full mailbox. If the mailbox is full, we stop the catch-up. Better to skip than deadlock.
API at a Glance
SpawnBuilder Chain
actor.spawn // Start the builder
.named // Optional: assign a name/ID
.on_system // Optional: target a specific ActorSystem
.with_config // Optional: custom ActorConfig
.supervisor // Optional: supervise children (default config)
.with_supervision // Optional: enable supervision (custom config)
.await?; // Finalize: spawns the actor
ActorHandle Methods
| Method | Description |
|---|---|
notify(msg) |
Fire-and-forget (awaits mailbox space) |
try_notify(msg) |
Non-blocking fire-and-forget |
send(msg) |
Request-response (awaits processing) |
stop(reason) |
Stop via system channel (bypasses full mailbox) |
get_status() |
Introspection snapshot via system channel |
is_alive() |
Check if actor is still running |
mailbox_len() |
Current queue depth |
mailbox_available() |
Free space in mailbox |
mailbox_capacity() |
Total mailbox capacity |
id() |
Get actor ID |
ActorContext Methods
| Method | Description |
|---|---|
spawn_child(factory) |
Returns a [ChildSpawnBuilder] - chain .named(), .restart_type(), .shutdown(), .with_config() |
children() |
Introspection info for all supervised children |
terminate_child(id) |
Stop a child WITHOUT restart (spec kept for later revival) |
restart_child(id) |
Revive a terminated child from its stored spec |
delete_child(id) |
Remove a terminated child's spec |
stop_child(id) |
Stop a child, restarting per policy (budget-free bounce) |
schedule(msg) |
Returns a [ScheduleBuilder] - chain .at(instant), .after(delay), or .every(interval) |
cancel_timer(id) |
Cancel specific timer |
cancel_all_timers() |
Cancel all active timers |
active_timer_count() |
Number of active timers |
add_stream(stream) |
Attach an external stream to the mailbox |
cancel_stream(id) |
Cancel a specific stream |
cancel_all_streams() |
Cancel all active streams |
active_stream_count() |
Number of active streams |
self_handle() |
Get handle to this actor |
actor_id() |
This actor's ID |
actor_name() |
This actor's registered name |
status() |
Current lifecycle status |
ActorSystem Methods
| Method | Description |
|---|---|
ActorSystem::default() |
Lazy default system singleton |
ActorSystem::create(name) |
New named system |
ActorSystem::create_with(name, config) |
New system with custom config |
get::<A>(name) |
Typed actor lookup (OTP whereis) |
stop(name) |
Graceful stop by name |
kill(name) |
Force kill by name |
shutdown() |
Coordinated shutdown with escalation |
registered() |
List all registered actor names |
ActorConfig Builder
default
.with_mailbox_capacity
.supervisor // OneForOne, 3 restarts / 5s
.with_supervision // Custom strategy + budget
Testing
Tests cover:
- Request-response and fire-and-forget messaging
- Timer drift policies (Skip, CatchUp, Delay)
- Mailbox backpressure and bounded capacity
- Handle equality and hashing
- Lifecycle hooks and 3-tier termination (Kill bypass)
- ActorSystem registry, spawn methods, and shutdown
- Supervision strategies, restart budget, child lifecycle
- Panic capture, crash restarts, and group restart ordering
- Stream integration (add_stream, StreamEvent, cancellation)
- SpawnBuilder chain (all combinations)
- Error propagation and type preservation
Examples
| Example | Description |
|---|---|
simple_counter |
Basic notify/send usage |
ping_pong |
Bidirectional actor communication |
timers |
Recurring timers with MissPolicy |
cross_comm |
Multiple actors coordinating |
stream_counter |
External stream integration |
supervision |
Parent-child supervision with restart |
Run with:
A Foundation for Agentic Systems
Multi-agent AI systems need exactly the guarantees described above. Each agent is an actor with isolated state: conversation history lives in one place, owned by one task, with no shared mutable state and no Any casting. Bounded mailboxes give token streams and API fan-out natural backpressure, so a slow consumer slows the producer instead of growing an unbounded queue. Tool calls map to type-safe request/response (send), and orchestration across multiple model APIs chains without callback hell. Flaky tool handlers become supervised children: a panic mid-call is a restart per strategy, not a silent death, and a crashed agent comes back with the same ActorId and configuration so the rest of the system can re-look it up and continue. None of this is a separate AI feature set -- it is what OTP fidelity buys you.
Future Enhancements
Planned
- Telemetry hooks: Metrics and tracing integration
- Priority messages: Typed channel abstraction mapping to OTP EEP 76
Non-Goals
- Remote messaging: Tokio Actors is explicitly local (in-process)
- Distributed systems: Use Akka/Orleans/Proto.Actor for that
- Proc macros: We keep it simple, just traits
Architecture
Every actor is a dedicated tokio::task. No shared executor, no fancy scheduling, just Tokio doing what it does best.
Stop signals and status queries flow through a dedicated system channel with biased; select! priority over the user mailbox. This means stop() and get_status() work even when the mailbox is full.
License
MIT OR Apache-2.0
Built for Rust developers who value predictability over magic.
For implementation details and edge cases, see examples/ and tests/.
Author
Saddam Uwejan (Sam) - Rust systems engineer specializing in concurrent systems and production infrastructure.
Building high-performance, production-ready Rust libraries for real-world problems.