Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,82 @@ or lifecycle APIs:
6. Handle `SendError` and `TrySendError`; do not assume retries, ordering across
routes, atomic fan-out, persistence, or exactly-once processing.

## Requests, responses, and reply slots

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.

```rust,ignore
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.

### Choosing capacities

`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):

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

## Capacity, backpressure, and failure

Every generated actor declares `mailbox_capacity`; it bounds queued messages,
Expand All @@ -149,5 +225,6 @@ messages. Design application-level acknowledgements where completion matters.
- [Current actor declaration syntax](docs/actor-module-migration.md)
- [Typed actor addresses](docs/typed-actor-addresses.md)
- [Command responses](docs/command-responses.md)
- [Embassy reply-slot and shutdown semantics](docs/embassy.md)
- [When actors are useful](docs/actors-use-cases.md)
- [Release procedure](docs/release.md)
4 changes: 2 additions & 2 deletions examples/embassy-microbit-v2/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 38 additions & 4 deletions examples/embassy-microbit-v2/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ enum ButtonPressed {
#[actor_command(response = Result<u32, ()>)]
struct ReadCount;

/// Ask the counter actor to read itself through an injected request capability.
#[derive(actor_framework::ActorCommand)]
#[actor_command(response = Result<u32, ()>)]
struct ReportCount;

#[derive(Clone)]
struct PressObserved {
button: ButtonPressed,
Expand All @@ -37,6 +42,8 @@ struct ActivityLed {
row: Output<'static>,
}

struct CountReporter;

impl Counter {
#[actor_handler]
async fn count_press(&mut self, events: ActorAddress<ActorEvent<PressObserved>>, button: ButtonPressed) {
Expand All @@ -59,6 +66,24 @@ impl Counter {
}
}

impl CountReporter {
#[actor_handler]
async fn report(
&mut self,
counter: ActorAddress<ActorRequest<ReadCount>>,
_command: ReportCount,
) -> Result<u32, ()> {
info!("reporter requesting count from counter actor");
let result = counter.request(ReadCount).await;
match &result {
Ok(Ok(count)) => info!("reporter received count {}", count),
Ok(Err(())) => info!("counter rejected reporter request"),
Err(_) => info!("reporter request failed: counter stopped before replying"),
}
result.expect("counter actor stopped")
}
}

impl ActivityLed {
#[actor_handler]
async fn blink(&mut self, event: PressObserved) {
Expand Down Expand Up @@ -87,6 +112,14 @@ actor! {
};
}

actor! {
actor reporter: CountReporter {
mailbox_capacity: 2;
commands { ReportCount => CountReporter::report; }
events {}
};
}

actor! {
actor activity_led: ActivityLed {
mailbox_capacity: 2;
Expand All @@ -97,7 +130,7 @@ actor! {

actor_system! {
pub struct AppActors {
actors: [counter, activity_led];
actors: [counter, reporter, activity_led];
}
}

Expand All @@ -108,6 +141,7 @@ async fn main(spawner: Spawner) {
let mut button_b = Input::new(peripherals.P0_23, Pull::Up);
let actors = AppActors::builder()
.counter(Counter { count: 0 })
.reporter(CountReporter)
.activity_led(ActivityLed {
// P0.28/P0.21 drive one micro:bit-v2 LED matrix pixel.
column: Output::new(peripherals.P0_28, Level::High, OutputDrive::Standard),
Expand All @@ -134,10 +168,10 @@ async fn main(spawner: Spawner) {

// `request` waits for the typed handler response. Handler errors stay
// inside the outer transport result.
match actors.request(ReadCount).await {
Ok(Ok(count)) => info!("counter now reads {}", count),
match actors.request(ReportCount).await {
Ok(Ok(count)) => info!("reporter queried counter; count is {}", count),
Ok(Err(())) => info!("counter rejected read"),
Err(_) => info!("counter actor stopped before replying"),
Err(_) => info!("reporter or counter stopped before replying"),
}
Timer::after(Duration::from_millis(50)).await;
}
Expand Down
4 changes: 2 additions & 2 deletions examples/tokio/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 43 additions & 2 deletions examples/tokio/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ use tokio::sync::Mutex;
#[actor_command(response = ())]
struct Add(u32);

#[derive(actor_framework::ActorCommand)]
#[actor_command(response = Result<u32, ()>)]
struct GetTotal;

#[derive(actor_framework::ActorCommand)]
#[actor_command(response = Result<u32, ()>)]
struct Audit;

#[derive(Clone)]
struct TotalChanged(u32);

Expand All @@ -17,6 +25,9 @@ struct Logger {
values: Arc<Mutex<Vec<u32>>>,
}

/// Demonstrates an actor awaiting a typed response from another actor.
struct Auditor;

impl Counter {
#[actor_handler]
async fn add(&mut self, events: ActorAddress<ActorEvent<TotalChanged>>, message: Add) {
Expand All @@ -30,6 +41,13 @@ async fn add(&mut self, events: ActorAddress<ActorEvent<TotalChanged>>, message
}
}

impl Counter {
#[actor_handler]
async fn get_total(&mut self, _: GetTotal) -> Result<u32, ()> {
Ok(self.total)
}
}

impl Logger {
#[actor_handler]
async fn log(&mut self, event: TotalChanged) {
Expand All @@ -38,12 +56,23 @@ async fn log(&mut self, event: TotalChanged) {
}
}

impl Auditor {
#[actor_handler]
async fn audit(
&mut self,
counter: ActorAddress<ActorRequest<GetTotal>>,
_: Audit,
) -> Result<u32, ()> {
counter.request(GetTotal).await.expect("counter is running")
}
}

static LOG: std::sync::LazyLock<Arc<Mutex<Vec<u32>>>> = std::sync::LazyLock::new(|| Arc::new(Mutex::new(Vec::new())));

actor! {
pub actor counter: Counter {
mailbox_capacity: 8;
commands { Add => Counter::add; }
commands { Add => Counter::add;, GetTotal => Counter::get_total; }
events {}
};
}
Expand All @@ -56,9 +85,17 @@ actor! {
};
}

actor! {
pub actor auditor: Auditor {
mailbox_capacity: 2;
commands { Audit => Auditor::audit; }
events {}
};
}

actor_system! {
pub struct AppActors {
actors: [counter, logger];
actors: [counter, logger, auditor];
}
}

Expand All @@ -68,6 +105,7 @@ async fn main() {
let actors = AppActors::builder()
.counter(Counter { total: 0 })
.logger(Logger { values: LOG.clone() })
.auditor(Auditor)
.start()
.expect("all actor states supplied");

Expand All @@ -85,5 +123,8 @@ async fn main() {

let observed = LOG.lock().await.clone();
assert_eq!(observed, [5, 12]);
// Auditor receives `Audit`, then uses an injected ActorRequest capability
// to await Counter's typed GetTotal response.
assert_eq!(actors.request(Audit).await, Ok(Ok(12)));
println!("Observed totals: {observed:?}");
}
16 changes: 10 additions & 6 deletions src/actor-framework-macros/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ pub fn derive_actor_command(input: TokenStream) -> TokenStream {
/// Method generics are unsupported; generic inherent impls are supported.
/// Reference the handler as `State::method` in the actor manifest.
///
/// Context parameters use `ActorAddress<ActorCommand<Message>>` or
/// Context parameters use `ActorAddress<ActorRequest<Message>>`, `ActorAddress<ActorCommand<Message>>` or
/// `ActorAddress<ActorEvent<Event>>`. The generated helper binds the concrete
/// system router at the composition site.
#[proc_macro_attribute]
Expand Down Expand Up @@ -280,27 +280,27 @@ pub fn actor_handler(attribute: TokenStream, input: TokenStream) -> TokenStream
let syn::PathArguments::AngleBracketed(args) = &segment.arguments else {
return syn::Error::new_spanned(
&argument.ty,
"expected `ActorAddress<ActorCommand<M>>` or `ActorAddress<ActorEvent<E>>`",
"expected ActorAddress with an ActorCommand<M>, ActorRequest<M>, or ActorEvent<E> marker",
)
.into_compile_error()
.into();
};
let Some(syn::GenericArgument::Type(marker_ty)) = args.args.first() else {
return syn::Error::new_spanned(&argument.ty, "expected a command/event marker type")
return syn::Error::new_spanned(&argument.ty, "expected a command/request/event marker type")
.into_compile_error()
.into();
};
let syn::Type::Path(marker_path) = marker_ty else {
return syn::Error::new_spanned(marker_ty, "expected ActorCommand<M> or ActorEvent<E>")
return syn::Error::new_spanned(marker_ty, "expected ActorCommand<M>, ActorRequest<M>, or ActorEvent<E>")
.into_compile_error()
.into();
};
let Some(marker_segment) = marker_path.path.segments.last() else {
unreachable!()
};
let kind = marker_segment.ident.to_string();
if kind != "ActorCommand" && kind != "ActorEvent" {
return syn::Error::new_spanned(marker_ty, "expected ActorCommand<M> or ActorEvent<E>")
if kind != "ActorCommand" && kind != "ActorEvent" && kind != "ActorRequest" {
return syn::Error::new_spanned(marker_ty, "expected ActorCommand<M>, ActorRequest<M>, or ActorEvent<E>")
.into_compile_error()
.into();
}
Expand Down Expand Up @@ -338,6 +338,8 @@ pub fn actor_handler(attribute: TokenStream, input: TokenStream) -> TokenStream
for (_, kind, message, _) in &contexts {
let predicate = if kind == "ActorCommand" {
quote!(#router: ::actor_framework::ActorSendRoute<#message>)
} else if kind == "ActorRequest" {
quote!(#router: ::actor_framework::ActorRequestRoute<#message>)
} else {
quote!(#router: ::actor_framework::ActorPublishRoute<#message>)
};
Expand Down Expand Up @@ -389,6 +391,8 @@ pub fn actor_handler(attribute: TokenStream, input: TokenStream) -> TokenStream
let bounds = contexts.iter().map(|(_, kind, message, _)| {
if kind == "ActorCommand" {
quote!(#router: ::actor_framework::ActorSendRoute<#message>,)
} else if kind == "ActorRequest" {
quote!(#router: ::actor_framework::ActorRequestRoute<#message>,)
} else {
quote!(#router: ::actor_framework::ActorPublishRoute<#message>,)
}
Expand Down
Loading
Loading