diff --git a/Readme.md b/Readme.md index 257d9e1..04be040 100644 --- a/Readme.md +++ b/Readme.md @@ -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)] +struct Lookup(Id); + +impl Router { + #[actor_framework::actor_handler] + async fn route( + &mut self, + lookup: ActorAddress>, + id: Route, + ) -> Result, actor_framework::RequestError> { + lookup.request(Lookup(id.0)).await + } +} +``` + +`ActorRequest` 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`: + +- `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 { + // ... + } +} + +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, @@ -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) diff --git a/examples/embassy-microbit-v2/Cargo.lock b/examples/embassy-microbit-v2/Cargo.lock index 8cb57dc..0ff2a4b 100644 --- a/examples/embassy-microbit-v2/Cargo.lock +++ b/examples/embassy-microbit-v2/Cargo.lock @@ -20,7 +20,7 @@ dependencies = [ [[package]] name = "actor-framework-macros" -version = "0.1.0" +version = "1.0.0" dependencies = [ "proc-macro2", "quote", @@ -29,7 +29,7 @@ dependencies = [ [[package]] name = "actor-framework-rs" -version = "0.1.0" +version = "1.0.0" dependencies = [ "actor-framework-macros", "embassy-futures", diff --git a/examples/embassy-microbit-v2/src/main.rs b/examples/embassy-microbit-v2/src/main.rs index abd06bc..29f28fa 100644 --- a/examples/embassy-microbit-v2/src/main.rs +++ b/examples/embassy-microbit-v2/src/main.rs @@ -22,6 +22,11 @@ enum ButtonPressed { #[actor_command(response = Result)] struct ReadCount; +/// Ask the counter actor to read itself through an injected request capability. +#[derive(actor_framework::ActorCommand)] +#[actor_command(response = Result)] +struct ReportCount; + #[derive(Clone)] struct PressObserved { button: ButtonPressed, @@ -37,6 +42,8 @@ struct ActivityLed { row: Output<'static>, } +struct CountReporter; + impl Counter { #[actor_handler] async fn count_press(&mut self, events: ActorAddress>, button: ButtonPressed) { @@ -59,6 +66,24 @@ impl Counter { } } +impl CountReporter { + #[actor_handler] + async fn report( + &mut self, + counter: ActorAddress>, + _command: ReportCount, + ) -> Result { + 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) { @@ -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; @@ -97,7 +130,7 @@ actor! { actor_system! { pub struct AppActors { - actors: [counter, activity_led]; + actors: [counter, reporter, activity_led]; } } @@ -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), @@ -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; } diff --git a/examples/tokio/Cargo.lock b/examples/tokio/Cargo.lock index ecf00f9..8dd2ccb 100644 --- a/examples/tokio/Cargo.lock +++ b/examples/tokio/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "actor-framework-macros" -version = "0.1.0" +version = "1.0.0" dependencies = [ "proc-macro2", "quote", @@ -13,7 +13,7 @@ dependencies = [ [[package]] name = "actor-framework-rs" -version = "0.1.0" +version = "1.0.0" dependencies = [ "actor-framework-macros", "tokio", diff --git a/examples/tokio/src/main.rs b/examples/tokio/src/main.rs index fd3cdf4..f03fb69 100644 --- a/examples/tokio/src/main.rs +++ b/examples/tokio/src/main.rs @@ -6,6 +6,14 @@ use tokio::sync::Mutex; #[actor_command(response = ())] struct Add(u32); +#[derive(actor_framework::ActorCommand)] +#[actor_command(response = Result)] +struct GetTotal; + +#[derive(actor_framework::ActorCommand)] +#[actor_command(response = Result)] +struct Audit; + #[derive(Clone)] struct TotalChanged(u32); @@ -17,6 +25,9 @@ struct Logger { values: Arc>>, } +/// Demonstrates an actor awaiting a typed response from another actor. +struct Auditor; + impl Counter { #[actor_handler] async fn add(&mut self, events: ActorAddress>, message: Add) { @@ -30,6 +41,13 @@ async fn add(&mut self, events: ActorAddress>, message } } +impl Counter { + #[actor_handler] + async fn get_total(&mut self, _: GetTotal) -> Result { + Ok(self.total) + } +} + impl Logger { #[actor_handler] async fn log(&mut self, event: TotalChanged) { @@ -38,12 +56,23 @@ async fn log(&mut self, event: TotalChanged) { } } +impl Auditor { + #[actor_handler] + async fn audit( + &mut self, + counter: ActorAddress>, + _: Audit, + ) -> Result { + counter.request(GetTotal).await.expect("counter is running") + } +} + static LOG: std::sync::LazyLock>>> = 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 {} }; } @@ -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]; } } @@ -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"); @@ -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:?}"); } diff --git a/src/actor-framework-macros/src/lib.rs b/src/actor-framework-macros/src/lib.rs index f886e7e..76b7f13 100644 --- a/src/actor-framework-macros/src/lib.rs +++ b/src/actor-framework-macros/src/lib.rs @@ -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>` or +/// Context parameters use `ActorAddress>`, `ActorAddress>` or /// `ActorAddress>`. The generated helper binds the concrete /// system router at the composition site. #[proc_macro_attribute] @@ -280,18 +280,18 @@ 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>` or `ActorAddress>`", + "expected ActorAddress with an ActorCommand, ActorRequest, or ActorEvent 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 or ActorEvent") + return syn::Error::new_spanned(marker_ty, "expected ActorCommand, ActorRequest, or ActorEvent") .into_compile_error() .into(); }; @@ -299,8 +299,8 @@ pub fn actor_handler(attribute: TokenStream, input: TokenStream) -> TokenStream unreachable!() }; let kind = marker_segment.ident.to_string(); - if kind != "ActorCommand" && kind != "ActorEvent" { - return syn::Error::new_spanned(marker_ty, "expected ActorCommand or ActorEvent") + if kind != "ActorCommand" && kind != "ActorEvent" && kind != "ActorRequest" { + return syn::Error::new_spanned(marker_ty, "expected ActorCommand, ActorRequest, or ActorEvent") .into_compile_error() .into(); } @@ -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>) }; @@ -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>,) } diff --git a/src/actor-framework/src/actor_address.rs b/src/actor-framework/src/actor_address.rs index 51b3816..d72ef14 100644 --- a/src/actor-framework/src/actor_address.rs +++ b/src/actor-framework/src/actor_address.rs @@ -1,17 +1,28 @@ //! Statically routed, typed actor contexts. //! //! The `actor_handler` attribute lets a handler declare route capabilities as -//! `ActorAddress>` or `ActorAddress>`. The selected +//! `ActorAddress>`, `ActorAddress>`, or +//! `ActorAddress>`. The selected //! system backend binds the concrete generated router type at composition time. //! No dynamic registry or message-type lookup is used. use core::marker::PhantomData; -use crate::{ActorPublishRoute, ActorSendRoute, ActorTrySendRoute, SendError, TrySendError}; +use crate::{ + ActorPublishRoute, ActorRequestRoute, ActorSendRoute, ActorTrySendRoute, Command, RequestError, SendError, + TrySendError, +}; /// Capability marker for sending one command message type. pub struct ActorCommand(PhantomData M>); +/// Capability marker for making one typed command request. +/// +/// Unlike [`ActorCommand`], a request waits for the command handler's typed +/// response. Its bounded capacity and cancellation behavior are defined by the +/// selected runtime's generated [`ActorRequestRoute`]. +pub struct ActorRequest(PhantomData M>); + /// Capability marker for publishing one event message type. pub struct ActorEvent(PhantomData M>); @@ -55,6 +66,22 @@ where } } +impl ActorAddress<'_, ActorRequest, Router> +where + M: Command, + Router: ActorRequestRoute, +{ + /// Send a command and wait for its typed handler response. + /// + /// `RequestError::Closed` means shutdown stopped the destination before + /// acceptance; `ResponseDropped` means an accepted request lost its reply. + /// Dropping this future cancels the caller's wait; runtime-specific reply + /// resources are reclaimed according to the selected backend. + pub async fn request(&self, message: M) -> Result { + self.router.request_route(message).await + } +} + impl ActorAddress<'_, ActorEvent, Router> where Router: ActorPublishRoute, diff --git a/src/actor-framework/src/lib.rs b/src/actor-framework/src/lib.rs index a92b666..82c31e3 100644 --- a/src/actor-framework/src/lib.rs +++ b/src/actor-framework/src/lib.rs @@ -14,7 +14,7 @@ pub mod core; pub mod actor_address; #[cfg(feature = "embassy")] pub mod embassy; -pub use actor_address::{ActorAddress, ActorCommand, ActorEvent}; +pub use actor_address::{ActorAddress, ActorCommand, ActorEvent, ActorRequest}; #[cfg(feature = "wasm")] pub mod wasm; pub use crate::core::{ diff --git a/src/actor-framework/tests/command_requests.rs b/src/actor-framework/tests/command_requests.rs index 0a9e243..2c61c8e 100644 --- a/src/actor-framework/tests/command_requests.rs +++ b/src/actor-framework/tests/command_requests.rs @@ -9,6 +9,12 @@ struct Query(bool); #[actor_command(response = Result<(), ()>)] struct CrashQuery; +#[derive(actor_framework::ActorCommand)] +#[actor_command(response = Result)] +struct Forward(bool); + +struct RouterState; + impl WorkerState { #[actor_framework::actor_handler] async fn handle(&mut self, query: Query) -> Result { @@ -23,6 +29,17 @@ impl WorkerState { } } +impl RouterState { + #[actor_framework::actor_handler] + async fn forward( + &mut self, + worker: actor_framework::ActorAddress>, + command: Forward, + ) -> Result { + worker.request(Query(command.0)).await.unwrap() + } +} + actor_framework::actor! { actor worker: WorkerState { mailbox_capacity: 2; @@ -30,11 +47,24 @@ actor_framework::actor! { events {} }; } -actor_framework::actor_system! { struct Queries { actors: [worker]; } } +actor_framework::actor! { + actor router: RouterState { + mailbox_capacity: 2; + commands { Forward => RouterState::forward; } + events {} + }; +} +actor_framework::actor_system! { struct Queries { actors: [worker, router]; } } #[tokio::test] async fn request_separates_handler_result_from_transport_error() { - let system = Queries::builder().worker(WorkerState).start().unwrap(); + let system = Queries::builder() + .worker(WorkerState) + .router(RouterState) + .start() + .unwrap(); + assert_eq!(system.request(Forward(true)).await, Ok(Ok(42))); + assert_eq!(system.request(Forward(false)).await, Ok(Err("rejected"))); assert_eq!(system.request(Query(true)).await, Ok(Ok(42))); assert_eq!(system.request(Query(false)).await, Ok(Err("rejected"))); system.shutdown().await.unwrap(); @@ -46,7 +76,11 @@ async fn request_separates_handler_result_from_transport_error() { #[tokio::test] async fn actor_panic_drops_the_request_reply() { - let system = Queries::builder().worker(WorkerState).start().unwrap(); + let system = Queries::builder() + .worker(WorkerState) + .router(RouterState) + .start() + .unwrap(); assert_eq!( system.request(CrashQuery).await, Err(actor_framework::RequestError::ResponseDropped) diff --git a/src/actor-framework/tests/embassy_actor_system.rs b/src/actor-framework/tests/embassy_actor_system.rs index abe949d..7355de9 100644 --- a/src/actor-framework/tests/embassy_actor_system.rs +++ b/src/actor-framework/tests/embassy_actor_system.rs @@ -26,12 +26,12 @@ impl Counter { #[actor_framework::actor_handler(request_capacity = 2)] async fn add( &mut self, - stored: ActorAddress>, + stored: ActorAddress>, events: ActorAddress>, message: Add, ) { - stored.post(Store(message.0)).await.unwrap(); + stored.request(Store(message.0)).await.unwrap(); events.publish(Changed(message.0)).await.unwrap(); } } diff --git a/src/actor-framework/tests/wasm_macros.rs b/src/actor-framework/tests/wasm_macros.rs index e38ad8e..bb3ddb1 100644 --- a/src/actor-framework/tests/wasm_macros.rs +++ b/src/actor-framework/tests/wasm_macros.rs @@ -27,6 +27,7 @@ impl Counter { pub async fn add( &mut self, events: actor_framework::ActorAddress>, + _queries: actor_framework::ActorAddress>, command: Add, ) { self.0.set(self.0.get() + command.0);