6 releases
Uses new Rust 2024
| new 0.2.1 | May 15, 2026 |
|---|---|
| 0.2.0 | May 15, 2026 |
| 0.1.3 | Apr 9, 2026 |
| 0.1.2 | Dec 23, 2025 |
| 0.1.0 | Nov 28, 2025 |
#915 in Asynchronous
118 downloads per month
33KB
268 lines
Actorizor
Actorizor takes standard Rust structs and turns them into tokio actors.
Credit to Alice Ryhl for the approach.
Usage
struct MyActor {
some_state: u64,
}
#[actorizor::actorize]
impl MyActor {
pub fn new() -> Self {
Self {
some_state: 0
}
}
pub fn increment(&mut self) -> u64 {
self.some_state += 1;
self.some_state
}
}
#[tokio::main]
async fn main() {
let handle = MyActorHandle::new();
let new_val = handle.increment().await.expect("error incrementing");
println!("New value: {new_val}");
}
You only need actorizor in your Cargo.toml. The generated code resolves
its tokio / tracing usage through actorizor's own dependency tree (via a
hidden re-export), so you don't have to declare those just to satisfy the
macro. You'll still depend on
tokio yourself in practice — you need a runtime for #[tokio::main] — and
because tokio is a single semver-1.x crate, Cargo unifies your version
with actorizor's: you choose the exact tokio version, actorizor only sets
a permissive floor.
Handles are cheap to clone and cloning is the expected way to have multiple producers communicate with the actor.
How it works
Actorizor creates a new MyActorHandle type for the impl block specified.
This handle is used to interact with the actor, sending messages and
receiving responses. Any public initializer or &self/&mut self method on
the base actor is proxied onto the handle.
A run_actor function takes ownership of the actor and brokers messages
from the handle onto the underlying actor. Your actor is extended with a
handle_msg entrypoint that run_actor dispatches into.
Queue depth defaults to 10; override with #[actorizor::actorize(32)] or
#[actorizor::actorize(qdepth = 32)].
Lifecycle controls
Every generated handle has, in addition to the proxied methods:
abort()— forceful. Cancels the actor task at its next.awaiteven if other handle clones exist. In-flight calls returnRecvFromActorError.shutdown()— cooperative. The actor finishes the message it's currently handling, then the loop exits. Doesn't require dropping handle clones.is_alive()/is_finished()— cheap liveness checks against the task.
The actor task also exits naturally when the last handle clone is dropped.
Supervision
By default, generated constructors (MyActorHandle::new() etc.) spawn the
actor via tokio::task::spawn and drop the JoinHandle — fire and forget.
To take ownership of how the task is spawned — to hold the JoinHandle,
observe panics, emit metrics, or keep a registry of live actors — implement
the Supervisor trait and use the generated launch_with method:
use actorizor::{actorize, Supervisor, TokioSpawn};
let h = MyActorHandle::launch_with(MyActor::new(), &TokioSpawn); // no-op supervisor
TokioSpawn is the always-available trivial supervisor (delegates to
tokio::task::spawn). A real supervisor implements:
pub trait Supervisor: Send + Sync + 'static {
fn spawn<F>(&self, name: &'static str, fut: F) -> tokio::task::AbortHandle
where F: std::future::Future<Output = ()> + Send + 'static;
}
Supervisors are owned values you construct in main (or per test) and pass
by reference into launch_with — there is no global state in the library.
TrackingSupervisor (feature = "tracking")
Enabling the tracking feature unlocks TrackingSupervisor: a name-keyed
registry that watches each actor task, emits a tracing event on
exit/abort/panic, and exposes query + control methods —
alive_count, alive_count_by_name, is_alive, snapshot,
abort_by_name, abort_by_id, abort_all.
actorizor = { version = "0.2", features = ["tracking"] }
See examples/supervisor.rs for a full
walkthrough — run it with
cargo run --example supervisor --features tracking.
Examples
Runnable, narrated demos live in examples/:
| Example | Run | Shows |
|---|---|---|
basic |
cargo run --example basic |
construct, call sync/async methods, clone-and-share |
constructors |
cargo run --example constructors |
what becomes a ctor/method, and what is not on the handle |
lifecycle |
cargo run --example lifecycle |
natural exit vs shutdown() vs abort() |
custom_supervisor |
cargo run --example custom_supervisor |
implementing Supervisor yourself |
generic |
cargo run --example generic |
a generic actor: two instantiations, non-Clone payload, where-clause |
generic_supervisor |
cargo run --example generic_supervisor |
one custom Supervisor driving two differently-typed generic actors |
supervisor |
cargo run --example supervisor --features tracking |
the bundled TrackingSupervisor |
Generics
Impl-level type and const generics are supported, including
where-clauses:
#[actorizor::actorize]
impl<T: Send + 'static> Store<T> {
pub fn new() -> Self { Self { items: Vec::new() } }
pub fn push(&mut self, item: T) -> usize { self.items.push(item); self.items.len() }
}
let s = StoreHandle::<String>::new();
The generated StoreHandle<T> is Clone even when T is not (the
impl is hand-written, not derived). What's not supported, rejected at
compile time with a clear error:
- Generic methods —
pub fn foo<U>(&self, x: U). Move the parameter to the impl, or monomorphize at the call site. - Lifetime parameters —
impl<'a> MyActor<'a>. An actor task is spawned and must be'static(the actor may still hold'staticreferences internally).
Limitations
- One actor per module. The macro emits a module-scoped
run_actorfree function with a fixed name. Two#[actorize]blocks in the same module collide with a duplicate-symbol error. Put each actor in its ownmod { ... }(or its own file). - Associated functions with no
&selfreceiver that don't returnSelfare neither methods nor constructors — they stay on the originalimplblock and are not exposed on the handle.
Diags
Enabling the diagout feature flag dumps the macro output to stderr
whenever the macro is processed.
Using actorizor with Claude Code
Claude does not have built-in knowledge of actorizor. If you use Claude Code
in a project that depends on actorizor, add the following to your project's
CLAUDE.md (create one at your repo root if it doesn't exist). This tells
Claude how to use actorizor correctly and prevents it from suggesting
Arc<Mutex<T>> patterns that actorizor is specifically designed to replace.
## actorizor
This project uses the `actorizor` crate. Apply `#[actorizor::actorize]` to an `impl` block to
turn a plain struct into a tokio actor. Never suggest Arc<Mutex<T>> for shared state — clone
the handle instead.
### What the macro generates (example: `MyActor`)
- `MyActorHandle` — the public interface. Cheap to clone; cloning is the intended sharing
mechanism. Carries `abort()`, `shutdown()`, `is_alive()`, `is_finished()`, and
`launch_with<S: Supervisor>(actor, &S)` in addition to the proxied methods.
- `MyActorActorMsg` — internal message enum. Do not use directly.
- `MyActorHandleError` — error type on all handle methods.
### Rules
- Only `pub` methods become handle methods. Private methods stay on the actor only.
- A `pub fn` returning `Self` or the actor type is a constructor; it migrates to the handle
(e.g. `pub fn new() -> Self` becomes `MyActorHandle::new()`).
- A `pub fn` with no `&self` receiver that does NOT return `Self` is neither a method nor a
constructor — it is not exposed on the handle.
- All non-constructor handle methods are `async` and return `Result<T, MyActorHandleError>`
(for a generic actor the error is `MyActorHandleError<…>`; you rarely name it — use `?`).
- After `abort()` or `shutdown()`, further handle method calls return an `Err`. Don't treat a
post-teardown call as infallible.
- Queue depth defaults to 10; override with `#[actorizor::actorize(32)]` or
`#[actorizor::actorize(qdepth = 32)]`. `qdepth = 0` is a compile error.
- Impl-level type/const generics + `where`-clauses are supported
(`impl<T: Bound> MyActor<T>`, `impl<const N: usize> Buf<N>`). Generic *methods*
(`fn f<U>(…)`) and lifetime parameters (`impl<'a> MyActor<'a>`) are rejected with a
clear compile error.
- Every generic param must be `Send + 'static` (the actor task is spawned). Owned
custom structs, `&'static` references, `Box<_>`, and `Arc<_>` are fine. **`Rc<_>` is
NOT** — it is `!Send`; use `Arc`. (No `T: Clone`/`Debug` needed — the handle/error
are `Clone`/`Debug` regardless of `T`.)
- Name the type parameter at the construction call site for a generic actor:
`StoreHandle::<u64>::new()` / `StoreHandle::<u64>::launch_with(Store::new(), &sup)`.
- One actor per module — the macro emits a module-scoped `run_actor`; two actors in one
module collide. Wrap each in its own `mod`.
### Supervision
- Constructors (`MyActorHandle::new()`) are unsupervised (`tokio::task::spawn`, fire & forget).
- For supervision, implement `actorizor::Supervisor` and call
`MyActorHandle::launch_with(MyActor::new(), &supervisor)`. Supervisors are owned values, not
globals. `actorizor::TokioSpawn` is the no-op default; `actorizor::TrackingSupervisor`
(feature `tracking`) is a registry with abort/snapshot controls.
- Forceful stop: `handle.abort()`. Cooperative stop: `handle.shutdown()`.
### Required dependencies
Only `actorizor` itself. Generated code references `tokio`/`tracing` through
actorizor's hidden re-export. You'll have `tokio`
as a direct dependency anyway (you need a runtime), and Cargo unifies that
single semver-1.x tokio with actorizor's — your version wins. Don't rename
the `actorizor` crate in `Cargo.toml` (e.g. `foo = { package = "actorizor" }`):
the macro emits `::actorizor::…` paths and won't resolve under a different name.
### Example
```rust
struct Counter { value: u64 }
#[actorizor::actorize]
impl Counter {
pub fn new() -> Self { Self { value: 0 } }
pub fn increment(&mut self) -> u64 { self.value += 1; self.value }
}
// Usage:
let handle = CounterHandle::new();
let v = handle.increment().await.unwrap();
let h2 = handle.clone(); // share by cloning, not Arc
```
Dependencies
~6.5–10MB
~95K SLoC