actor-framework-rs is a statically routed async actor framework. Actors own their
state, handle one message at a time, and communicate through typed, bounded
mailboxes. The published package is actor-framework-rs; import it as
actor_framework.
The 1.0 release provides a stable public API for the documented features. The
runtime-neutral core is no_std, allocator-free, and does not select an executor.
Generated actor systems are
available for:
- Tokio (the default feature) for
stdapplications; wasm32-unknown-unknownwithwasm-bindgenand a browser, worker, or Node host (wasmfeature); and- Embassy (
embassyfeature), using statically allocated task and mailbox storage.
Runtime lifecycle and storage semantics differ; consult Tokio, WASM, and Embassy before choosing a backend. There is no dynamic actor registry, supervision/restart, delivery guarantee beyond mailbox acceptance, or implicit timer API.
Use the default Tokio feature for a hosted application. Use only wasm for
wasm32-unknown-unknown; it uses local tasks and permits non-Send state. Use
only embassy for an Embassy application with static storage and an executor
Spawner. Do not treat the runtime-neutral core module as a ready-to-spawn
actor system: it supplies contracts, inline storage, and the caller-driven
run loop, while the runtime feature supplies generated systems and channels.
Tokio is enabled by default:
[dependencies]
actor-framework-rs = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }For a runtime-specific build, disable defaults explicitly:
# Browser/worker WASM
actor-framework-rs = { version = "1", default-features = false, features = ["wasm"] }
# Embassy / no_std application
actor-framework-rs = { version = "1", default-features = false, features = ["embassy"] }default-features = false without a runtime feature exposes the heap-free
core contracts and inline Mailbox, not the actor!/actor_system! macros.
Commands derive ActorCommand and declare their response type. An actor!
declaration describes exactly one actor; actor_system! composes declarations.
Handlers are async inherent methods. Typed ActorAddress parameters are
injected by #[actor_handler] and allow only the declared command/event route.
use actor_framework::{actor, actor_handler, actor_system};
#[derive(actor_framework::ActorCommand)]
#[actor_command(response = ())]
struct Add(u32);
struct Counter(u32);
impl Counter {
#[actor_handler]
async fn add(&mut self, message: Add) {
self.0 += message.0;
}
}
actor! {
pub actor counter: Counter {
mailbox_capacity: 8;
commands { Add => Counter::add; }
events {}
};
}
actor_system! {
pub struct App { actors: [counter]; }
}
#[tokio::main]
async fn main() {
let app = App::builder().counter(Counter(0)).start().unwrap();
app.send(Add(1)).await.unwrap();
app.shutdown().await.unwrap();
}See examples/tokio, the cross-crate composition example in
examples/module-lib, and the runnable
examples/wasm. The Micro:bit example documents Embassy
setup in examples/embassy-microbit-v2.
These rules are the intended public API usage; do not infer alternate routing or lifecycle APIs:
- One
actor!invocation declares one actor. Compose declarations only withactor_system!; provide every state value through its generated builder. - A routed handler is an async inherent method with
&mut self, optional typed route addresses, and exactly one final message parameter. Mark every routed handler with#[actor_handler]. Free functions, trait methods, and dynamic actor lookup are not supported. - A command must derive
ActorCommand, declare#[actor_command(response = Type)], and be routed to exactly one handler.system.request(command).awaitreturns that response orRequestError;sendis fire-and-forget acceptance. - An event must be
Clonewhen it has subscribers. Declare subscribers in each actor'seventsblock, or writeEvent => []for an intentional no-op route. - Use a positive
mailbox_capacityfor generated actors. Capacity is a queue bound, not a processing-completion guarantee. The standalone coreMailbox<M, 0>is valid but permanently full and is not a rendezvous channel. - Handle
SendErrorandTrySendError; do not assume retries, ordering across routes, atomic fan-out, persistence, or exactly-once processing.
A command declares its response type. request waits for that typed response;
the outer Result is transport/lifecycle failure and an inner Result, when
used as the response type, is the handler's application result.
use actor_framework::{ActorAddress, ActorRequest};
#[derive(actor_framework::ActorCommand)]
#[actor_command(response = Result<u32, LookupError>)]
struct Lookup(Id);
impl Router {
#[actor_framework::actor_handler]
async fn route(
&mut self,
lookup: ActorAddress<ActorRequest<Lookup>>,
id: Route,
) -> Result<Result<u32, LookupError>, actor_framework::RequestError> {
lookup.request(Lookup(id.0)).await
}
}ActorRequest<M> is an injected, statically permitted capability: a handler
can request only command types declared by the composed system. It has no global
registry and works identically in Tokio, WASM, and Embassy. Its
.request(message).await returns Result<M::Response, RequestError>:
RequestError::Closed: shutdown or task exit closed the destination before the request was accepted.RequestError::ResponseDropped: the request was accepted, but its reply endpoint was dropped (for example, the actor task failed).
Dropping the caller's request future stops waiting; it does not cancel an accepted handler. Avoid request cycles (including requesting the same single-threaded actor from its active handler), as they can deadlock.
mailbox_capacity belongs in every actor! declaration and limits queued
commands/events for that actor. It does not include the handler currently
executing, and it does not limit how many callers are waiting for responses.
Choose it from the maximum burst of accepted work that the actor may buffer;
send, post, and accepted requests wait when it is full.
For Embassy, each requestable handler also has a statically allocated reply-slot
pool. Configure its number of concurrent in-flight requests with
#[actor_handler(request_capacity = N)] (default: 1; N must be positive):
impl Worker {
#[actor_framework::actor_handler(request_capacity = 4)]
async fn lookup(&mut self, message: Lookup) -> Result<u32, LookupError> {
// ...
}
}
actor_framework::actor! {
actor worker: Worker {
mailbox_capacity: 8;
commands { Lookup => Worker::lookup; }
events {}
};
}Here up to four callers can reserve reply storage while requests are queued or
handled; further callers wait for a slot. Increasing request_capacity consumes
static Embassy storage proportional to the response type and creates no heap
allocation. Tokio and WASM retain their backend request machinery; the
attribute is accepted so one handler definition remains portable. Set both
capacities deliberately: mailbox_capacity bounds buffered work, while Embassy
request_capacity bounds simultaneous response slots.
Every generated actor declares mailbox_capacity; it bounds queued messages,
not the message currently being handled, task/future storage, or execution stacks.
There is no universal actor stack_size: Tokio worker stacks belong to the
runtime, WASM limits to the toolchain/host, and Embassy future storage is sized
statically by the compiler. See the resource planning guide
for configuration examples, all generated task/storage counts, and target RAM
verification.
send(...).awaitandActorAddress::post(...).awaitwait for capacity. Success means the mailbox accepted the message, not that handling finished.try_send/try_postnever wait and return the original message inTrySendError::FullorTrySendError::Closed.- Events are delivered sequentially to subscribers. A slow subscriber blocks
publication; failure or cancellation can leave an already-delivered prefix.
An explicitly declared
Event => []route is a successful no-op. - Closure, shutdown, and task failures reject later sends. Do not use mailbox acceptance as a persistence, processing, or exactly-once acknowledgement.
The precise shutdown behavior is backend-specific, especially for queued messages. Design application-level acknowledgements where completion matters.