From b0e5afb2c69e1f5b6ab8ee82b59582348877c819 Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Wed, 11 Feb 2026 16:37:03 -0300 Subject: [PATCH 01/20] wip: protocol trait approach for #145 --- CLAUDE.md | 85 ++ Cargo.lock | 9 + Cargo.toml | 1 + concurrency/src/error.rs | 20 +- concurrency/src/lib.rs | 4 +- concurrency/src/message.rs | 52 + concurrency/src/messages.rs | 2 - concurrency/src/tasks/actor.rs | 1246 ++++++++-------------- concurrency/src/tasks/mod.rs | 10 +- concurrency/src/tasks/stream.rs | 23 +- concurrency/src/tasks/stream_tests.rs | 154 ++- concurrency/src/tasks/time.rs | 31 +- concurrency/src/tasks/timer_tests.rs | 264 ++--- concurrency/src/threads/actor.rs | 488 +++++---- concurrency/src/threads/mod.rs | 10 +- concurrency/src/threads/stream.rs | 18 +- concurrency/src/threads/time.rs | 48 +- concurrency/src/threads/timer_tests.rs | 266 ++--- docs/ALTERNATIVE_APPROACHES.md | 237 ++++ examples/bank/src/main.rs | 72 +- examples/bank/src/messages.rs | 42 +- examples/bank/src/server.rs | 141 +-- examples/bank_threads/src/main.rs | 72 +- examples/bank_threads/src/messages.rs | 42 +- examples/bank_threads/src/server.rs | 134 +-- examples/blocking_genserver/main.rs | 113 +- examples/busy_genserver_warning/main.rs | 51 +- examples/chat_room/Cargo.toml | 13 + examples/chat_room/src/main.rs | 71 ++ examples/chat_room/src/messages.rs | 7 + examples/chat_room/src/protocols.rs | 17 + examples/chat_room/src/room.rs | 71 ++ examples/chat_room/src/user.rs | 36 + examples/name_server/src/main.rs | 31 +- examples/name_server/src/messages.rs | 26 +- examples/name_server/src/server.rs | 53 +- examples/ping_pong/src/consumer.rs | 31 +- examples/ping_pong/src/main.rs | 67 +- examples/ping_pong/src/messages.rs | 16 +- examples/ping_pong/src/producer.rs | 46 +- examples/ping_pong/src/protocols.rs | 40 + examples/signal_test/src/main.rs | 79 +- examples/signal_test_threads/src/main.rs | 75 +- examples/updater/src/main.rs | 2 +- examples/updater/src/messages.rs | 14 +- examples/updater/src/server.rs | 45 +- examples/updater_threads/src/main.rs | 2 +- examples/updater_threads/src/messages.rs | 14 +- examples/updater_threads/src/server.rs | 46 +- 49 files changed, 2149 insertions(+), 2288 deletions(-) create mode 100644 CLAUDE.md create mode 100644 concurrency/src/message.rs delete mode 100644 concurrency/src/messages.rs create mode 100644 docs/ALTERNATIVE_APPROACHES.md create mode 100644 examples/chat_room/Cargo.toml create mode 100644 examples/chat_room/src/main.rs create mode 100644 examples/chat_room/src/messages.rs create mode 100644 examples/chat_room/src/protocols.rs create mode 100644 examples/chat_room/src/room.rs create mode 100644 examples/chat_room/src/user.rs create mode 100644 examples/ping_pong/src/protocols.rs diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e87a107 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,85 @@ +# Spawned - Project Context + +Rust actor framework inspired by Erlang OTP. Provides `Actor` trait (similar to GenServer) that separates concurrency logic from business logic. + +## Project Structure + +``` +spawned/ +├── concurrency/ # Main library: Actor trait, timers, streams +│ └── src/ +│ ├── tasks/ # Async version (requires tokio runtime) +│ └── threads/ # Sync version (native OS threads) +├── rt/ # Runtime abstractions (wraps tokio, provides CancellationToken) +│ └── src/ +│ ├── tasks/ # Tokio-based runtime +│ └── threads/ # Thread-based runtime +└── examples/ # Usage examples (name_server, bank, ping_pong, etc.) +``` + +## Two Execution Modes + +- **tasks**: Async/await with tokio. Use `spawned_rt::tasks` and `spawned_concurrency::tasks` +- **threads**: Blocking, no async. Use `spawned_rt::threads` and `spawned_concurrency::threads` + +Both provide identical Actor API. The `tasks` module has `Backend` enum: `Async`, `Blocking`, `Thread`. + +## Key Types + +| Type | Description | +|------|-------------| +| `Actor` | Trait for stateful message handlers | +| `ActorRef` | Handle to communicate with a running actor | +| `ActorRef::request()` | Sync call, waits for reply (like Erlang `call`) | +| `ActorRef::send()` | Async fire-and-forget (like Erlang `cast`) | +| `ActorRef::join()` | Wait for actor to stop | +| `CancellationToken` | Signal cancellation to timers/actors | +| `TimerHandle` | Handle for `send_after`/`send_interval` | + +## Actor Lifecycle + +1. `init()` - Setup before main loop +2. `handle_request()` / `handle_message()` - Process messages +3. `teardown()` - Cleanup after stop + +## Common Patterns + +```rust +// Start an actor +let mut handle = MyActor::new().start(); + +// Request (sync call) +let reply = handle.request(MyRequest::GetValue).await?; + +// Send (fire-and-forget) +handle.send(MyMessage::DoSomething).await?; + +// Timer that wakes on cancellation +send_after(Duration::from_secs(5), handle.clone(), Msg::Timeout); + +// Wait for actor to stop +handle.join().await; +``` + +## Testing + +```bash +cargo test --workspace # Run all tests +cargo test -p spawned-concurrency # Test concurrency crate only +``` + +## Conventions + +- Use `tracing` for logging (not `println!`) +- Prefer `&self` over `&mut self` for thread-safe methods +- Handle poisoned mutexes with `unwrap_or_else(|p| p.into_inner())` +- Use conventional commits: `feat:`, `fix:`, `refactor:`, `docs:`, `test:` + +## PR List Format + +When listing PRs, output raw markdown in a code block so it can be copied directly: +- Format: [#NUMBER](URL) title (+additions/-deletions) ✅ +- Use ⏳ instead of ✅ when approvals are 0 +- Group by label/topic with a header +- No bullet points on PR lines +- End with: **Summary:** X PRs | **Total:** (+A/-D) | **Net:** ±N lines \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 4d962a6..978e604 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -120,6 +120,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chat_room" +version = "0.1.0" +dependencies = [ + "spawned-concurrency", + "spawned-rt", + "tracing", +] + [[package]] name = "core-foundation" version = "0.9.4" diff --git a/Cargo.toml b/Cargo.toml index f234fe4..7bbb458 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [ "examples/busy_genserver_warning", "examples/signal_test", "examples/signal_test_threads", + "examples/chat_room", ] [workspace.dependencies] diff --git a/concurrency/src/error.rs b/concurrency/src/error.rs index 3b23e4b..35123ef 100644 --- a/concurrency/src/error.rs +++ b/concurrency/src/error.rs @@ -1,28 +1,20 @@ #[derive(Debug, thiserror::Error)] pub enum ActorError { - #[error("Callback Error")] - Callback, - #[error("Initialization error")] - Initialization, - #[error("Server error")] - Server, - #[error("Unsupported Request on this Actor")] - RequestUnused, - #[error("Unsupported Message on this Actor")] - MessageUnused, + #[error("Actor stopped")] + ActorStopped, #[error("Request to Actor timed out")] RequestTimeout, } impl From> for ActorError { fn from(_value: spawned_rt::threads::mpsc::SendError) -> Self { - Self::Server + Self::ActorStopped } } impl From> for ActorError { fn from(_value: spawned_rt::tasks::mpsc::SendError) -> Self { - Self::Server + Self::ActorStopped } } @@ -32,7 +24,7 @@ mod tests { #[test] fn test_error_into_std_error() { - let error: &dyn std::error::Error = &ActorError::Callback; - assert_eq!(error.to_string(), "Callback Error"); + let error: &dyn std::error::Error = &ActorError::ActorStopped; + assert_eq!(error.to_string(), "Actor stopped"); } } diff --git a/concurrency/src/lib.rs b/concurrency/src/lib.rs index 0edcab8..9d71f12 100644 --- a/concurrency/src/lib.rs +++ b/concurrency/src/lib.rs @@ -1,6 +1,4 @@ -//! spawned concurrency -//! Some basic traits and structs to implement concurrent code à-la-Erlang. pub mod error; -pub mod messages; +pub mod message; pub mod tasks; pub mod threads; diff --git a/concurrency/src/message.rs b/concurrency/src/message.rs new file mode 100644 index 0000000..a97b310 --- /dev/null +++ b/concurrency/src/message.rs @@ -0,0 +1,52 @@ +pub trait Message: Send + 'static { + type Result: Send + 'static; +} + +/// Declarative macro for defining message types. +/// +/// Supports both unit structs and structs with fields, and they can be mixed +/// in a single invocation: +/// +/// ```ignore +/// messages! { +/// GetCount -> u64; +/// Deposit { who: String, amount: i32 } -> Result; +/// Stop -> () +/// } +/// ``` +#[macro_export] +macro_rules! messages { + () => {}; + + // Base: unit message + ($(#[$meta:meta])* $name:ident -> $result:ty) => { + $(#[$meta])* + #[derive(Debug)] + pub struct $name; + impl $crate::message::Message for $name { + type Result = $result; + } + }; + + // Base: struct message + ($(#[$meta:meta])* $name:ident { $($field:ident : $ftype:ty),* $(,)? } -> $result:ty) => { + $(#[$meta])* + #[derive(Debug)] + pub struct $name { $(pub $field: $ftype,)* } + impl $crate::message::Message for $name { + type Result = $result; + } + }; + + // Recursive: unit message followed by more + ($(#[$meta:meta])* $name:ident -> $result:ty; $($rest:tt)*) => { + $crate::messages!($(#[$meta])* $name -> $result); + $crate::messages!($($rest)*); + }; + + // Recursive: struct message followed by more + ($(#[$meta:meta])* $name:ident { $($field:ident : $ftype:ty),* $(,)? } -> $result:ty; $($rest:tt)*) => { + $crate::messages!($(#[$meta])* $name { $($field : $ftype),* } -> $result); + $crate::messages!($($rest)*); + }; +} diff --git a/concurrency/src/messages.rs b/concurrency/src/messages.rs deleted file mode 100644 index e0aceb8..0000000 --- a/concurrency/src/messages.rs +++ /dev/null @@ -1,2 +0,0 @@ -#[derive(Clone, Debug)] -pub struct Unused; diff --git a/concurrency/src/tasks/actor.rs b/concurrency/src/tasks/actor.rs index d41e3a3..71f8357 100644 --- a/concurrency/src/tasks/actor.rs +++ b/concurrency/src/tasks/actor.rs @@ -1,470 +1,382 @@ -//! Actor trait and structs to create an abstraction similar to Erlang gen_server. -//! See examples/name_server for a usage example. -use crate::{ - error::ActorError, - tasks::InitResult::{NoSuccess, Success}, -}; +use crate::error::ActorError; +use crate::message::Message; use core::pin::pin; use futures::future::{self, FutureExt as _}; use spawned_rt::{ tasks::{self as rt, mpsc, oneshot, timeout, watch, CancellationToken, JoinHandle}, threads, }; -use std::{fmt::Debug, future::Future, panic::AssertUnwindSafe, time::Duration}; +use std::{fmt::Debug, future::Future, panic::AssertUnwindSafe, pin::Pin, time::Duration}; const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); -/// Execution backend for Actor. -/// -/// Determines how the Actor's async loop is executed. Choose based on -/// the nature of your workload: -/// -/// # Backend Comparison -/// -/// | Backend | Execution Model | Best For | Limitations | -/// |---------|-----------------|----------|-------------| -/// | `Async` | Tokio task | Non-blocking I/O, async operations | Blocks runtime if sync code runs too long | -/// | `Blocking` | Tokio blocking pool | Short blocking operations (file I/O, DNS) | Shared pool with limited threads | -/// | `Thread` | Dedicated OS thread with own runtime | Long-running services, isolation from main runtime | Higher memory overhead per Actor | -/// -/// **Note**: All backends use async internally. For fully synchronous code without any async -/// runtime, use [`threads::Actor`](crate::threads::Actor) instead. -/// -/// # Examples -/// -/// ```ignore -/// // For typical async workloads (HTTP handlers, database queries) -/// let handle = MyServer::new().start(); -/// -/// // For occasional blocking operations (file reads, external commands) -/// let handle = MyServer::new().start_with_backend(Backend::Blocking); -/// -/// // For CPU-intensive or permanently blocking services -/// let handle = MyServer::new().start_with_backend(Backend::Thread); -/// ``` -/// -/// # When to Use Each Backend -/// -/// ## `Backend::Async` (Default) -/// - **Advantages**: Lightweight, efficient, good for high concurrency -/// - **Use when**: Your Actor does mostly async I/O (network, database) -/// - **Avoid when**: Your code blocks (e.g., `std::thread::sleep`, heavy computation) -/// -/// ## `Backend::Blocking` -/// - **Advantages**: Prevents blocking the async runtime, uses tokio's managed pool -/// - **Use when**: You have occasional blocking operations that complete quickly -/// - **Avoid when**: You need guaranteed thread availability or long-running blocks -/// -/// ## `Backend::Thread` -/// - **Advantages**: Isolated from main runtime, dedicated thread won't affect other tasks -/// - **Use when**: Long-running singleton services that shouldn't share the main runtime -/// - **Avoid when**: You need many Actors (each gets its own OS thread + runtime) -/// - **Note**: Still uses async internally (own runtime). For sync code, use `threads::Actor` +// --------------------------------------------------------------------------- +// Backend +// --------------------------------------------------------------------------- + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum Backend { - /// Run on tokio async runtime (default). - /// - /// Best for non-blocking, async workloads. The Actor runs as a - /// lightweight tokio task, enabling high concurrency with minimal overhead. - /// - /// **Warning**: If your `handle_request` or `handle_message` blocks synchronously - /// (e.g., `std::thread::sleep`, CPU-heavy loops), it will block the entire - /// tokio runtime thread, affecting other tasks. #[default] Async, - - /// Run on tokio's blocking thread pool. - /// - /// Use for Actors that perform blocking operations like: - /// - Synchronous file I/O - /// - DNS lookups - /// - External process calls - /// - Short CPU-bound computations - /// - /// The pool is shared across all `spawn_blocking` calls and has a default - /// limit of 512 threads. If the pool is exhausted, new blocking tasks wait. Blocking, - - /// Run on a dedicated OS thread with its own async runtime. - /// - /// Use for Actors that: - /// - Need isolation from the main tokio runtime - /// - Are long-running singleton services - /// - Should not compete with other tasks for runtime resources - /// - /// Each Actor gets its own thread with a separate tokio runtime, - /// providing isolation from other async tasks. Higher memory overhead - /// (~2MB stack per thread plus runtime overhead). - /// - /// **Note**: This still uses async internally. For fully synchronous code - /// without any async runtime, use [`threads::Actor`](crate::threads::Actor). Thread, } -#[derive(Debug)] -pub struct ActorRef { - pub tx: mpsc::Sender>, - /// Cancellation token to stop the Actor +// --------------------------------------------------------------------------- +// Actor trait +// --------------------------------------------------------------------------- + +pub trait Actor: Send + Sized + 'static { + fn started(&mut self, _ctx: &Context) -> impl Future + Send { + async {} + } + + fn stopped(&mut self, _ctx: &Context) -> impl Future + Send { + async {} + } +} + +// --------------------------------------------------------------------------- +// Handler trait (per-message, uses RPITIT — NOT object-safe, that's fine) +// --------------------------------------------------------------------------- + +pub trait Handler: Actor { + fn handle( + &mut self, + msg: M, + ctx: &Context, + ) -> impl Future + Send; +} + +// --------------------------------------------------------------------------- +// Envelope (type-erasure on the actor side) +// --------------------------------------------------------------------------- + +trait Envelope: Send { + fn handle<'a>( + self: Box, + actor: &'a mut A, + ctx: &'a Context, + ) -> Pin + Send + 'a>>; +} + +struct MessageEnvelope { + msg: M, + tx: Option>, +} + +impl Envelope for MessageEnvelope +where + A: Actor + Handler, + M: Message, +{ + fn handle<'a>( + self: Box, + actor: &'a mut A, + ctx: &'a Context, + ) -> Pin + Send + 'a>> { + Box::pin(async move { + let result = actor.handle(self.msg, ctx).await; + if let Some(tx) = self.tx { + let _ = tx.send(result); + } + }) + } +} + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +pub struct Context { + sender: mpsc::Sender + Send>>, cancellation_token: CancellationToken, - /// Completion signal for waiting on actor stop (true = stopped) - completion_rx: watch::Receiver, } -impl Clone for ActorRef { +impl Clone for Context { fn clone(&self) -> Self { Self { - tx: self.tx.clone(), + sender: self.sender.clone(), cancellation_token: self.cancellation_token.clone(), - completion_rx: self.completion_rx.clone(), } } } -impl ActorRef { - fn new(actor: A) -> Self { - let (tx, mut rx) = mpsc::channel::>(); - let cancellation_token = CancellationToken::new(); - let (completion_tx, completion_rx) = watch::channel(false); - let handle = ActorRef { - tx, - cancellation_token, - completion_rx, - }; - let handle_clone = handle.clone(); - let inner_future = async move { - if let Err(error) = actor.run(&handle, &mut rx).await { - tracing::trace!(%error, "Actor crashed") - } - // Signal completion to all waiters - let _ = completion_tx.send(true); - }; +impl Debug for Context { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Context").finish_non_exhaustive() + } +} - #[cfg(debug_assertions)] - // Optionally warn if the Actor future blocks for too much time - let inner_future = warn_on_block::WarnOnBlocking::new(inner_future); +impl Context { + pub fn from_ref(actor_ref: &ActorRef) -> Self { + Self { + sender: actor_ref.sender.clone(), + cancellation_token: actor_ref.cancellation_token.clone(), + } + } - let _task_handle = rt::spawn(inner_future); + pub fn stop(&self) { + self.cancellation_token.cancel(); + } - handle_clone + pub fn send(&self, msg: M) -> Result<(), ActorError> + where + A: Handler, + M: Message, + { + let envelope = MessageEnvelope { msg, tx: None }; + self.sender + .send(Box::new(envelope)) + .map_err(|_| ActorError::ActorStopped) } - fn new_blocking(actor: A) -> Self { - let (tx, mut rx) = mpsc::channel::>(); - let cancellation_token = CancellationToken::new(); - let (completion_tx, completion_rx) = watch::channel(false); - let handle = ActorRef { - tx, - cancellation_token, - completion_rx, + pub fn request(&self, msg: M) -> Result, ActorError> + where + A: Handler, + M: Message, + { + let (tx, rx) = oneshot::channel(); + let envelope = MessageEnvelope { + msg, + tx: Some(tx), }; - let handle_clone = handle.clone(); - let _task_handle = rt::spawn_blocking(move || { - rt::block_on(async move { - if let Err(error) = actor.run(&handle, &mut rx).await { - tracing::trace!(%error, "Actor crashed") - }; - // Signal completion to all waiters - let _ = completion_tx.send(true); - }) - }); + self.sender + .send(Box::new(envelope)) + .map_err(|_| ActorError::ActorStopped)?; + Ok(rx) + } + + pub async fn send_request(&self, msg: M) -> Result + where + A: Handler, + M: Message, + { + let rx = self.request(msg)?; + match timeout(DEFAULT_REQUEST_TIMEOUT, rx).await { + Ok(Ok(result)) => Ok(result), + Ok(Err(_)) => Err(ActorError::ActorStopped), + Err(_) => Err(ActorError::RequestTimeout), + } + } - handle_clone + pub(crate) fn cancellation_token(&self) -> CancellationToken { + self.cancellation_token.clone() } +} - fn new_on_thread(actor: A) -> Self { - let (tx, mut rx) = mpsc::channel::>(); - let cancellation_token = CancellationToken::new(); - let (completion_tx, completion_rx) = watch::channel(false); - let handle = ActorRef { - tx, - cancellation_token, - completion_rx, - }; - let handle_clone = handle.clone(); - let _thread_handle = threads::spawn(move || { - threads::block_on(async move { - if let Err(error) = actor.run(&handle, &mut rx).await { - tracing::trace!(%error, "Actor crashed") - }; - // Signal completion to all waiters - let _ = completion_tx.send(true); - }) - }); +// --------------------------------------------------------------------------- +// ActorRef +// --------------------------------------------------------------------------- - handle_clone +pub struct ActorRef { + sender: mpsc::Sender + Send>>, + cancellation_token: CancellationToken, + completion_rx: watch::Receiver, +} + +impl Debug for ActorRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ActorRef").finish_non_exhaustive() } +} - pub fn sender(&self) -> mpsc::Sender> { - self.tx.clone() +impl Clone for ActorRef { + fn clone(&self) -> Self { + Self { + sender: self.sender.clone(), + cancellation_token: self.cancellation_token.clone(), + completion_rx: self.completion_rx.clone(), + } } +} - pub async fn request(&mut self, message: A::Request) -> Result { - self.request_with_timeout(message, DEFAULT_REQUEST_TIMEOUT) - .await +impl ActorRef { + pub fn send(&self, msg: M) -> Result<(), ActorError> + where + A: Handler, + M: Message, + { + let envelope = MessageEnvelope { msg, tx: None }; + self.sender + .send(Box::new(envelope)) + .map_err(|_| ActorError::ActorStopped) + } + + pub fn request(&self, msg: M) -> Result, ActorError> + where + A: Handler, + M: Message, + { + let (tx, rx) = oneshot::channel(); + let envelope = MessageEnvelope { + msg, + tx: Some(tx), + }; + self.sender + .send(Box::new(envelope)) + .map_err(|_| ActorError::ActorStopped)?; + Ok(rx) } - pub async fn request_with_timeout( - &mut self, - message: A::Request, + pub async fn send_request(&self, msg: M) -> Result + where + A: Handler, + M: Message, + { + self.send_request_with_timeout(msg, DEFAULT_REQUEST_TIMEOUT).await + } + + pub async fn send_request_with_timeout( + &self, + msg: M, duration: Duration, - ) -> Result { - let (oneshot_tx, oneshot_rx) = oneshot::channel::>(); - self.tx.send(ActorInMsg::Request { - sender: oneshot_tx, - message, - })?; - - match timeout(duration, oneshot_rx).await { - Ok(Ok(result)) => result, - Ok(Err(_)) => Err(ActorError::Server), + ) -> Result + where + A: Handler, + M: Message, + { + let rx = self.request(msg)?; + match timeout(duration, rx).await { + Ok(Ok(result)) => Ok(result), + Ok(Err(_)) => Err(ActorError::ActorStopped), Err(_) => Err(ActorError::RequestTimeout), } } - pub async fn send(&mut self, message: A::Message) -> Result<(), ActorError> { - self.tx - .send(ActorInMsg::Message { message }) - .map_err(|_error| ActorError::Server) - } - - pub(crate) fn cancellation_token(&self) -> CancellationToken { - self.cancellation_token.clone() + pub fn context(&self) -> Context { + Context::from_ref(self) } - /// Waits for the actor to stop. - /// - /// This method returns a future that completes when the actor has finished - /// processing and exited its main loop. Can be called multiple times from - /// different clones of the ActorRef - all callers will be notified when - /// the actor stops. pub async fn join(&self) { let mut rx = self.completion_rx.clone(); - // Wait until completion signal is true while !*rx.borrow_and_update() { if rx.changed().await.is_err() { - // Sender dropped, actor must have completed break; } } } } -pub enum ActorInMsg { - Request { - sender: oneshot::Sender>, - message: A::Request, - }, - Message { - message: A::Message, - }, -} +// --------------------------------------------------------------------------- +// Actor startup + main loop +// --------------------------------------------------------------------------- -pub enum RequestResponse { - Reply(A::Reply), - Unused, - Stop(A::Reply), -} - -pub enum MessageResponse { - NoReply, - Unused, - Stop, -} +impl ActorRef { + fn spawn(actor: A, backend: Backend) -> Self { + let (tx, rx) = mpsc::channel:: + Send>>(); + let cancellation_token = CancellationToken::new(); + let (completion_tx, completion_rx) = watch::channel(false); -pub enum InitResult { - Success(A), - NoSuccess(A), -} + let actor_ref = ActorRef { + sender: tx.clone(), + cancellation_token: cancellation_token.clone(), + completion_rx, + }; -pub trait Actor: Send + Sized { - type Request: Clone + Send + Sized + Sync; - type Message: Clone + Send + Sized + Sync; - type Reply: Send + Sized; - type Error: Debug + Send; + let ctx = Context { + sender: tx, + cancellation_token: cancellation_token.clone(), + }; - /// Start the Actor with the default backend (Async). - fn start(self) -> ActorRef { - self.start_with_backend(Backend::default()) - } + let inner_future = async move { + run_actor(actor, ctx, rx, cancellation_token).await; + let _ = completion_tx.send(true); + }; - /// Start the Actor with the specified backend. - /// - /// # Arguments - /// * `backend` - The execution backend to use: - /// - `Backend::Async` - Run on tokio async runtime (default, best for non-blocking workloads) - /// - `Backend::Blocking` - Run on tokio's blocking thread pool (for blocking operations) - /// - `Backend::Thread` - Run on a dedicated OS thread (for long-running blocking services) - fn start_with_backend(self, backend: Backend) -> ActorRef { match backend { - Backend::Async => ActorRef::new(self), - Backend::Blocking => ActorRef::new_blocking(self), - Backend::Thread => ActorRef::new_on_thread(self), - } - } - - fn run( - self, - handle: &ActorRef, - rx: &mut mpsc::Receiver>, - ) -> impl Future> + Send { - async { - let res = match self.init(handle).await { - Ok(Success(new_state)) => Ok(new_state.main_loop(handle, rx).await), - Ok(NoSuccess(intermediate_state)) => { - // new_state is NoSuccess, this means the initialization failed, but the error was handled - // in callback. No need to report the error. - // Just skip main_loop and return the state to teardown the Actor - Ok(intermediate_state) - } - Err(err) => { - tracing::error!("Initialization failed with unhandled error: {err:?}"); - Err(ActorError::Initialization) - } - }; - - handle.cancellation_token().cancel(); - if let Ok(final_state) = res { - if let Err(err) = final_state.teardown(handle).await { - tracing::error!("Error during teardown: {err:?}"); - } + Backend::Async => { + #[cfg(debug_assertions)] + let inner_future = warn_on_block::WarnOnBlocking::new(inner_future); + let _handle = rt::spawn(inner_future); + } + Backend::Blocking => { + let _handle = rt::spawn_blocking(move || { + rt::block_on(inner_future) + }); + } + Backend::Thread => { + let _handle = threads::spawn(move || { + threads::block_on(inner_future) + }); } - Ok(()) } - } - /// Initialization function. It's called before main loop. It - /// can be overrided on implementations in case initial steps are - /// required. - fn init( - self, - _handle: &ActorRef, - ) -> impl Future, Self::Error>> + Send { - async { Ok(Success(self)) } + actor_ref } +} - fn main_loop( - mut self, - handle: &ActorRef, - rx: &mut mpsc::Receiver>, - ) -> impl Future + Send { - async { - loop { - if !self.receive(handle, rx).await { +async fn run_actor( + mut actor: A, + ctx: Context, + mut rx: mpsc::Receiver + Send>>, + cancellation_token: CancellationToken, +) { + actor.started(&ctx).await; + + if cancellation_token.is_cancelled() { + actor.stopped(&ctx).await; + return; + } + + loop { + let msg = rx.recv().await; + match msg { + Some(envelope) => { + let result = AssertUnwindSafe(envelope.handle(&mut actor, &ctx)) + .catch_unwind() + .await; + if let Err(panic) = result { + tracing::error!("Panic in message handler: {panic:?}"); + break; + } + if cancellation_token.is_cancelled() { break; } } - tracing::trace!("Stopping Actor"); - self + None => break, } } - fn receive( - &mut self, - handle: &ActorRef, - rx: &mut mpsc::Receiver>, - ) -> impl Future + Send { - async move { - let message = rx.recv().await; - - let keep_running = match message { - Some(ActorInMsg::Request { sender, message }) => { - let (keep_running, response) = - match AssertUnwindSafe(self.handle_request(message, handle)) - .catch_unwind() - .await - { - Ok(response) => match response { - RequestResponse::Reply(response) => (true, Ok(response)), - RequestResponse::Stop(response) => (false, Ok(response)), - RequestResponse::Unused => { - tracing::error!("Actor received unexpected Request"); - (false, Err(ActorError::RequestUnused)) - } - }, - Err(error) => { - tracing::error!("Error in callback: '{error:?}'"); - (false, Err(ActorError::Callback)) - } - }; - // Send response back - if sender.send(response).is_err() { - tracing::error!("Actor failed to send response back, client must have died") - }; - keep_running - } - Some(ActorInMsg::Message { message }) => { - match AssertUnwindSafe(self.handle_message(message, handle)) - .catch_unwind() - .await - { - Ok(response) => match response { - MessageResponse::NoReply => true, - MessageResponse::Stop => false, - MessageResponse::Unused => { - tracing::error!("Actor received unexpected Message"); - false - } - }, - Err(error) => { - tracing::trace!("Error in callback: '{error:?}'"); - false - } - } - } - None => { - // Channel has been closed; won't receive further messages. Stop the server. - false - } - }; - keep_running - } - } + cancellation_token.cancel(); + actor.stopped(&ctx).await; +} - fn handle_request( - &mut self, - _message: Self::Request, - _handle: &ActorRef, - ) -> impl Future> + Send { - async { RequestResponse::Unused } - } +// --------------------------------------------------------------------------- +// Actor::start +// --------------------------------------------------------------------------- - fn handle_message( - &mut self, - _message: Self::Message, - _handle: &ActorRef, - ) -> impl Future + Send { - async { MessageResponse::Unused } +pub trait ActorStart: Actor { + fn start(self) -> ActorRef { + self.start_with_backend(Backend::default()) } - /// Teardown function. It's called after the stop message is received. - /// It can be overrided on implementations in case final steps are required, - /// like closing streams, stopping timers, etc. - fn teardown( - self, - _handle: &ActorRef, - ) -> impl Future> + Send { - async { Ok(()) } + fn start_with_backend(self, backend: Backend) -> ActorRef { + ActorRef::spawn(self, backend) } } -/// Spawns a task that awaits on a future and sends a message to an Actor -/// on completion. -/// This function returns a handle to the spawned task. -pub fn send_message_on(handle: ActorRef, future: U, message: T::Message) -> JoinHandle<()> +impl ActorStart for A {} + +// --------------------------------------------------------------------------- +// send_message_on (utility) +// --------------------------------------------------------------------------- + +pub fn send_message_on(ctx: Context, future: U, msg: M) -> JoinHandle<()> where - T: Actor, + A: Actor + Handler, + M: Message, U: Future + Send + 'static, ::Output: Send, { - let cancellation_token = handle.cancellation_token(); - let mut handle_clone = handle.clone(); + let cancellation_token = ctx.cancellation_token(); let join_handle = rt::spawn(async move { let is_cancelled = pin!(cancellation_token.cancelled()); let signal = pin!(future); match future::select(is_cancelled, signal).await { future::Either::Left(_) => tracing::debug!("Actor stopped"), future::Either::Right(_) => { - if let Err(e) = handle_clone.send(message).await { + if let Err(e) = ctx.send(msg) { tracing::error!("Failed to send message: {e:?}") } } @@ -473,10 +385,13 @@ where join_handle } +// --------------------------------------------------------------------------- +// WarnOnBlocking (debug only) +// --------------------------------------------------------------------------- + #[cfg(debug_assertions)] mod warn_on_block { use super::*; - use std::time::Instant; use tracing::warn; @@ -514,229 +429,54 @@ mod warn_on_block { } } +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + #[cfg(test)] mod tests { - use super::*; - use crate::{messages::Unused, tasks::send_after}; + use crate::messages; use std::{ - sync::{Arc, Mutex}, + sync::{atomic, Arc}, thread, time::Duration, }; - struct BadlyBehavedTask; - - #[derive(Clone)] - pub enum InMessage { - GetCount, - Stop, - } - #[derive(Clone)] - pub enum OutMsg { - Count(u64), - } - - impl Actor for BadlyBehavedTask { - type Request = InMessage; - type Message = Unused; - type Reply = Unused; - type Error = Unused; - - async fn handle_request( - &mut self, - _: Self::Request, - _: &ActorRef, - ) -> RequestResponse { - RequestResponse::Stop(Unused) - } - - async fn handle_message( - &mut self, - _: Self::Message, - _: &ActorRef, - ) -> MessageResponse { - rt::sleep(Duration::from_millis(20)).await; - thread::sleep(Duration::from_secs(2)); - MessageResponse::Stop - } - } - - struct WellBehavedTask { - pub count: u64, - } - - impl Actor for WellBehavedTask { - type Request = InMessage; - type Message = Unused; - type Reply = OutMsg; - type Error = Unused; - - async fn handle_request( - &mut self, - message: Self::Request, - _: &ActorRef, - ) -> RequestResponse { - match message { - InMessage::GetCount => RequestResponse::Reply(OutMsg::Count(self.count)), - InMessage::Stop => RequestResponse::Stop(OutMsg::Count(self.count)), - } - } - - async fn handle_message( - &mut self, - _: Self::Message, - handle: &ActorRef, - ) -> MessageResponse { - self.count += 1; - println!("{:?}: good still alive", thread::current().id()); - send_after(Duration::from_millis(100), handle.to_owned(), Unused); - MessageResponse::NoReply - } - } - - const BLOCKING: Backend = Backend::Blocking; + // --- Counter actor for basic tests --- - #[test] - pub fn badly_behaved_thread_non_blocking() { - let runtime = rt::Runtime::new().unwrap(); - runtime.block_on(async move { - let mut badboy = BadlyBehavedTask.start(); - let _ = badboy.send(Unused).await; - let mut goodboy = WellBehavedTask { count: 0 }.start(); - let _ = goodboy.send(Unused).await; - rt::sleep(Duration::from_secs(1)).await; - let count = goodboy.request(InMessage::GetCount).await.unwrap(); - - match count { - OutMsg::Count(num) => { - assert_ne!(num, 10); - } - } - goodboy.request(InMessage::Stop).await.unwrap(); - }); + struct Counter { + count: u64, } - #[test] - pub fn badly_behaved_thread() { - let runtime = rt::Runtime::new().unwrap(); - runtime.block_on(async move { - let mut badboy = BadlyBehavedTask.start_with_backend(BLOCKING); - let _ = badboy.send(Unused).await; - let mut goodboy = WellBehavedTask { count: 0 }.start(); - let _ = goodboy.send(Unused).await; - rt::sleep(Duration::from_secs(1)).await; - let count = goodboy.request(InMessage::GetCount).await.unwrap(); - - match count { - OutMsg::Count(num) => { - assert_eq!(num, 10); - } - } - goodboy.request(InMessage::Stop).await.unwrap(); - }); + messages! { + GetCount -> u64; + Increment -> u64; + StopCounter -> u64 } - const TIMEOUT_DURATION: Duration = Duration::from_millis(100); - - #[derive(Debug, Default)] - struct SomeTask; - - #[derive(Clone)] - enum SomeTaskRequest { - SlowOperation, - FastOperation, - } + impl Actor for Counter {} - impl Actor for SomeTask { - type Request = SomeTaskRequest; - type Message = Unused; - type Reply = Unused; - type Error = Unused; - - async fn handle_request( - &mut self, - message: Self::Request, - _handle: &ActorRef, - ) -> RequestResponse { - match message { - SomeTaskRequest::SlowOperation => { - // Simulate a slow operation that will not resolve in time - rt::sleep(TIMEOUT_DURATION * 2).await; - RequestResponse::Reply(Unused) - } - SomeTaskRequest::FastOperation => { - // Simulate a fast operation that resolves in time - rt::sleep(TIMEOUT_DURATION / 2).await; - RequestResponse::Reply(Unused) - } - } + impl Handler for Counter { + async fn handle(&mut self, _msg: GetCount, _ctx: &Context) -> u64 { + self.count } } - #[test] - pub fn unresolving_task_times_out() { - let runtime = rt::Runtime::new().unwrap(); - runtime.block_on(async move { - let mut unresolving_task = SomeTask.start(); - - let result = unresolving_task - .request_with_timeout(SomeTaskRequest::FastOperation, TIMEOUT_DURATION) - .await; - assert!(matches!(result, Ok(Unused))); - - let result = unresolving_task - .request_with_timeout(SomeTaskRequest::SlowOperation, TIMEOUT_DURATION) - .await; - assert!(matches!(result, Err(ActorError::RequestTimeout))); - }); - } - - struct SomeTaskThatFailsOnInit { - sender_channel: Arc>>, - } - - impl SomeTaskThatFailsOnInit { - pub fn new(sender_channel: Arc>>) -> Self { - Self { sender_channel } + impl Handler for Counter { + async fn handle(&mut self, _msg: Increment, _ctx: &Context) -> u64 { + self.count += 1; + self.count } } - impl Actor for SomeTaskThatFailsOnInit { - type Request = Unused; - type Message = Unused; - type Reply = Unused; - type Error = Unused; - - async fn init(self, _handle: &ActorRef) -> Result, Self::Error> { - // Simulate an initialization failure by returning NoSuccess - Ok(NoSuccess(self)) - } - - async fn teardown(self, _handle: &ActorRef) -> Result<(), Self::Error> { - self.sender_channel.lock().unwrap().close(); - Ok(()) + impl Handler for Counter { + async fn handle(&mut self, _msg: StopCounter, ctx: &Context) -> u64 { + ctx.stop(); + self.count } } - #[test] - pub fn task_fails_with_intermediate_state() { - let runtime = rt::Runtime::new().unwrap(); - runtime.block_on(async move { - let (rx, tx) = mpsc::channel::(); - let sender_channel = Arc::new(Mutex::new(tx)); - let _task = SomeTaskThatFailsOnInit::new(sender_channel).start(); - - // Wait a while to ensure the task has time to run and fail - rt::sleep(Duration::from_secs(1)).await; - - // We assure that the teardown function has ran by checking that the receiver channel is closed - assert!(rx.is_closed()) - }); - } - - // ==================== Backend enum tests ==================== - #[test] pub fn backend_default_is_async() { assert_eq!(Backend::default(), Backend::Async); @@ -746,8 +486,8 @@ mod tests { #[allow(clippy::clone_on_copy)] pub fn backend_enum_is_copy_and_clone() { let backend = Backend::Async; - let copied = backend; // Copy - let cloned = backend.clone(); // Clone - intentionally testing Clone trait + let copied = backend; + let cloned = backend.clone(); assert_eq!(backend, copied); assert_eq!(backend, cloned); } @@ -769,284 +509,165 @@ mod tests { assert_ne!(Backend::Blocking, Backend::Thread); } - // ==================== Backend functionality tests ==================== - - /// Simple counter Actor for testing all backends - struct Counter { - count: u64, - } - - #[derive(Clone)] - enum CounterRequest { - Get, - Increment, - Stop, - } - - #[derive(Clone)] - enum CounterMessage { - Increment, - } - - impl Actor for Counter { - type Request = CounterRequest; - type Message = CounterMessage; - type Reply = u64; - type Error = (); - - async fn handle_request( - &mut self, - message: Self::Request, - _: &ActorRef, - ) -> RequestResponse { - match message { - CounterRequest::Get => RequestResponse::Reply(self.count), - CounterRequest::Increment => { - self.count += 1; - RequestResponse::Reply(self.count) - } - CounterRequest::Stop => RequestResponse::Stop(self.count), - } - } - - async fn handle_message( - &mut self, - message: Self::Message, - _: &ActorRef, - ) -> MessageResponse { - match message { - CounterMessage::Increment => { - self.count += 1; - MessageResponse::NoReply - } - } - } - } - #[test] - pub fn backend_async_handles_call_and_cast() { + pub fn backend_async_handles_send_and_request() { let runtime = rt::Runtime::new().unwrap(); runtime.block_on(async move { - let mut counter = Counter { count: 0 }.start(); + let counter = Counter { count: 0 }.start(); - // Test call - let result = counter.request(CounterRequest::Get).await.unwrap(); + let result = counter.send_request(GetCount).await.unwrap(); assert_eq!(result, 0); - let result = counter.request(CounterRequest::Increment).await.unwrap(); + let result = counter.send_request(Increment).await.unwrap(); assert_eq!(result, 1); - // Test cast - counter.send(CounterMessage::Increment).await.unwrap(); - rt::sleep(Duration::from_millis(10)).await; // Give time for cast to process + counter.send(Increment).unwrap(); + rt::sleep(Duration::from_millis(10)).await; - let result = counter.request(CounterRequest::Get).await.unwrap(); + let result = counter.send_request(GetCount).await.unwrap(); assert_eq!(result, 2); - // Stop - let final_count = counter.request(CounterRequest::Stop).await.unwrap(); + let final_count = counter.send_request(StopCounter).await.unwrap(); assert_eq!(final_count, 2); }); } #[test] - pub fn backend_blocking_handles_call_and_cast() { + pub fn backend_blocking_handles_send_and_request() { let runtime = rt::Runtime::new().unwrap(); runtime.block_on(async move { - let mut counter = Counter { count: 0 }.start_with_backend(Backend::Blocking); + let counter = Counter { count: 0 }.start_with_backend(Backend::Blocking); - // Test call - let result = counter.request(CounterRequest::Get).await.unwrap(); + let result = counter.send_request(GetCount).await.unwrap(); assert_eq!(result, 0); - let result = counter.request(CounterRequest::Increment).await.unwrap(); + let result = counter.send_request(Increment).await.unwrap(); assert_eq!(result, 1); - // Test cast - counter.send(CounterMessage::Increment).await.unwrap(); - rt::sleep(Duration::from_millis(50)).await; // Give time for cast to process + counter.send(Increment).unwrap(); + rt::sleep(Duration::from_millis(50)).await; - let result = counter.request(CounterRequest::Get).await.unwrap(); + let result = counter.send_request(GetCount).await.unwrap(); assert_eq!(result, 2); - // Stop - let final_count = counter.request(CounterRequest::Stop).await.unwrap(); + let final_count = counter.send_request(StopCounter).await.unwrap(); assert_eq!(final_count, 2); }); } #[test] - pub fn backend_thread_handles_call_and_cast() { + pub fn backend_thread_handles_send_and_request() { let runtime = rt::Runtime::new().unwrap(); runtime.block_on(async move { - let mut counter = Counter { count: 0 }.start_with_backend(Backend::Thread); + let counter = Counter { count: 0 }.start_with_backend(Backend::Thread); - // Test call - let result = counter.request(CounterRequest::Get).await.unwrap(); + let result = counter.send_request(GetCount).await.unwrap(); assert_eq!(result, 0); - let result = counter.request(CounterRequest::Increment).await.unwrap(); + let result = counter.send_request(Increment).await.unwrap(); assert_eq!(result, 1); - // Test cast - counter.send(CounterMessage::Increment).await.unwrap(); - rt::sleep(Duration::from_millis(50)).await; // Give time for cast to process + counter.send(Increment).unwrap(); + rt::sleep(Duration::from_millis(50)).await; - let result = counter.request(CounterRequest::Get).await.unwrap(); + let result = counter.send_request(GetCount).await.unwrap(); assert_eq!(result, 2); - // Stop - let final_count = counter.request(CounterRequest::Stop).await.unwrap(); + let final_count = counter.send_request(StopCounter).await.unwrap(); assert_eq!(final_count, 2); }); } #[test] - pub fn backend_thread_isolates_blocking_work() { - // Similar to badly_behaved_thread but using Backend::Thread + pub fn multiple_backends_concurrent() { let runtime = rt::Runtime::new().unwrap(); runtime.block_on(async move { - let mut badboy = BadlyBehavedTask.start_with_backend(Backend::Thread); - let _ = badboy.send(Unused).await; - let mut goodboy = WellBehavedTask { count: 0 }.start(); - let _ = goodboy.send(Unused).await; - rt::sleep(Duration::from_secs(1)).await; - let count = goodboy.request(InMessage::GetCount).await.unwrap(); + let async_counter = Counter { count: 0 }.start(); + let blocking_counter = Counter { count: 100 }.start_with_backend(Backend::Blocking); + let thread_counter = Counter { count: 200 }.start_with_backend(Backend::Thread); - // goodboy should have run normally because badboy is on a separate thread - match count { - OutMsg::Count(num) => { - assert_eq!(num, 10); - } - } - goodboy.request(InMessage::Stop).await.unwrap(); - }); - } + async_counter.send_request(Increment).await.unwrap(); + blocking_counter.send_request(Increment).await.unwrap(); + thread_counter.send_request(Increment).await.unwrap(); - #[test] - pub fn multiple_backends_concurrent() { - let runtime = rt::Runtime::new().unwrap(); - runtime.block_on(async move { - // Start counters on all three backends - let mut async_counter = Counter { count: 0 }.start(); - let mut blocking_counter = Counter { count: 100 }.start_with_backend(Backend::Blocking); - let mut thread_counter = Counter { count: 200 }.start_with_backend(Backend::Thread); - - // Increment each - async_counter - .request(CounterRequest::Increment) - .await - .unwrap(); - blocking_counter - .request(CounterRequest::Increment) - .await - .unwrap(); - thread_counter - .request(CounterRequest::Increment) - .await - .unwrap(); - - // Verify each has independent state - let async_val = async_counter.request(CounterRequest::Get).await.unwrap(); - let blocking_val = blocking_counter.request(CounterRequest::Get).await.unwrap(); - let thread_val = thread_counter.request(CounterRequest::Get).await.unwrap(); + let async_val = async_counter.send_request(GetCount).await.unwrap(); + let blocking_val = blocking_counter.send_request(GetCount).await.unwrap(); + let thread_val = thread_counter.send_request(GetCount).await.unwrap(); assert_eq!(async_val, 1); assert_eq!(blocking_val, 101); assert_eq!(thread_val, 201); - // Clean up - async_counter.request(CounterRequest::Stop).await.unwrap(); - blocking_counter - .request(CounterRequest::Stop) - .await - .unwrap(); - thread_counter.request(CounterRequest::Stop).await.unwrap(); + async_counter.send_request(StopCounter).await.unwrap(); + blocking_counter.send_request(StopCounter).await.unwrap(); + thread_counter.send_request(StopCounter).await.unwrap(); }); } #[test] - pub fn backend_default_works_in_start() { + pub fn request_timeout() { let runtime = rt::Runtime::new().unwrap(); runtime.block_on(async move { - // Using Backend::default() should work the same as Backend::Async - let mut counter = Counter { count: 42 }.start_with_backend(Backend::Async); - - let result = counter.request(CounterRequest::Get).await.unwrap(); - assert_eq!(result, 42); + struct SlowActor; + messages! { SlowOp -> () } + impl Actor for SlowActor {} + impl Handler for SlowActor { + async fn handle(&mut self, _msg: SlowOp, _ctx: &Context) { + rt::sleep(Duration::from_millis(200)).await; + } + } - counter.request(CounterRequest::Stop).await.unwrap(); + let actor = SlowActor.start(); + let result = actor + .send_request_with_timeout(SlowOp, Duration::from_millis(50)) + .await; + assert!(matches!(result, Err(ActorError::RequestTimeout))); }); } - /// Actor that sleeps during teardown to simulate slow shutdown + // --- SlowShutdownActor for join tests --- + struct SlowShutdownActor; + messages! { StopSlow -> () } + impl Actor for SlowShutdownActor { - type Request = Unused; - type Message = Unused; - type Reply = Unused; - type Error = Unused; - - async fn handle_message( - &mut self, - _message: Self::Message, - _handle: &ActorRef, - ) -> MessageResponse { - MessageResponse::Stop + async fn stopped(&mut self, _ctx: &Context) { + thread::sleep(Duration::from_millis(500)); } + } - async fn teardown(self, _handle: &ActorRef) -> Result<(), Self::Error> { - // Simulate slow shutdown - this runs on the thread - std::thread::sleep(Duration::from_millis(500)); - Ok(()) + impl Handler for SlowShutdownActor { + async fn handle(&mut self, _msg: StopSlow, ctx: &Context) { + ctx.stop(); } } - /// Test that join() on a Backend::Thread actor doesn't block other async tasks. - /// - /// This test verifies that when we call join().await on an actor running on - /// Backend::Thread, it doesn't block the tokio runtime - other async tasks - /// should continue to make progress. - /// - /// Uses a single-threaded runtime to ensure we detect blocking behavior. #[test] pub fn thread_backend_join_does_not_block_runtime() { - // Use current_thread runtime to ensure blocking would be detected let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap(); runtime.block_on(async move { - // Start a thread-backend actor that takes 500ms to teardown - let mut slow_actor = SlowShutdownActor.start_with_backend(Backend::Thread); + let slow_actor = SlowShutdownActor.start_with_backend(Backend::Thread); - // Spawn an async task that increments a counter every 50ms - let tick_count = Arc::new(std::sync::atomic::AtomicU64::new(0)); + let tick_count = Arc::new(atomic::AtomicU64::new(0)); let tick_count_clone = tick_count.clone(); let _ticker = rt::spawn(async move { for _ in 0..20 { rt::sleep(Duration::from_millis(50)).await; - tick_count_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + tick_count_clone.fetch_add(1, atomic::Ordering::SeqCst); } }); - // Tell the actor to stop - it will start its slow teardown - slow_actor.send(Unused).await.unwrap(); - - // Small delay to ensure the actor received the message + slow_actor.send(StopSlow).unwrap(); rt::sleep(Duration::from_millis(10)).await; - // Now join the actor - this waits for the 500ms teardown - // If implemented correctly, the ticker should continue running DURING the join slow_actor.join().await; - // Check tick count IMMEDIATELY after join returns, before awaiting ticker. - // The actor teardown takes 500ms. In that time, the ticker should have - // completed about 10 ticks (500ms / 50ms = 10). - // If join() blocked the runtime, the ticker would have 0-1 ticks. - let count_after_join = tick_count.load(std::sync::atomic::Ordering::SeqCst); + let count_after_join = tick_count.load(atomic::Ordering::SeqCst); assert!( count_after_join >= 8, "Ticker should have completed ~10 ticks during the 500ms join(), but only got {}. \ @@ -1056,19 +677,14 @@ mod tests { }); } - /// Test that multiple callers can wait on join() simultaneously. - /// - /// This verifies that the completion signal approach works correctly - /// when multiple tasks want to wait for the same actor to stop. #[test] pub fn multiple_join_callers_all_notified() { let runtime = rt::Runtime::new().unwrap(); runtime.block_on(async move { - let mut actor = SlowShutdownActor.start(); + let actor = SlowShutdownActor.start(); let actor_clone1 = actor.clone(); let actor_clone2 = actor.clone(); - // Spawn multiple tasks that will all call join() let join1 = rt::spawn(async move { actor_clone1.join().await; 1u32 @@ -1078,19 +694,105 @@ mod tests { 2u32 }); - // Give the join tasks time to start waiting rt::sleep(Duration::from_millis(10)).await; - // Tell the actor to stop - actor.send(Unused).await.unwrap(); + actor.send(StopSlow).unwrap(); - // All join tasks should complete after the actor stops let (r1, r2) = tokio::join!(join1, join2); assert_eq!(r1.unwrap(), 1); assert_eq!(r2.unwrap(), 2); - // Calling join again should return immediately (actor already stopped) actor.join().await; }); } + + // --- Badly behaved actors for blocking tests --- + + struct BadlyBehavedTask; + + messages! { DoBlock -> () } + + impl Actor for BadlyBehavedTask {} + + impl Handler for BadlyBehavedTask { + async fn handle(&mut self, _msg: DoBlock, ctx: &Context) { + rt::sleep(Duration::from_millis(20)).await; + thread::sleep(Duration::from_secs(2)); + ctx.stop(); + } + } + + messages! { IncrementWell -> () } + + struct WellBehavedTask { + pub count: u64, + } + + impl Actor for WellBehavedTask {} + + impl Handler for WellBehavedTask { + async fn handle(&mut self, _msg: GetCount, _ctx: &Context) -> u64 { + self.count + } + } + + impl Handler for WellBehavedTask { + async fn handle(&mut self, _msg: StopCounter, ctx: &Context) -> u64 { + ctx.stop(); + self.count + } + } + + impl Handler for WellBehavedTask { + async fn handle(&mut self, _msg: IncrementWell, ctx: &Context) { + self.count += 1; + use crate::tasks::send_after; + send_after(Duration::from_millis(100), ctx.clone(), IncrementWell); + } + } + + #[test] + pub fn badly_behaved_thread_non_blocking() { + let runtime = rt::Runtime::new().unwrap(); + runtime.block_on(async move { + let badboy = BadlyBehavedTask.start(); + badboy.send(DoBlock).unwrap(); + let goodboy = WellBehavedTask { count: 0 }.start(); + goodboy.send(IncrementWell).unwrap(); + rt::sleep(Duration::from_secs(1)).await; + let count = goodboy.send_request(GetCount).await.unwrap(); + assert_ne!(count, 10); + goodboy.send_request(StopCounter).await.unwrap(); + }); + } + + #[test] + pub fn badly_behaved_thread() { + let runtime = rt::Runtime::new().unwrap(); + runtime.block_on(async move { + let badboy = BadlyBehavedTask.start_with_backend(Backend::Blocking); + badboy.send(DoBlock).unwrap(); + let goodboy = WellBehavedTask { count: 0 }.start(); + goodboy.send(IncrementWell).unwrap(); + rt::sleep(Duration::from_secs(1)).await; + let count = goodboy.send_request(GetCount).await.unwrap(); + assert_eq!(count, 10); + goodboy.send_request(StopCounter).await.unwrap(); + }); + } + + #[test] + pub fn backend_thread_isolates_blocking_work() { + let runtime = rt::Runtime::new().unwrap(); + runtime.block_on(async move { + let badboy = BadlyBehavedTask.start_with_backend(Backend::Thread); + badboy.send(DoBlock).unwrap(); + let goodboy = WellBehavedTask { count: 0 }.start(); + goodboy.send(IncrementWell).unwrap(); + rt::sleep(Duration::from_secs(1)).await; + let count = goodboy.send_request(GetCount).await.unwrap(); + assert_eq!(count, 10); + goodboy.send_request(StopCounter).await.unwrap(); + }); + } } diff --git a/concurrency/src/tasks/mod.rs b/concurrency/src/tasks/mod.rs index dbbc269..abfa512 100644 --- a/concurrency/src/tasks/mod.rs +++ b/concurrency/src/tasks/mod.rs @@ -1,7 +1,4 @@ -//! spawned concurrency -//! Runtime tasks-based traits and structs to implement concurrent code à-la-Erlang. - -mod actor; +pub(crate) mod actor; mod process; mod stream; mod time; @@ -12,9 +9,8 @@ mod stream_tests; mod timer_tests; pub use actor::{ - send_message_on, Actor, ActorInMsg, ActorRef, Backend, InitResult, InitResult::NoSuccess, - InitResult::Success, MessageResponse, RequestResponse, + send_message_on, Actor, ActorRef, ActorStart, Backend, Context, Handler, }; pub use process::{send, Process, ProcessInfo}; pub use stream::spawn_listener; -pub use time::{send_after, send_interval}; +pub use time::{send_after, send_interval, TimerHandle}; diff --git a/concurrency/src/tasks/stream.rs b/concurrency/src/tasks/stream.rs index ebf09a3..afc5ce6 100644 --- a/concurrency/src/tasks/stream.rs +++ b/concurrency/src/tasks/stream.rs @@ -1,26 +1,23 @@ -use crate::tasks::{Actor, ActorRef}; +use crate::message::Message; use futures::{future::select, Stream, StreamExt}; use spawned_rt::tasks::JoinHandle; -/// Spawns a listener that listens to a stream and sends messages to an Actor. -/// -/// Items sent through the stream are required to be wrapped in a Result type. -/// -/// This function returns a handle to the spawned task and a cancellation token -/// to stop it. -pub fn spawn_listener(mut handle: ActorRef, stream: S) -> JoinHandle<()> +use super::actor::{Actor, Context, Handler}; + +pub fn spawn_listener(ctx: Context, stream: S) -> JoinHandle<()> where - T: Actor, - S: Send + Stream + 'static, + A: Actor + Handler, + M: Message, + S: Send + Stream + 'static, { - let cancellation_token = handle.cancellation_token(); + let cancellation_token = ctx.cancellation_token(); let join_handle = spawned_rt::tasks::spawn(async move { let mut pinned_stream = core::pin::pin!(stream); let is_cancelled = core::pin::pin!(cancellation_token.cancelled()); let listener_loop = core::pin::pin!(async { loop { match pinned_stream.next().await { - Some(msg) => match handle.send(msg).await { + Some(msg) => match ctx.send(msg) { Ok(_) => tracing::trace!("Message sent successfully"), Err(e) => { tracing::error!("Failed to send message: {e:?}"); @@ -36,7 +33,7 @@ where }); match select(is_cancelled, listener_loop).await { futures::future::Either::Left(_) => tracing::trace!("Actor stopped"), - futures::future::Either::Right(_) => (), // Stream finished or errored out + futures::future::Either::Right(_) => (), } }); join_handle diff --git a/concurrency/src/tasks/stream_tests.rs b/concurrency/src/tasks/stream_tests.rs index d270002..69c5a6f 100644 --- a/concurrency/src/tasks/stream_tests.rs +++ b/concurrency/src/tasks/stream_tests.rs @@ -1,11 +1,30 @@ use crate::tasks::{ - send_after, stream::spawn_listener, Actor, ActorRef, MessageResponse, RequestResponse, + send_after, Actor, ActorStart, Context, Handler, + stream::spawn_listener, }; +use crate::message::Message; use futures::{stream, StreamExt}; use spawned_rt::tasks::{self as rt, BroadcastStream, ReceiverStream}; use std::time::Duration; -type SummatoryHandle = ActorRef; +// --- Messages --- + +#[derive(Debug)] +enum StreamMsg { + Add(u16), + Error, +} +impl Message for StreamMsg { type Result = (); } + +#[derive(Debug)] +struct StopSum; +impl Message for StopSum { type Result = (); } + +#[derive(Debug)] +struct GetValue; +impl Message for GetValue { type Result = u16; } + +// --- Summatory Actor --- struct Summatory { count: u16, @@ -17,49 +36,26 @@ impl Summatory { } } -type SummatoryOutMessage = u16; - -#[derive(Clone)] -enum SummatoryCastMessage { - Add(u16), - StreamError, - Stop, -} +impl Actor for Summatory {} -impl Summatory { - pub async fn get_value(server: &mut SummatoryHandle) -> Result { - server.request(()).await.map_err(|_| ()) +impl Handler for Summatory { + async fn handle(&mut self, msg: StreamMsg, ctx: &Context) { + match msg { + StreamMsg::Add(val) => self.count += val, + StreamMsg::Error => ctx.stop(), + } } } -impl Actor for Summatory { - type Request = (); // We only handle one type of call, so there is no need for a specific message type. - type Message = SummatoryCastMessage; - type Reply = SummatoryOutMessage; - type Error = (); - - async fn handle_message( - &mut self, - message: Self::Message, - _handle: &ActorRef, - ) -> MessageResponse { - match message { - SummatoryCastMessage::Add(val) => { - self.count += val; - MessageResponse::NoReply - } - SummatoryCastMessage::StreamError => MessageResponse::Stop, - SummatoryCastMessage::Stop => MessageResponse::Stop, - } +impl Handler for Summatory { + async fn handle(&mut self, _msg: StopSum, ctx: &Context) { + ctx.stop(); } +} - async fn handle_request( - &mut self, - _message: Self::Request, - _handle: &SummatoryHandle, - ) -> RequestResponse { - let current_value = self.count; - RequestResponse::Reply(current_value) +impl Handler for Summatory { + async fn handle(&mut self, _msg: GetValue, _ctx: &Context) -> u16 { + self.count } } @@ -67,18 +63,18 @@ impl Actor for Summatory { pub fn test_sum_numbers_from_stream() { let runtime = rt::Runtime::new().unwrap(); runtime.block_on(async move { - let mut summatory_handle = Summatory::new(0).start(); + let summatory = Summatory::new(0).start(); let stream = stream::iter(vec![1u16, 2, 3, 4, 5].into_iter().map(Ok::)); + let ctx = Context::from_ref(&summatory); spawn_listener( - summatory_handle.clone(), - stream.filter_map(|result| async move { result.ok().map(SummatoryCastMessage::Add) }), + ctx, + stream.filter_map(|result| async move { result.ok().map(StreamMsg::Add) }), ); - // Wait for 1 second so the whole stream is processed rt::sleep(Duration::from_secs(1)).await; - let val = Summatory::get_value(&mut summatory_handle).await.unwrap(); + let val = summatory.send_request(GetValue).await.unwrap(); assert_eq!(val, 15); }) } @@ -87,26 +83,25 @@ pub fn test_sum_numbers_from_stream() { pub fn test_sum_numbers_from_channel() { let runtime = rt::Runtime::new().unwrap(); runtime.block_on(async move { - let mut summatory_handle = Summatory::new(0).start(); + let summatory = Summatory::new(0).start(); let (tx, rx) = spawned_rt::tasks::mpsc::channel::>(); - // Spawn a task to send numbers to the channel spawned_rt::tasks::spawn(async move { for i in 1..=5 { tx.send(Ok(i)).unwrap(); } }); + let ctx = Context::from_ref(&summatory); spawn_listener( - summatory_handle.clone(), + ctx, ReceiverStream::new(rx) - .filter_map(|result| async move { result.ok().map(SummatoryCastMessage::Add) }), + .filter_map(|result| async move { result.ok().map(StreamMsg::Add) }), ); - // Wait for 1 second so the whole stream is processed rt::sleep(Duration::from_secs(1)).await; - let val = Summatory::get_value(&mut summatory_handle).await.unwrap(); + let val = summatory.send_request(GetValue).await.unwrap(); assert_eq!(val, 15); }) } @@ -115,44 +110,40 @@ pub fn test_sum_numbers_from_channel() { pub fn test_sum_numbers_from_broadcast_channel() { let runtime = rt::Runtime::new().unwrap(); runtime.block_on(async move { - let mut summatory_handle = Summatory::new(0).start(); + let summatory = Summatory::new(0).start(); let (tx, rx) = tokio::sync::broadcast::channel::(5); - // Spawn a task to send numbers to the channel spawned_rt::tasks::spawn(async move { for i in 1u16..=5 { tx.send(i).unwrap(); } }); + let ctx = Context::from_ref(&summatory); spawn_listener( - summatory_handle.clone(), + ctx, BroadcastStream::new(rx) - .filter_map(|result| async move { result.ok().map(SummatoryCastMessage::Add) }), + .filter_map(|result| async move { result.ok().map(StreamMsg::Add) }), ); - // Wait for 1 second so the whole stream is processed rt::sleep(Duration::from_secs(1)).await; - let val = Summatory::get_value(&mut summatory_handle).await.unwrap(); + let val = summatory.send_request(GetValue).await.unwrap(); assert_eq!(val, 15); }) } #[test] pub fn test_stream_cancellation() { - // Messages sent at: t=0, t=250, t=500, t=750, t=1000ms - // We read at t=850ms (after 4th message at t=750, before 5th at t=1000) const MESSAGE_INTERVAL: u64 = 250; const READ_TIME: u64 = 850; const STOP_TIME: u64 = 1100; let runtime = rt::Runtime::new().unwrap(); runtime.block_on(async move { - let mut summatory_handle = Summatory::new(0).start(); + let summatory = Summatory::new(0).start(); let (tx, rx) = spawned_rt::tasks::mpsc::channel::>(); - // Spawn a task to send numbers to the channel spawned_rt::tasks::spawn(async move { for i in 1..=5 { tx.send(Ok(i)).unwrap(); @@ -160,34 +151,28 @@ pub fn test_stream_cancellation() { } }); + let ctx = Context::from_ref(&summatory); let listener_handle = spawn_listener( - summatory_handle.clone(), + ctx.clone(), ReceiverStream::new(rx) - .filter_map(|result| async move { result.ok().map(SummatoryCastMessage::Add) }), + .filter_map(|result| async move { result.ok().map(StreamMsg::Add) }), ); - // Start a timer to stop the actor after all messages would be sent - let summatory_handle_clone = summatory_handle.clone(); let _ = send_after( Duration::from_millis(STOP_TIME), - summatory_handle_clone, - SummatoryCastMessage::Stop, + ctx, + StopSum, ); - // Read value after 4th message (t=750) but before 5th (t=1000). - // Expected sum: 1+2+3+4 = 10, but allow some slack for timing variations. rt::sleep(Duration::from_millis(READ_TIME)).await; - let val = Summatory::get_value(&mut summatory_handle).await.unwrap(); + let val = summatory.send_request(GetValue).await.unwrap(); - // At t=850ms, we expect 4 messages processed (sum=10), but timing variations - // could result in 3 messages (sum=6) or occasionally all 5 (sum=15). assert!((1..=15).contains(&val)); assert!(listener_handle.await.is_ok()); - // Finally, we check that the server is stopped, by getting an error when trying to call it. rt::sleep(Duration::from_millis(10)).await; - assert!(Summatory::get_value(&mut summatory_handle).await.is_err()); + assert!(summatory.send_request(GetValue).await.is_err()); }) } @@ -195,22 +180,21 @@ pub fn test_stream_cancellation() { pub fn test_halting_on_stream_error() { let runtime = rt::Runtime::new().unwrap(); runtime.block_on(async move { - let mut summatory_handle = Summatory::new(0).start(); + let summatory = Summatory::new(0).start(); let stream = tokio_stream::iter(vec![Ok(1u16), Ok(2), Ok(3), Err(()), Ok(4), Ok(5)]); let msg_stream = stream.filter_map(|value| async move { match value { - Ok(number) => Some(SummatoryCastMessage::Add(number)), - Err(_) => Some(SummatoryCastMessage::StreamError), + Ok(number) => Some(StreamMsg::Add(number)), + Err(_) => Some(StreamMsg::Error), } }); - spawn_listener(summatory_handle.clone(), msg_stream); + let ctx = Context::from_ref(&summatory); + spawn_listener(ctx, msg_stream); - // Wait for 1 second so the whole stream is processed rt::sleep(Duration::from_secs(1)).await; - let result = Summatory::get_value(&mut summatory_handle).await; - // Actor should have been terminated, hence the result should be an error + let result = summatory.send_request(GetValue).await; assert!(result.is_err()); }) } @@ -219,21 +203,21 @@ pub fn test_halting_on_stream_error() { pub fn test_skipping_on_stream_error() { let runtime = rt::Runtime::new().unwrap(); runtime.block_on(async move { - let mut summatory_handle = Summatory::new(0).start(); + let summatory = Summatory::new(0).start(); let stream = tokio_stream::iter(vec![Ok(1u16), Ok(2), Ok(3), Err(()), Ok(4), Ok(5)]); let msg_stream = stream.filter_map(|value| async move { match value { - Ok(number) => Some(SummatoryCastMessage::Add(number)), + Ok(number) => Some(StreamMsg::Add(number)), Err(_) => None, } }); - spawn_listener(summatory_handle.clone(), msg_stream); + let ctx = Context::from_ref(&summatory); + spawn_listener(ctx, msg_stream); - // Wait for 1 second so the whole stream is processed rt::sleep(Duration::from_secs(1)).await; - let val = Summatory::get_value(&mut summatory_handle).await.unwrap(); + let val = summatory.send_request(GetValue).await.unwrap(); assert_eq!(val, 15); }) } diff --git a/concurrency/src/tasks/time.rs b/concurrency/src/tasks/time.rs index e334c81..69871e8 100644 --- a/concurrency/src/tasks/time.rs +++ b/concurrency/src/tasks/time.rs @@ -3,7 +3,8 @@ use std::time::Duration; use spawned_rt::tasks::{self as rt, CancellationToken, JoinHandle}; -use super::{Actor, ActorRef}; +use super::actor::{Actor, Context, Handler}; +use crate::message::Message; use core::pin::pin; pub struct TimerHandle { @@ -11,24 +12,22 @@ pub struct TimerHandle { pub cancellation_token: CancellationToken, } -// Sends a message after a given period to the specified Actor. The task terminates -// once the send has completed -pub fn send_after(period: Duration, mut handle: ActorRef, message: T::Message) -> TimerHandle +pub fn send_after(period: Duration, ctx: Context, msg: M) -> TimerHandle where - T: Actor + 'static, + A: Actor + Handler, + M: Message, { let cancellation_token = CancellationToken::new(); let cloned_token = cancellation_token.clone(); - let actor_cancellation_token = handle.cancellation_token(); + let actor_cancellation_token = ctx.cancellation_token(); let join_handle = rt::spawn(async move { - // Timer action is ignored if it was either cancelled or the associated Actor is no longer running. let cancel_token_fut = pin!(cloned_token.cancelled()); let actor_cancel_fut = pin!(actor_cancellation_token.cancelled()); let cancel_conditions = select(cancel_token_fut, actor_cancel_fut); let async_block = pin!(async { rt::sleep(period).await; - let _ = handle.send(message.clone()).await; + let _ = ctx.send(msg); }); let _ = select(cancel_conditions, async_block).await; }); @@ -38,28 +37,24 @@ where } } -// Sends a message to the specified Actor repeatedly after `Time` milliseconds. -pub fn send_interval( - period: Duration, - mut handle: ActorRef, - message: T::Message, -) -> TimerHandle +pub fn send_interval(period: Duration, ctx: Context, msg: M) -> TimerHandle where - T: Actor + 'static, + A: Actor + Handler, + M: Message + Clone, { let cancellation_token = CancellationToken::new(); let cloned_token = cancellation_token.clone(); - let actor_cancellation_token = handle.cancellation_token(); + let actor_cancellation_token = ctx.cancellation_token(); let join_handle = rt::spawn(async move { loop { - // Timer action is ignored if it was either cancelled or the associated Actor is no longer running. let cancel_token_fut = pin!(cloned_token.cancelled()); let actor_cancel_fut = pin!(actor_cancellation_token.cancelled()); let cancel_conditions = select(cancel_token_fut, actor_cancel_fut); + let msg_clone = msg.clone(); let async_block = pin!(async { rt::sleep(period).await; - let _ = handle.send(message.clone()).await; + let _ = ctx.send(msg_clone); }); let result = select(cancel_conditions, async_block).await; match result { diff --git a/concurrency/src/tasks/timer_tests.rs b/concurrency/src/tasks/timer_tests.rs index 46eb664..205d5ba 100644 --- a/concurrency/src/tasks/timer_tests.rs +++ b/concurrency/src/tasks/timer_tests.rs @@ -1,31 +1,27 @@ use super::{ - send_after, send_interval, Actor, ActorRef, InitResult, InitResult::Success, MessageResponse, - RequestResponse, + send_after, send_interval, Actor, ActorStart, Context, Handler, }; +use crate::message::Message; use spawned_rt::tasks::{self as rt, CancellationToken}; use std::time::Duration; -type RepeaterHandle = ActorRef; +// --- Repeater (interval timer test) --- -#[derive(Clone)] -enum RepeaterCastMessage { - Inc, - StopTimer, -} +#[derive(Clone, Debug)] +struct Inc; +impl Message for Inc { type Result = (); } -#[derive(Clone)] -enum RepeaterCallMessage { - GetCount, -} +#[derive(Clone, Debug)] +struct StopTimer; +impl Message for StopTimer { type Result = (); } -#[derive(PartialEq, Debug)] -enum RepeaterOutMessage { - Count(i32), -} +#[derive(Debug)] +struct GetRepCount; +impl Message for GetRepCount { type Result = i32; } struct Repeater { - pub(crate) count: i32, - pub(crate) cancellation_token: Option, + count: i32, + cancellation_token: Option, } impl Repeater { @@ -37,63 +33,34 @@ impl Repeater { } } -impl Repeater { - pub async fn stop_timer(server: &mut RepeaterHandle) -> Result<(), ()> { - server - .send(RepeaterCastMessage::StopTimer) - .await - .map_err(|_| ()) - } - - pub async fn get_count(server: &mut RepeaterHandle) -> Result { - server - .request(RepeaterCallMessage::GetCount) - .await - .map_err(|_| ()) - } -} - impl Actor for Repeater { - type Request = RepeaterCallMessage; - type Message = RepeaterCastMessage; - type Reply = RepeaterOutMessage; - type Error = (); - - async fn init(mut self, handle: &RepeaterHandle) -> Result, Self::Error> { + async fn started(&mut self, ctx: &Context) { let timer = send_interval( Duration::from_millis(100), - handle.clone(), - RepeaterCastMessage::Inc, + ctx.clone(), + Inc, ); self.cancellation_token = Some(timer.cancellation_token); - Ok(Success(self)) } +} + +impl Handler for Repeater { + async fn handle(&mut self, _msg: Inc, _ctx: &Context) { + self.count += 1; + } +} - async fn handle_request( - &mut self, - _message: Self::Request, - _handle: &RepeaterHandle, - ) -> RequestResponse { - let count = self.count; - RequestResponse::Reply(RepeaterOutMessage::Count(count)) +impl Handler for Repeater { + async fn handle(&mut self, _msg: StopTimer, _ctx: &Context) { + if let Some(ct) = self.cancellation_token.clone() { + ct.cancel(); + } } +} - async fn handle_message( - &mut self, - message: Self::Message, - _handle: &ActorRef, - ) -> MessageResponse { - match message { - RepeaterCastMessage::Inc => { - self.count += 1; - } - RepeaterCastMessage::StopTimer => { - if let Some(ct) = self.cancellation_token.clone() { - ct.cancel() - }; - } - }; - MessageResponse::NoReply +impl Handler for Repeater { + async fn handle(&mut self, _msg: GetRepCount, _ctx: &Context) -> i32 { + self.count } } @@ -101,109 +68,60 @@ impl Actor for Repeater { pub fn test_send_interval_and_cancellation() { let runtime = rt::Runtime::new().unwrap(); runtime.block_on(async move { - // Start a Repeater - let mut repeater = Repeater::new(0).start(); + let repeater = Repeater::new(0).start(); - // Wait for 1 second rt::sleep(Duration::from_secs(1)).await; - // Check count - let count = Repeater::get_count(&mut repeater).await.unwrap(); + let count = repeater.send_request(GetRepCount).await.unwrap(); + assert_eq!(9, count); - // 9 messages in 1 second (after first 100 milliseconds sleep) - assert_eq!(RepeaterOutMessage::Count(9), count); + repeater.send(StopTimer).unwrap(); - // Pause timer - Repeater::stop_timer(&mut repeater).await.unwrap(); - - // Wait another second rt::sleep(Duration::from_secs(1)).await; - // Check count again - let count2 = Repeater::get_count(&mut repeater).await.unwrap(); - - // As timer was paused, count should remain at 9 - assert_eq!(RepeaterOutMessage::Count(9), count2); + let count2 = repeater.send_request(GetRepCount).await.unwrap(); + assert_eq!(9, count2); }); } -type DelayedHandle = ActorRef; - -#[derive(Clone)] -enum DelayedCastMessage { - Inc, -} +// --- Delayed (send_after test) --- -#[derive(Clone)] -enum DelayedCallMessage { - GetCount, - Stop, -} +#[derive(Debug)] +struct GetDelCount; +impl Message for GetDelCount { type Result = i32; } -#[derive(PartialEq, Debug)] -enum DelayedOutMessage { - Count(i32), -} +#[derive(Debug)] +struct StopDelayed; +impl Message for StopDelayed { type Result = i32; } struct Delayed { - pub(crate) count: i32, + count: i32, } impl Delayed { pub fn new(initial_count: i32) -> Self { - Delayed { - count: initial_count, - } + Delayed { count: initial_count } } } -impl Delayed { - pub async fn get_count(server: &mut DelayedHandle) -> Result { - server - .request(DelayedCallMessage::GetCount) - .await - .map_err(|_| ()) - } +impl Actor for Delayed {} - pub async fn stop(server: &mut DelayedHandle) -> Result { - server - .request(DelayedCallMessage::Stop) - .await - .map_err(|_| ()) +impl Handler for Delayed { + async fn handle(&mut self, _msg: Inc, _ctx: &Context) { + self.count += 1; } } -impl Actor for Delayed { - type Request = DelayedCallMessage; - type Message = DelayedCastMessage; - type Reply = DelayedOutMessage; - type Error = (); - - async fn handle_request( - &mut self, - message: Self::Request, - _handle: &DelayedHandle, - ) -> RequestResponse { - match message { - DelayedCallMessage::GetCount => { - let count = self.count; - RequestResponse::Reply(DelayedOutMessage::Count(count)) - } - DelayedCallMessage::Stop => RequestResponse::Stop(DelayedOutMessage::Count(self.count)), - } +impl Handler for Delayed { + async fn handle(&mut self, _msg: GetDelCount, _ctx: &Context) -> i32 { + self.count } +} - async fn handle_message( - &mut self, - message: Self::Message, - _handle: &DelayedHandle, - ) -> MessageResponse { - match message { - DelayedCastMessage::Inc => { - self.count += 1; - } - }; - MessageResponse::NoReply +impl Handler for Delayed { + async fn handle(&mut self, _msg: StopDelayed, ctx: &Context) -> i32 { + ctx.stop(); + self.count } } @@ -211,43 +129,33 @@ impl Actor for Delayed { pub fn test_send_after_and_cancellation() { let runtime = rt::Runtime::new().unwrap(); runtime.block_on(async move { - // Start a Delayed - let mut repeater = Delayed::new(0).start(); + let repeater = Delayed::new(0).start(); - // Set a just once timed message + let ctx = Context::from_ref(&repeater); let _ = send_after( Duration::from_millis(100), - repeater.clone(), - DelayedCastMessage::Inc, + ctx, + Inc, ); - // Wait for 200 milliseconds rt::sleep(Duration::from_millis(200)).await; - // Check count - let count = Delayed::get_count(&mut repeater).await.unwrap(); + let count = repeater.send_request(GetDelCount).await.unwrap(); + assert_eq!(1, count); - // Only one message (no repetition) - assert_eq!(DelayedOutMessage::Count(1), count); - - // New timer + let ctx = Context::from_ref(&repeater); let timer = send_after( Duration::from_millis(100), - repeater.clone(), - DelayedCastMessage::Inc, + ctx, + Inc, ); - // Cancel the new timer before timeout timer.cancellation_token.cancel(); - // Wait another 200 milliseconds rt::sleep(Duration::from_millis(200)).await; - // Check count again - let count2 = Delayed::get_count(&mut repeater).await.unwrap(); - - // As timer was cancelled, count should remain at 1 - assert_eq!(DelayedOutMessage::Count(1), count2); + let count2 = repeater.send_request(GetDelCount).await.unwrap(); + assert_eq!(1, count2); }); } @@ -255,39 +163,31 @@ pub fn test_send_after_and_cancellation() { pub fn test_send_after_gen_server_teardown() { let runtime = rt::Runtime::new().unwrap(); runtime.block_on(async move { - // Start a Delayed - let mut repeater = Delayed::new(0).start(); + let repeater = Delayed::new(0).start(); - // Set a just once timed message + let ctx = Context::from_ref(&repeater); let _ = send_after( Duration::from_millis(100), - repeater.clone(), - DelayedCastMessage::Inc, + ctx, + Inc, ); - // Wait for 200 milliseconds rt::sleep(Duration::from_millis(200)).await; - // Check count - let count = Delayed::get_count(&mut repeater).await.unwrap(); - - // Only one message (no repetition) - assert_eq!(DelayedOutMessage::Count(1), count); + let count = repeater.send_request(GetDelCount).await.unwrap(); + assert_eq!(1, count); - // New timer + let ctx = Context::from_ref(&repeater); let _ = send_after( Duration::from_millis(100), - repeater.clone(), - DelayedCastMessage::Inc, + ctx, + Inc, ); - // Stop the Actor before timeout - let count2 = Delayed::stop(&mut repeater).await.unwrap(); + let count2 = repeater.send_request(StopDelayed).await.unwrap(); - // Wait another 200 milliseconds rt::sleep(Duration::from_millis(200)).await; - // As timer was cancelled, count should remain at 1 - assert_eq!(DelayedOutMessage::Count(1), count2); + assert_eq!(1, count2); }); } diff --git a/concurrency/src/threads/actor.rs b/concurrency/src/threads/actor.rs index 04796b7..a851d4e 100644 --- a/concurrency/src/threads/actor.rs +++ b/concurrency/src/threads/actor.rs @@ -1,21 +1,156 @@ -//! Actor trait and structs to create an abstraction similar to Erlang gen_server. -//! See examples/name_server for a usage example. +use crate::error::ActorError; +use crate::message::Message; use spawned_rt::threads::{ self as rt, mpsc, oneshot, oneshot::RecvTimeoutError, CancellationToken, }; use std::{ - fmt::Debug, panic::{catch_unwind, AssertUnwindSafe}, sync::{Arc, Condvar, Mutex}, time::Duration, }; -use crate::error::ActorError; - const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); -/// Guard that signals completion when dropped. -/// Ensures waiters are notified even if the actor thread panics. +// --------------------------------------------------------------------------- +// Actor trait +// --------------------------------------------------------------------------- + +pub trait Actor: Send + Sized + 'static { + fn started(&mut self, _ctx: &Context) {} + fn stopped(&mut self, _ctx: &Context) {} +} + +// --------------------------------------------------------------------------- +// Handler trait (per-message, sync version) +// --------------------------------------------------------------------------- + +pub trait Handler: Actor { + fn handle(&mut self, msg: M, ctx: &Context) -> M::Result; +} + +// --------------------------------------------------------------------------- +// Envelope (type-erasure on the actor side) +// --------------------------------------------------------------------------- + +trait Envelope: Send { + fn handle(self: Box, actor: &mut A, ctx: &Context); +} + +struct MessageEnvelope { + msg: M, + tx: Option>, +} + +impl Envelope for MessageEnvelope +where + A: Actor + Handler, + M: Message, +{ + fn handle(self: Box, actor: &mut A, ctx: &Context) { + let result = actor.handle(self.msg, ctx); + if let Some(tx) = self.tx { + let _ = tx.send(result); + } + } +} + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +pub struct Context { + sender: mpsc::Sender + Send>>, + cancellation_token: CancellationToken, +} + +impl Clone for Context { + fn clone(&self) -> Self { + Self { + sender: self.sender.clone(), + cancellation_token: self.cancellation_token.clone(), + } + } +} + +impl std::fmt::Debug for Context { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Context").finish_non_exhaustive() + } +} + +impl Context { + pub fn from_ref(actor_ref: &ActorRef) -> Self { + Self { + sender: actor_ref.sender.clone(), + cancellation_token: actor_ref.cancellation_token.clone(), + } + } + + pub fn stop(&self) { + self.cancellation_token.cancel(); + } + + pub fn send(&self, msg: M) -> Result<(), ActorError> + where + A: Handler, + M: Message, + { + let envelope = MessageEnvelope { msg, tx: None }; + self.sender + .send(Box::new(envelope)) + .map_err(|_| ActorError::ActorStopped) + } + + pub fn request(&self, msg: M) -> Result, ActorError> + where + A: Handler, + M: Message, + { + let (tx, rx) = oneshot::channel(); + let envelope = MessageEnvelope { + msg, + tx: Some(tx), + }; + self.sender + .send(Box::new(envelope)) + .map_err(|_| ActorError::ActorStopped)?; + Ok(rx) + } + + pub fn send_request(&self, msg: M) -> Result + where + A: Handler, + M: Message, + { + self.send_request_with_timeout(msg, DEFAULT_REQUEST_TIMEOUT) + } + + pub fn send_request_with_timeout( + &self, + msg: M, + duration: Duration, + ) -> Result + where + A: Handler, + M: Message, + { + let rx = self.request(msg)?; + match rx.recv_timeout(duration) { + Ok(result) => Ok(result), + Err(RecvTimeoutError::Timeout) => Err(ActorError::RequestTimeout), + Err(RecvTimeoutError::Disconnected) => Err(ActorError::ActorStopped), + } + } + + pub(crate) fn cancellation_token(&self) -> CancellationToken { + self.cancellation_token.clone() + } +} + +// --------------------------------------------------------------------------- +// ActorRef +// --------------------------------------------------------------------------- + struct CompletionGuard(Arc<(Mutex, Condvar)>); impl Drop for CompletionGuard { @@ -27,94 +162,85 @@ impl Drop for CompletionGuard { } } -pub struct ActorRef { - pub tx: mpsc::Sender>, +pub struct ActorRef { + sender: mpsc::Sender + Send>>, cancellation_token: CancellationToken, - /// Completion signal: (is_completed, condvar for waiters) completion: Arc<(Mutex, Condvar)>, } +impl std::fmt::Debug for ActorRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ActorRef").finish_non_exhaustive() + } +} + impl Clone for ActorRef { fn clone(&self) -> Self { Self { - tx: self.tx.clone(), + sender: self.sender.clone(), cancellation_token: self.cancellation_token.clone(), completion: self.completion.clone(), } } } -impl std::fmt::Debug for ActorRef { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ActorRef") - .field("tx", &self.tx) - .field("cancellation_token", &self.cancellation_token) - .finish_non_exhaustive() - } -} - impl ActorRef { - pub(crate) fn new(actor: A) -> Self { - let (tx, mut rx) = mpsc::channel::>(); - let cancellation_token = CancellationToken::new(); - let completion = Arc::new((Mutex::new(false), Condvar::new())); - let handle = ActorRef { - tx, - cancellation_token, - completion: completion.clone(), - }; - let handle_clone = handle.clone(); - let _thread_handle = rt::spawn(move || { - // Guard ensures completion is signaled even if actor panics - let _guard = CompletionGuard(completion); - if actor.run(&handle, &mut rx).is_err() { - tracing::trace!("Actor crashed") - }; - }); - handle_clone + pub fn send(&self, msg: M) -> Result<(), ActorError> + where + A: Handler, + M: Message, + { + let envelope = MessageEnvelope { msg, tx: None }; + self.sender + .send(Box::new(envelope)) + .map_err(|_| ActorError::ActorStopped) } - pub fn sender(&self) -> mpsc::Sender> { - self.tx.clone() + pub fn request(&self, msg: M) -> Result, ActorError> + where + A: Handler, + M: Message, + { + let (tx, rx) = oneshot::channel(); + let envelope = MessageEnvelope { + msg, + tx: Some(tx), + }; + self.sender + .send(Box::new(envelope)) + .map_err(|_| ActorError::ActorStopped)?; + Ok(rx) } - pub fn request(&mut self, message: A::Request) -> Result { - self.request_with_timeout(message, DEFAULT_REQUEST_TIMEOUT) + pub fn send_request(&self, msg: M) -> Result + where + A: Handler, + M: Message, + { + self.send_request_with_timeout(msg, DEFAULT_REQUEST_TIMEOUT) } - pub fn request_with_timeout( - &mut self, - message: A::Request, + pub fn send_request_with_timeout( + &self, + msg: M, duration: Duration, - ) -> Result { - let (oneshot_tx, oneshot_rx) = oneshot::channel::>(); - self.tx.send(ActorInMsg::Request { - sender: oneshot_tx, - message, - })?; - match oneshot_rx.recv_timeout(duration) { - Ok(result) => result, + ) -> Result + where + A: Handler, + M: Message, + { + let rx = self.request(msg)?; + match rx.recv_timeout(duration) { + Ok(result) => Ok(result), Err(RecvTimeoutError::Timeout) => Err(ActorError::RequestTimeout), - Err(RecvTimeoutError::Disconnected) => Err(ActorError::Server), + Err(RecvTimeoutError::Disconnected) => Err(ActorError::ActorStopped), } } - pub fn send(&mut self, message: A::Message) -> Result<(), ActorError> { - self.tx - .send(ActorInMsg::Message { message }) - .map_err(|_error| ActorError::Server) - } - - pub(crate) fn cancellation_token(&self) -> CancellationToken { - self.cancellation_token.clone() + pub fn context(&self) -> Context { + Context::from_ref(self) } - /// Blocks until the actor has stopped. - /// - /// This method blocks the current thread until the actor has finished - /// processing and exited its main loop. Can be called multiple times from - /// different clones of the ActorRef - all callers will be notified when - /// the actor stops. pub fn join(&self) { let (lock, cvar) = &*self.completion; let mut completed = lock.lock().unwrap_or_else(|p| p.into_inner()); @@ -124,191 +250,99 @@ impl ActorRef { } } -pub enum ActorInMsg { - Request { - sender: oneshot::Sender>, - message: A::Request, - }, - Message { - message: A::Message, - }, -} +// --------------------------------------------------------------------------- +// Actor startup + main loop +// --------------------------------------------------------------------------- -pub enum RequestResponse { - Reply(A::Reply), - Unused, - Stop(A::Reply), -} - -pub enum MessageResponse { - NoReply, - Unused, - Stop, -} - -pub enum InitResult { - Success(A), - NoSuccess(A), -} - -pub trait Actor: Send + Sized { - type Request: Clone + Send + Sized + Sync; - type Message: Clone + Send + Sized + Sync; - type Reply: Send + Sized; - type Error: Debug + Send; - - fn start(self) -> ActorRef { - ActorRef::new(self) - } +impl ActorRef { + fn spawn(actor: A) -> Self { + let (tx, rx) = mpsc::channel:: + Send>>(); + let cancellation_token = CancellationToken::new(); + let completion = Arc::new((Mutex::new(false), Condvar::new())); - fn run( - self, - handle: &ActorRef, - rx: &mut mpsc::Receiver>, - ) -> Result<(), ActorError> { - let cancellation_token = handle.cancellation_token.clone(); - - let res = match self.init(handle) { - Ok(InitResult::Success(new_state)) => { - let final_state = new_state.main_loop(handle, rx)?; - Ok(final_state) - } - Ok(InitResult::NoSuccess(intermediate_state)) => { - // Initialization failed but error was handled in callback. - // Skip main_loop and return state for teardown. - Ok(intermediate_state) - } - Err(err) => { - tracing::error!("Initialization failed with unhandled error: {err:?}"); - Err(ActorError::Initialization) - } + let actor_ref = ActorRef { + sender: tx.clone(), + cancellation_token: cancellation_token.clone(), + completion: completion.clone(), }; - cancellation_token.cancel(); + let ctx = Context { + sender: tx, + cancellation_token: cancellation_token.clone(), + }; - if let Ok(final_state) = res { - if let Err(err) = final_state.teardown(handle) { - tracing::error!("Error during teardown: {err:?}"); - } - } + let _thread_handle = rt::spawn(move || { + let _guard = CompletionGuard(completion); + run_actor(actor, ctx, rx, cancellation_token); + }); - Ok(()) + actor_ref } +} - /// Initialization function. It's called before main loop. It - /// can be overrided on implementations in case initial steps are - /// required. - fn init(self, _handle: &ActorRef) -> Result, Self::Error> { - Ok(InitResult::Success(self)) - } +fn run_actor( + mut actor: A, + ctx: Context, + rx: mpsc::Receiver + Send>>, + cancellation_token: CancellationToken, +) { + actor.started(&ctx); - fn main_loop( - mut self, - handle: &ActorRef, - rx: &mut mpsc::Receiver>, - ) -> Result { - loop { - if !self.receive(handle, rx)? { - break; - } - } - tracing::trace!("Stopping Actor"); - Ok(self) + if cancellation_token.is_cancelled() { + actor.stopped(&ctx); + return; } - fn receive( - &mut self, - handle: &ActorRef, - rx: &mut mpsc::Receiver>, - ) -> Result { - let message = rx.recv().ok(); - - let keep_running = match message { - Some(ActorInMsg::Request { sender, message }) => { - let (keep_running, response) = match catch_unwind(AssertUnwindSafe(|| { - self.handle_request(message, handle) - })) { - Ok(response) => match response { - RequestResponse::Reply(response) => (true, Ok(response)), - RequestResponse::Stop(response) => (false, Ok(response)), - RequestResponse::Unused => { - tracing::error!("Actor received unexpected Request"); - (false, Err(ActorError::RequestUnused)) - } - }, - Err(error) => { - tracing::error!("Error in callback: '{error:?}'"); - (true, Err(ActorError::Callback)) - } - }; - // Send response back - if sender.send(response).is_err() { - tracing::trace!("Actor failed to send response back, client must have died") - }; - keep_running - } - Some(ActorInMsg::Message { message }) => { - match catch_unwind(AssertUnwindSafe(|| self.handle_message(message, handle))) { - Ok(response) => match response { - MessageResponse::NoReply => true, - MessageResponse::Stop => false, - MessageResponse::Unused => { - tracing::error!("Actor received unexpected Message"); - false - } - }, - Err(error) => { - tracing::error!("Error in callback: '{error:?}'"); - true - } + loop { + let msg = rx.recv().ok(); + match msg { + Some(envelope) => { + let result = catch_unwind(AssertUnwindSafe(|| { + envelope.handle(&mut actor, &ctx); + })); + if let Err(panic) = result { + tracing::error!("Panic in message handler: {panic:?}"); + break; + } + if cancellation_token.is_cancelled() { + break; } } - None => { - // Channel has been closed; won't receive further messages. Stop the server. - false - } - }; - Ok(keep_running) + None => break, + } } - fn handle_request( - &mut self, - _message: Self::Request, - _handle: &ActorRef, - ) -> RequestResponse { - RequestResponse::Unused - } + cancellation_token.cancel(); + actor.stopped(&ctx); +} - fn handle_message( - &mut self, - _message: Self::Message, - _handle: &ActorRef, - ) -> MessageResponse { - MessageResponse::Unused - } +// --------------------------------------------------------------------------- +// Actor::start +// --------------------------------------------------------------------------- - /// Teardown function. It's called after the stop message is received. - /// It can be overrided on implementations in case final steps are required, - /// like closing streams, stopping timers, etc. - fn teardown(self, _handle: &ActorRef) -> Result<(), Self::Error> { - Ok(()) +pub trait ActorStart: Actor { + fn start(self) -> ActorRef { + ActorRef::spawn(self) } } -/// Spawns a thread that runs a blocking operation and sends a message to an Actor -/// on completion. This is the sync equivalent of tasks::send_message_on. -/// This function returns a handle to the spawned thread. -pub fn send_message_on(handle: ActorRef, f: F, message: T::Message) -> rt::JoinHandle<()> +impl ActorStart for A {} + +// --------------------------------------------------------------------------- +// send_message_on (utility) +// --------------------------------------------------------------------------- + +pub fn send_message_on(ctx: Context, f: F, msg: M) -> rt::JoinHandle<()> where - T: Actor, + A: Actor + Handler, + M: Message, F: FnOnce() + Send + 'static, { - let cancellation_token = handle.cancellation_token(); - let mut handle_clone = handle.clone(); + let cancellation_token = ctx.cancellation_token(); rt::spawn(move || { f(); if !cancellation_token.is_cancelled() { - if let Err(e) = handle_clone.send(message) { + if let Err(e) = ctx.send(msg) { tracing::error!("Failed to send message: {e:?}") } } diff --git a/concurrency/src/threads/mod.rs b/concurrency/src/threads/mod.rs index 9643a13..d1750ca 100644 --- a/concurrency/src/threads/mod.rs +++ b/concurrency/src/threads/mod.rs @@ -1,7 +1,4 @@ -//! spawned concurrency -//! IO threads-based traits and structs to implement concurrent code à-la-Erlang. - -mod actor; +pub(crate) mod actor; mod process; mod stream; mod time; @@ -10,9 +7,8 @@ mod time; mod timer_tests; pub use actor::{ - send_message_on, Actor, ActorInMsg, ActorRef, InitResult, InitResult::NoSuccess, - InitResult::Success, MessageResponse, RequestResponse, + send_message_on, Actor, ActorRef, ActorStart, Context, Handler, }; pub use process::{send, Process, ProcessInfo}; pub use stream::spawn_listener; -pub use time::{send_after, send_interval}; +pub use time::{send_after, send_interval, TimerHandle}; diff --git a/concurrency/src/threads/stream.rs b/concurrency/src/threads/stream.rs index 696c3cf..9249246 100644 --- a/concurrency/src/threads/stream.rs +++ b/concurrency/src/threads/stream.rs @@ -1,21 +1,21 @@ use std::thread::JoinHandle; -use crate::threads::{Actor, ActorRef}; +use crate::message::Message; -/// Spawns a listener that listens to a stream and sends messages to an Actor. -/// -/// Items sent through the stream are required to be wrapped in a Result type. -pub fn spawn_listener(mut handle: ActorRef, stream: I) -> JoinHandle<()> +use super::actor::{Actor, Context, Handler}; + +pub fn spawn_listener(ctx: Context, stream: I) -> JoinHandle<()> where - T: Actor, - I: IntoIterator, + A: Actor + Handler, + M: Message, + I: IntoIterator, ::IntoIter: std::marker::Send + 'static, { let mut iter = stream.into_iter(); - let cancellation_token = handle.cancellation_token(); + let cancellation_token = ctx.cancellation_token(); let join_handle = spawned_rt::threads::spawn(move || loop { match iter.next() { - Some(msg) => match handle.send(msg) { + Some(msg) => match ctx.send(msg) { Ok(_) => tracing::trace!("Message sent successfully"), Err(e) => { tracing::error!("Failed to send message: {e:?}"); diff --git a/concurrency/src/threads/time.rs b/concurrency/src/threads/time.rs index 5b4ebb8..78fb1cd 100644 --- a/concurrency/src/threads/time.rs +++ b/concurrency/src/threads/time.rs @@ -3,36 +3,30 @@ use std::time::Duration; use spawned_rt::threads::{self as rt, CancellationToken, JoinHandle}; -use super::{Actor, ActorRef}; +use super::actor::{Actor, Context, Handler}; +use crate::message::Message; pub struct TimerHandle { pub join_handle: JoinHandle<()>, pub cancellation_token: CancellationToken, } -/// Sends a message after a given period to the specified Actor. -/// -/// The timer respects both its own cancellation token and the Actor's -/// cancellation token. If either is cancelled, the timer wakes up immediately -/// and exits without sending the message. -pub fn send_after(period: Duration, mut handle: ActorRef, message: T::Message) -> TimerHandle +pub fn send_after(period: Duration, ctx: Context, msg: M) -> TimerHandle where - T: Actor + 'static, + A: Actor + Handler, + M: Message, { let cancellation_token = CancellationToken::new(); let timer_token = cancellation_token.clone(); - let actor_token = handle.cancellation_token(); + let actor_token = ctx.cancellation_token(); - // Channel to wake the timer thread on cancellation let (wake_tx, wake_rx) = mpsc::channel::<()>(); - // Register wake-up on timer cancellation let wake_tx1 = wake_tx.clone(); timer_token.on_cancel(Box::new(move || { let _ = wake_tx1.send(()); })); - // Register wake-up on actor cancellation actor_token.on_cancel(Box::new(move || { let _ = wake_tx.send(()); })); @@ -40,14 +34,11 @@ where let join_handle = rt::spawn(move || { match wake_rx.recv_timeout(period) { Err(RecvTimeoutError::Timeout) => { - // Timer expired - send if still valid if !timer_token.is_cancelled() && !actor_token.is_cancelled() { - let _ = handle.send(message); + let _ = ctx.send(msg); } } - Ok(()) | Err(RecvTimeoutError::Disconnected) => { - // Woken early by cancellation - exit without sending - } + Ok(()) | Err(RecvTimeoutError::Disconnected) => {} } }); @@ -57,46 +48,33 @@ where } } -/// Sends a message to the specified Actor repeatedly at the given interval. -/// -/// The timer respects both its own cancellation token and the Actor's -/// cancellation token. If either is cancelled, the timer wakes up immediately -/// and exits. -pub fn send_interval( - period: Duration, - mut handle: ActorRef, - message: T::Message, -) -> TimerHandle +pub fn send_interval(period: Duration, ctx: Context, msg: M) -> TimerHandle where - T: Actor + 'static, + A: Actor + Handler, + M: Message + Clone, { let cancellation_token = CancellationToken::new(); let timer_token = cancellation_token.clone(); - let actor_token = handle.cancellation_token(); + let actor_token = ctx.cancellation_token(); - // Channel to wake the timer thread on cancellation let (wake_tx, wake_rx) = mpsc::channel::<()>(); - // Register wake-up on timer cancellation let wake_tx1 = wake_tx.clone(); timer_token.on_cancel(Box::new(move || { let _ = wake_tx1.send(()); })); - // Register wake-up on actor cancellation actor_token.on_cancel(Box::new(move || { let _ = wake_tx.send(()); })); let join_handle = rt::spawn(move || { while let Err(RecvTimeoutError::Timeout) = wake_rx.recv_timeout(period) { - // Timer expired - send if still valid if timer_token.is_cancelled() || actor_token.is_cancelled() { break; } - let _ = handle.send(message.clone()); + let _ = ctx.send(msg.clone()); } - // If we exit the loop via Ok(()) or Disconnected, cancellation occurred }); TimerHandle { diff --git a/concurrency/src/threads/timer_tests.rs b/concurrency/src/threads/timer_tests.rs index e023a78..6ba13c9 100644 --- a/concurrency/src/threads/timer_tests.rs +++ b/concurrency/src/threads/timer_tests.rs @@ -1,33 +1,27 @@ use crate::threads::{ - send_interval, Actor, ActorRef, InitResult, MessageResponse, RequestResponse, + send_after, send_interval, Actor, ActorStart, Context, Handler, }; +use crate::message::Message; use spawned_rt::threads::{self as rt, CancellationToken}; use std::time::Duration; -use super::send_after; +// --- Repeater (interval timer test) --- -type RepeaterHandle = ActorRef; +#[derive(Clone, Debug)] +struct Inc; +impl Message for Inc { type Result = (); } -#[derive(Clone)] -enum RepeaterCastMessage { - Inc, - StopTimer, -} - -#[derive(Clone)] -enum RepeaterCallMessage { - GetCount, -} +#[derive(Clone, Debug)] +struct StopTimer; +impl Message for StopTimer { type Result = (); } -#[derive(PartialEq, Debug)] -enum RepeaterOutMessage { - Count(i32), -} +#[derive(Debug)] +struct GetRepCount; +impl Message for GetRepCount { type Result = i32; } -#[derive(Clone)] struct Repeater { - pub(crate) count: i32, - pub(crate) cancellation_token: Option, + count: i32, + cancellation_token: Option, } impl Repeater { @@ -39,240 +33,136 @@ impl Repeater { } } -impl Repeater { - pub fn stop_timer(server: &mut RepeaterHandle) -> Result<(), ()> { - server.send(RepeaterCastMessage::StopTimer).map_err(|_| ()) - } - - pub fn get_count(server: &mut RepeaterHandle) -> Result { - server - .request(RepeaterCallMessage::GetCount) - .map_err(|_| ()) - } -} - impl Actor for Repeater { - type Request = RepeaterCallMessage; - type Message = RepeaterCastMessage; - type Reply = RepeaterOutMessage; - type Error = (); - - fn init(mut self, handle: &RepeaterHandle) -> Result, Self::Error> { + fn started(&mut self, ctx: &Context) { let timer = send_interval( Duration::from_millis(100), - handle.clone(), - RepeaterCastMessage::Inc, + ctx.clone(), + Inc, ); self.cancellation_token = Some(timer.cancellation_token); - Ok(InitResult::Success(self)) } +} + +impl Handler for Repeater { + fn handle(&mut self, _msg: Inc, _ctx: &Context) { + self.count += 1; + } +} - fn handle_request( - &mut self, - _message: Self::Request, - _handle: &RepeaterHandle, - ) -> RequestResponse { - let count = self.count; - RequestResponse::Reply(RepeaterOutMessage::Count(count)) +impl Handler for Repeater { + fn handle(&mut self, _msg: StopTimer, _ctx: &Context) { + if let Some(ct) = self.cancellation_token.clone() { + ct.cancel(); + } } +} - fn handle_message( - &mut self, - message: Self::Message, - _handle: &ActorRef, - ) -> MessageResponse { - match message { - RepeaterCastMessage::Inc => { - self.count += 1; - } - RepeaterCastMessage::StopTimer => { - if let Some(ct) = self.cancellation_token.clone() { - ct.cancel() - }; - } - }; - MessageResponse::NoReply +impl Handler for Repeater { + fn handle(&mut self, _msg: GetRepCount, _ctx: &Context) -> i32 { + self.count } } #[test] pub fn test_send_interval_and_cancellation() { - // Start a Repeater - let mut repeater = Repeater::new(0).start(); + let repeater = Repeater::new(0).start(); - // Wait for 1 second rt::sleep(Duration::from_secs(1)); - // Check count - let count = Repeater::get_count(&mut repeater).unwrap(); - - // 9 messages in 1 second (after first 100 milliseconds sleep) - assert_eq!(RepeaterOutMessage::Count(9), count); + let count = repeater.send_request(GetRepCount).unwrap(); + assert_eq!(9, count); - // Pause timer - Repeater::stop_timer(&mut repeater).unwrap(); + repeater.send(StopTimer).unwrap(); - // Wait another second rt::sleep(Duration::from_secs(1)); - // Check count again - let count2 = Repeater::get_count(&mut repeater).unwrap(); - - // As timer was paused, count should remain at 9 - assert_eq!(RepeaterOutMessage::Count(9), count2); + let count2 = repeater.send_request(GetRepCount).unwrap(); + assert_eq!(9, count2); } -type DelayedHandle = ActorRef; - -#[derive(Clone)] -enum DelayedCastMessage { - Inc, -} +// --- Delayed (send_after test) --- -#[derive(Clone)] -enum DelayedCallMessage { - GetCount, - Stop, -} +#[derive(Debug)] +struct GetDelCount; +impl Message for GetDelCount { type Result = i32; } -#[derive(PartialEq, Debug)] -enum DelayedOutMessage { - Count(i32), -} +#[derive(Debug)] +struct StopDelayed; +impl Message for StopDelayed { type Result = i32; } -#[derive(Clone)] struct Delayed { - pub(crate) count: i32, + count: i32, } impl Delayed { pub fn new(initial_count: i32) -> Self { - Delayed { - count: initial_count, - } + Delayed { count: initial_count } } } -impl Delayed { - pub fn get_count(server: &mut DelayedHandle) -> Result { - server.request(DelayedCallMessage::GetCount).map_err(|_| ()) - } +impl Actor for Delayed {} - pub fn stop(server: &mut DelayedHandle) -> Result { - server.request(DelayedCallMessage::Stop).map_err(|_| ()) +impl Handler for Delayed { + fn handle(&mut self, _msg: Inc, _ctx: &Context) { + self.count += 1; } } -impl Actor for Delayed { - type Request = DelayedCallMessage; - type Message = DelayedCastMessage; - type Reply = DelayedOutMessage; - type Error = (); - - fn handle_request( - &mut self, - message: Self::Request, - _handle: &DelayedHandle, - ) -> RequestResponse { - match message { - DelayedCallMessage::GetCount => { - RequestResponse::Reply(DelayedOutMessage::Count(self.count)) - } - DelayedCallMessage::Stop => { - RequestResponse::Stop(DelayedOutMessage::Count(self.count)) - } - } +impl Handler for Delayed { + fn handle(&mut self, _msg: GetDelCount, _ctx: &Context) -> i32 { + self.count } +} - fn handle_message( - &mut self, - message: Self::Message, - _handle: &DelayedHandle, - ) -> MessageResponse { - match message { - DelayedCastMessage::Inc => { - self.count += 1; - } - }; - MessageResponse::NoReply +impl Handler for Delayed { + fn handle(&mut self, _msg: StopDelayed, ctx: &Context) -> i32 { + ctx.stop(); + self.count } } #[test] pub fn test_send_after_and_cancellation() { - // Start a Delayed - let mut repeater = Delayed::new(0).start(); + let actor = Delayed::new(0).start(); - // Set a just once timed message - let _ = send_after( - Duration::from_millis(100), - repeater.clone(), - DelayedCastMessage::Inc, - ); + let ctx = Context::from_ref(&actor); + let _ = send_after(Duration::from_millis(100), ctx, Inc); - // Wait for 200 milliseconds rt::sleep(Duration::from_millis(200)); - // Check count - let count = Delayed::get_count(&mut repeater).unwrap(); - - // Only one message (no repetition) - assert_eq!(DelayedOutMessage::Count(1), count); + let count = actor.send_request(GetDelCount).unwrap(); + assert_eq!(1, count); - // New timer - let timer = send_after( - Duration::from_millis(100), - repeater.clone(), - DelayedCastMessage::Inc, - ); + let ctx = Context::from_ref(&actor); + let timer = send_after(Duration::from_millis(100), ctx, Inc); - // Cancel the new timer before timeout timer.cancellation_token.cancel(); - // Wait another 200 milliseconds rt::sleep(Duration::from_millis(200)); - // Check count again - let count2 = Delayed::get_count(&mut repeater).unwrap(); - - // As timer was cancelled, count should remain at 1 - assert_eq!(DelayedOutMessage::Count(1), count2); + let count2 = actor.send_request(GetDelCount).unwrap(); + assert_eq!(1, count2); } #[test] pub fn test_send_after_actor_shutdown() { - // Start a Delayed - let mut actor = Delayed::new(0).start(); + let actor = Delayed::new(0).start(); - // Set a just once timed message - let _ = send_after( - Duration::from_millis(100), - actor.clone(), - DelayedCastMessage::Inc, - ); + let ctx = Context::from_ref(&actor); + let _ = send_after(Duration::from_millis(100), ctx, Inc); - // Wait for 200 milliseconds rt::sleep(Duration::from_millis(200)); - // Check count - let count = Delayed::get_count(&mut actor).unwrap(); - - // Only one message (no repetition) - assert_eq!(DelayedOutMessage::Count(1), count); + let count = actor.send_request(GetDelCount).unwrap(); + assert_eq!(1, count); - // New timer with long delay - let _ = send_after( - Duration::from_millis(100), - actor.clone(), - DelayedCastMessage::Inc, - ); + let ctx = Context::from_ref(&actor); + let _ = send_after(Duration::from_millis(100), ctx, Inc); - // Stop the Actor before timeout - this should wake up the timer immediately - let count2 = Delayed::stop(&mut actor).unwrap(); + let count2 = actor.send_request(StopDelayed).unwrap(); - // Wait another 200 milliseconds rt::sleep(Duration::from_millis(200)); - // As actor was stopped, count should remain at 1 (timer didn't fire) - assert_eq!(DelayedOutMessage::Count(1), count2); + assert_eq!(1, count2); } diff --git a/docs/ALTERNATIVE_APPROACHES.md b/docs/ALTERNATIVE_APPROACHES.md new file mode 100644 index 0000000..9afedf5 --- /dev/null +++ b/docs/ALTERNATIVE_APPROACHES.md @@ -0,0 +1,237 @@ +# Alternative Approach Branches for #144 + #145 + +Create 5 branches from `main`, each implementing a different approach to solve #144 (type safety) and #145 (circular deps). Each branch migrates the same examples (bank, name_server, chat_room, ping_pong) so they can be compared directly. + +**Baseline (already done):** `feat/handler-api-v0.5` — Handler + Recipient + +--- + +## Branch 1: `feat/144-typed-wrappers` + +**#144 solution:** Typed wrapper methods (user-side pattern, no framework trait changes) +**#145 solution:** Recipient via dual-channel (envelope channel alongside existing enum channel) + +### What changes + +The enum-based Actor trait stays **unchanged** from main: +```rust +pub trait Actor: Send + Sized { + type Request; type Message; type Reply; type Error; + async fn handle_request(&mut self, msg: Self::Request, handle: &ActorRef) -> RequestResponse; + async fn handle_message(&mut self, msg: Self::Message, handle: &ActorRef) -> MessageResponse; + // ... +} +``` + +Each actor adds typed convenience methods that hide enum matching: +```rust +impl Bank { + pub async fn deposit(handle: &mut ActorRef, who: String, amount: i32) -> Result { + match handle.request(InMessage::Add { who, amount }).await? { + Ok(OutMessage::Balance { amount, .. }) => Ok(amount), + Err(e) => Err(e), + _ => unreachable!(), + } + } +} +// Client: Bank::deposit(&mut bank, "joe".into(), 10).await? +``` + +For #145, add a **second channel** to ActorRef for envelope-based messages. The actor loop `select!`s on both channels. Actors that want to participate in cross-actor type-erased communication implement `Handler` for specific messages: + +```rust +pub struct ActorRef { + pub tx: mpsc::Sender>, // existing enum channel + envelope_tx: mpsc::Sender + Send>>, // NEW: for Recipient + // ... +} +``` + +### Key tradeoffs +- **Pro:** Zero breaking changes — existing code works untouched +- **Pro:** Typed wrappers are simple to understand +- **Con:** Dual channel adds complexity (select!, fairness, ordering) +- **Con:** `unreachable!()` still exists inside wrappers, just hidden +- **Con:** Two coexisting dispatch mechanisms is confusing + +--- + +## Branch 2: `feat/144-derive-macro` + +**#144 solution:** Proc macro `#[derive(ActorMessages)]` generates typed wrappers + per-message structs from annotated enum +**#145 solution:** Same dual-channel Recipient as Branch 1 + +### What changes + +A new `spawned-derive` proc-macro crate. The macro on the Request enum generates: +- Per-variant message structs implementing `Message` +- A reply enum wrapping per-variant result types +- Typed wrapper methods on `ActorRef` +- `Handler` impls that delegate to `handle_request()` + +```rust +#[derive(ActorMessages)] +#[actor(Bank)] +pub enum BankRequest { + #[reply(Result<(), BankError>)] + NewAccount { who: String }, + #[reply(Result)] + Deposit { who: String, amount: i32 }, +} +// Generates: struct NewAccount, struct Deposit, impl Message, typed methods, etc. +``` + +### Key tradeoffs +- **Pro:** Zero manual boilerplate for typed wrappers +- **Pro:** Macro enforces variant-struct mapping +- **Con:** Proc macro crate adds compilation cost +- **Con:** Generated code is hard to debug +- **Con:** `unreachable!()` still exists in generated code +- **Con:** Dual channel complexity from Branch 1 still applies + +--- + +## Branch 3: `feat/145-any-actor` + +**#144 solution:** Handler per-message trait (same as current PR) +**#145 solution:** `AnyActorRef` — fully type-erased handle via `Box` + +### What changes + +Replace `Receiver` / `Recipient` with a single type-erased handle: + +```rust +pub trait AnyActor: Send + Sync { + fn send_any(&self, msg: Box) -> Result<(), ActorError>; + fn request_any(&self, msg: Box) -> Result>, ActorError>; +} +pub type AnyActorRef = Arc; +``` + +Requires `AnyDispatchable` trait for runtime message type dispatch (macro or manual). + +### Chat room example +```rust +// room.rs — stores AnyActorRef, not Recipient +pub struct ChatRoom { members: Vec<(String, AnyActorRef)> } + +// user.rs — stores AnyActorRef, not Recipient +pub struct User { pub name: String, pub room: AnyActorRef } +``` + +### Key tradeoffs +- **Pro:** Single type for all type-erased refs — simpler storage +- **Con:** No compile-time safety at actor boundary — wrong message type is runtime error +- **Con:** Needs dispatch macro or manual boilerplate +- **Con:** Extra allocation (Box) and downcast overhead +- **Con:** Caller must downcast request-reply results + +--- + +## Branch 4: `feat/145-pid-addressing` + +**#144 solution:** Handler per-message trait (same as current PR) +**#145 solution:** Global PID registry — actors get a `Pid(u64)`, messages sent by PID + +### What changes + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Pid(u64); + +// Global registry maps (Pid, TypeId) -> Arc> (stored as Arc) +static REGISTRY: OnceLock> = OnceLock::new(); + +// Typed send/request — message type known, actor type erased +pub fn send(pid: Pid, msg: M) -> Result<(), ActorError>; +pub fn request(pid: Pid, msg: M) -> Result, ActorError>; + +// Named processes (Erlang register/1) +pub fn register_name(name: &str, pid: Pid); +pub fn whereis(name: &str) -> Option; +``` + +### Chat room example +```rust +// room.rs — stores Pid, not Recipient +pub struct ChatRoom { members: Vec<(String, Pid)> } + +// user.rs — stores Pid +pub struct User { pub name: String, pub room_pid: Pid } + +// main.rs — explicit registration +room.register::(); +room.register::(); +alice.register::(); +``` + +### Key tradeoffs +- **Pro:** Most Erlang-faithful — `Pid` is a lightweight copyable u64 +- **Pro:** Natural fit for named processes and clustering +- **Con:** Global mutable state — synchronization cost, harder to test +- **Con:** Registration boilerplate per message type +- **Con:** Runtime errors for unregistered message types or dead PIDs + +--- + +## Branch 5: `feat/145-protocol-trait` + +**#144 solution:** Handler per-message trait (same as current PR) +**#145 solution:** User-defined protocol traits as cross-actor contracts + +### What changes + +**No framework changes.** The cross-actor boundary is defined by explicit traits: + +```rust +// protocols.rs — shared contract, neither actor type mentioned +pub trait ChatParticipant: Send + Sync { + fn deliver(&self, from: String, text: String) -> Result<(), ActorError>; +} +pub trait ChatBroadcaster: Send + Sync { + fn say(&self, from: String, text: String) -> Result<(), ActorError>; + fn join(&self, name: String, inbox: Arc) -> Result<(), ActorError>; +} + +// Bridge impls connect ActorRef to protocol traits +impl ChatBroadcaster for ActorRef { + fn say(&self, from: String, text: String) -> Result<(), ActorError> { + self.send(RoomSay { from, text }) + } +} +impl ChatParticipant for ActorRef { + fn deliver(&self, from: String, text: String) -> Result<(), ActorError> { + self.send(UserDeliver { from, text }) + } +} +``` + +### Key tradeoffs +- **Pro:** Zero framework changes for #145 — purely a user-space pattern +- **Pro:** Strongest contracts — protocol trait is self-documenting +- **Pro:** Best testability — mock the protocol trait directly +- **Con:** More boilerplate — define trait + bridge impl per cross-actor boundary +- **Con:** Doesn't scale well for many-to-many actor topologies + +--- + +## Comparison Matrix + +| Dimension | Baseline (Recipient) | 1: Typed Wrappers | 2: Derive Macro | 3: AnyActor | 4: Pid | 5: Protocol Trait | +|-----------|---------------------|-------------------|-----------------|-------------|--------|------------------| +| Breaking changes | Yes | No | No | Yes | Yes | Yes | +| #144 type safety | Full | Hidden unreachable | Hidden unreachable | Full | Full | Full | +| #145 compile safety | Per-message | Per-message | Per-message | None | Runtime resolve | Per-protocol | +| Framework complexity | Medium | High (dual channel) | Very high (macro) | High (dispatch) | Medium (registry) | None | +| User boilerplate | Low | Medium (wrappers) | Low (macro) | Low | Medium (register) | High (traits) | +| Erlang alignment | Actix-like | Actix-like | Actix-like | Erlang-ish | Most Erlang | Least Erlang | +| Testability | Good | Good | Good | Fair | Hard (global) | Best | +| Clustering readiness | Hard | Hard | Hard | Medium | Excellent | Medium | + +## Execution Order + +1. **Branch 5** (`feat/145-protocol-trait`) — simplest, no new framework types for #145 +2. **Branch 4** (`feat/145-pid-addressing`) — new framework feature (Pid registry) +3. **Branch 3** (`feat/145-any-actor`) — new framework feature (AnyActor) +4. **Branch 1** (`feat/144-typed-wrappers`) — keeps enum Actor, adds dual-channel +5. **Branch 2** (`feat/144-derive-macro`) — most complex (proc macro crate + dual-channel) diff --git a/examples/bank/src/main.rs b/examples/bank/src/main.rs index d3321af..5ff3958 100644 --- a/examples/bank/src/main.rs +++ b/examples/bank/src/main.rs @@ -1,43 +1,26 @@ -//! Simple example to test concurrency/Process abstraction. +//! Bank example using the new Handler API. //! //! Based on Joe's Armstrong book: Programming Erlang, Second edition //! Section 22.1 - The Road to the Generic Server -//! -//! Erlang usage example: -//! 1> my_bank:start(). -//! {ok,<0.33.0>} -//! 2> my_bank:deposit("joe", 10). -//! not_a_customer -//! 3> my_bank:new_account("joe"). -//! {welcome,"joe"} -//! 4> my_bank:deposit("joe", 10). -//! {thanks,"joe",your_balance_is,10} -//! 5> my_bank:deposit("joe", 30). -//! {thanks,"joe",your_balance_is,40} -//! 6> my_bank:withdraw("joe", 15). -//! {thanks,"joe",your_balance_is,25} -//! 7> my_bank:withdraw("joe", 45). -//! {sorry,"joe",you_only_have,25,in_the_bank mod messages; mod server; -use messages::{BankError, BankOutMessage}; +use messages::*; use server::Bank; -use spawned_concurrency::tasks::Actor as _; +use spawned_concurrency::tasks::ActorStart as _; use spawned_rt::tasks as rt; fn main() { rt::run(async { - // Starting the bank - let mut name_server = Bank::new().start(); + let bank = Bank::new().start(); // Testing initial balance for "main" account - let result = Bank::withdraw(&mut name_server, "main".to_string(), 15).await; + let result = bank.send_request(Withdraw { who: "main".into(), amount: 15 }).await.unwrap(); tracing::info!("Withdraw result {result:?}"); assert_eq!( result, - Ok(BankOutMessage::WidrawOk { + Ok(BankOutMessage::WithdrawOk { who: "main".to_string(), amount: 985 }) @@ -45,73 +28,58 @@ fn main() { let joe = "Joe".to_string(); - // Error on deposit for an unexistent account - let result = Bank::deposit(&mut name_server, joe.clone(), 10).await; + // Error on deposit for a non-existent account + let result = bank.send_request(Deposit { who: joe.clone(), amount: 10 }).await.unwrap(); tracing::info!("Deposit result {result:?}"); assert_eq!(result, Err(BankError::NotACustomer { who: joe.clone() })); // Account creation - let result = Bank::new_account(&mut name_server, "Joe".to_string()).await; + let result = bank.send_request(NewAccount { who: joe.clone() }).await.unwrap(); tracing::info!("New account result {result:?}"); assert_eq!(result, Ok(BankOutMessage::Welcome { who: joe.clone() })); // Deposit - let result = Bank::deposit(&mut name_server, "Joe".to_string(), 10).await; + let result = bank.send_request(Deposit { who: joe.clone(), amount: 10 }).await.unwrap(); tracing::info!("Deposit result {result:?}"); assert_eq!( result, - Ok(BankOutMessage::Balance { - who: joe.clone(), - amount: 10 - }) + Ok(BankOutMessage::Balance { who: joe.clone(), amount: 10 }) ); // Deposit - let result = Bank::deposit(&mut name_server, "Joe".to_string(), 30).await; + let result = bank.send_request(Deposit { who: joe.clone(), amount: 30 }).await.unwrap(); tracing::info!("Deposit result {result:?}"); assert_eq!( result, - Ok(BankOutMessage::Balance { - who: joe.clone(), - amount: 40 - }) + Ok(BankOutMessage::Balance { who: joe.clone(), amount: 40 }) ); // Withdrawal - let result = Bank::withdraw(&mut name_server, "Joe".to_string(), 15).await; + let result = bank.send_request(Withdraw { who: joe.clone(), amount: 15 }).await.unwrap(); tracing::info!("Withdraw result {result:?}"); assert_eq!( result, - Ok(BankOutMessage::WidrawOk { - who: joe.clone(), - amount: 25 - }) + Ok(BankOutMessage::WithdrawOk { who: joe.clone(), amount: 25 }) ); // Withdrawal with not enough balance - let result = Bank::withdraw(&mut name_server, "Joe".to_string(), 45).await; + let result = bank.send_request(Withdraw { who: joe.clone(), amount: 45 }).await.unwrap(); tracing::info!("Withdraw result {result:?}"); assert_eq!( result, - Err(BankError::InsufficientBalance { - who: joe.clone(), - amount: 25 - }) + Err(BankError::InsufficientBalance { who: joe.clone(), amount: 25 }) ); // Full withdrawal - let result = Bank::withdraw(&mut name_server, "Joe".to_string(), 25).await; + let result = bank.send_request(Withdraw { who: joe.clone(), amount: 25 }).await.unwrap(); tracing::info!("Withdraw result {result:?}"); assert_eq!( result, - Ok(BankOutMessage::WidrawOk { - who: joe, - amount: 0 - }) + Ok(BankOutMessage::WithdrawOk { who: joe, amount: 0 }) ); // Stopping the bank - let result = Bank::stop(&mut name_server).await; + let result = bank.send_request(Stop).await.unwrap(); tracing::info!("Stop result {result:?}"); assert_eq!(result, Ok(BankOutMessage::Stopped)); }) diff --git a/examples/bank/src/messages.rs b/examples/bank/src/messages.rs index d58ae9d..908ea45 100644 --- a/examples/bank/src/messages.rs +++ b/examples/bank/src/messages.rs @@ -1,21 +1,45 @@ -#[derive(Debug, Clone)] -pub enum BankInMessage { - New { who: String }, - Add { who: String, amount: i32 }, - Remove { who: String, amount: i32 }, - Stop, +use spawned_concurrency::message::Message; + +#[derive(Debug)] +pub struct NewAccount { + pub who: String, +} +impl Message for NewAccount { + type Result = Result; +} + +#[derive(Debug)] +pub struct Deposit { + pub who: String, + pub amount: i32, +} +impl Message for Deposit { + type Result = Result; +} + +#[derive(Debug)] +pub struct Withdraw { + pub who: String, + pub amount: i32, +} +impl Message for Withdraw { + type Result = Result; +} + +#[derive(Debug)] +pub struct Stop; +impl Message for Stop { + type Result = Result; } -#[allow(dead_code)] #[derive(Debug, Clone, PartialEq)] pub enum BankOutMessage { Welcome { who: String }, Balance { who: String, amount: i32 }, - WidrawOk { who: String, amount: i32 }, + WithdrawOk { who: String, amount: i32 }, Stopped, } -#[allow(dead_code)] #[derive(Debug, Clone, PartialEq)] pub enum BankError { AlreadyACustomer { who: String }, diff --git a/examples/bank/src/server.rs b/examples/bank/src/server.rs index bd2bfed..7293ca5 100644 --- a/examples/bank/src/server.rs +++ b/examples/bank/src/server.rs @@ -1,18 +1,10 @@ use std::collections::HashMap; -use spawned_concurrency::{ - messages::Unused, - tasks::{ - Actor, ActorRef, - InitResult::{self, Success}, - RequestResponse, - }, -}; +use spawned_concurrency::tasks::{Actor, Context, Handler}; -use crate::messages::{BankError, BankInMessage as InMessage, BankOutMessage as OutMessage}; +use crate::messages::*; -type MsgResult = Result; -type BankHandle = ActorRef; +type MsgResult = Result; pub struct Bank { accounts: HashMap, @@ -26,90 +18,65 @@ impl Bank { } } -impl Bank { - pub async fn stop(server: &mut BankHandle) -> MsgResult { - server - .request(InMessage::Stop) - .await - .unwrap_or(Err(BankError::ServerError)) - } - - pub async fn new_account(server: &mut BankHandle, who: String) -> MsgResult { - server - .request(InMessage::New { who }) - .await - .unwrap_or(Err(BankError::ServerError)) +impl Actor for Bank { + async fn started(&mut self, _ctx: &Context) { + self.accounts.insert("main".to_string(), 1000); } +} - pub async fn deposit(server: &mut BankHandle, who: String, amount: i32) -> MsgResult { - server - .request(InMessage::Add { who, amount }) - .await - .unwrap_or(Err(BankError::ServerError)) +impl Handler for Bank { + async fn handle(&mut self, msg: NewAccount, _ctx: &Context) -> MsgResult { + match self.accounts.get(&msg.who) { + Some(_) => Err(BankError::AlreadyACustomer { who: msg.who }), + None => { + self.accounts.insert(msg.who.clone(), 0); + Ok(BankOutMessage::Welcome { who: msg.who }) + } + } } +} - pub async fn withdraw(server: &mut BankHandle, who: String, amount: i32) -> MsgResult { - server - .request(InMessage::Remove { who, amount }) - .await - .unwrap_or(Err(BankError::ServerError)) +impl Handler for Bank { + async fn handle(&mut self, msg: Deposit, _ctx: &Context) -> MsgResult { + match self.accounts.get(&msg.who) { + Some(current) => { + let new_amount = current + msg.amount; + self.accounts.insert(msg.who.clone(), new_amount); + Ok(BankOutMessage::Balance { + who: msg.who, + amount: new_amount, + }) + } + None => Err(BankError::NotACustomer { who: msg.who }), + } } } -impl Actor for Bank { - type Request = InMessage; - type Message = Unused; - type Reply = MsgResult; - type Error = BankError; - - // Initializing "main" account with 1000 in balance to test init() callback. - async fn init(mut self, _handle: &ActorRef) -> Result, Self::Error> { - self.accounts.insert("main".to_string(), 1000); - Ok(Success(self)) +impl Handler for Bank { + async fn handle(&mut self, msg: Withdraw, _ctx: &Context) -> MsgResult { + match self.accounts.get(&msg.who) { + Some(¤t) if current < msg.amount => { + Err(BankError::InsufficientBalance { + who: msg.who, + amount: current, + }) + } + Some(¤t) => { + let new_amount = current - msg.amount; + self.accounts.insert(msg.who.clone(), new_amount); + Ok(BankOutMessage::WithdrawOk { + who: msg.who, + amount: new_amount, + }) + } + None => Err(BankError::NotACustomer { who: msg.who }), + } } +} - async fn handle_request( - &mut self, - message: Self::Request, - _handle: &BankHandle, - ) -> RequestResponse { - match message.clone() { - Self::Request::New { who } => match self.accounts.get(&who) { - Some(_amount) => RequestResponse::Reply(Err(BankError::AlreadyACustomer { who })), - None => { - self.accounts.insert(who.clone(), 0); - RequestResponse::Reply(Ok(OutMessage::Welcome { who })) - } - }, - Self::Request::Add { who, amount } => match self.accounts.get(&who) { - Some(current) => { - let new_amount = current + amount; - self.accounts.insert(who.clone(), new_amount); - RequestResponse::Reply(Ok(OutMessage::Balance { - who, - amount: new_amount, - })) - } - None => RequestResponse::Reply(Err(BankError::NotACustomer { who })), - }, - Self::Request::Remove { who, amount } => match self.accounts.get(&who) { - Some(¤t) => match current < amount { - true => RequestResponse::Reply(Err(BankError::InsufficientBalance { - who, - amount: current, - })), - false => { - let new_amount = current - amount; - self.accounts.insert(who.clone(), new_amount); - RequestResponse::Reply(Ok(OutMessage::WidrawOk { - who, - amount: new_amount, - })) - } - }, - None => RequestResponse::Reply(Err(BankError::NotACustomer { who })), - }, - Self::Request::Stop => RequestResponse::Stop(Ok(OutMessage::Stopped)), - } +impl Handler for Bank { + async fn handle(&mut self, _msg: Stop, ctx: &Context) -> MsgResult { + ctx.stop(); + Ok(BankOutMessage::Stopped) } } diff --git a/examples/bank_threads/src/main.rs b/examples/bank_threads/src/main.rs index 9b89c54..5878516 100644 --- a/examples/bank_threads/src/main.rs +++ b/examples/bank_threads/src/main.rs @@ -1,43 +1,26 @@ -//! Simple example to test concurrency/Process abstraction. +//! Bank example using threads Actor with the new Handler API. //! //! Based on Joe's Armstrong book: Programming Erlang, Second edition //! Section 22.1 - The Road to the Generic Server -//! -//! Erlang usage example: -//! 1> my_bank:start(). -//! {ok,<0.33.0>} -//! 2> my_bank:deposit("joe", 10). -//! not_a_customer -//! 3> my_bank:new_account("joe"). -//! {welcome,"joe"} -//! 4> my_bank:deposit("joe", 10). -//! {thanks,"joe",your_balance_is,10} -//! 5> my_bank:deposit("joe", 30). -//! {thanks,"joe",your_balance_is,40} -//! 6> my_bank:withdraw("joe", 15). -//! {thanks,"joe",your_balance_is,25} -//! 7> my_bank:withdraw("joe", 45). -//! {sorry,"joe",you_only_have,25,in_the_bank mod messages; mod server; -use messages::{BankError, BankOutMessage}; +use messages::*; use server::Bank; -use spawned_concurrency::threads::Actor as _; +use spawned_concurrency::threads::ActorStart as _; use spawned_rt::threads as rt; fn main() { rt::run(|| { - // Starting the bank - let mut name_server = Bank::new().start(); + let bank = Bank::new().start(); // Testing initial balance for "main" account - let result = Bank::withdraw(&mut name_server, "main".to_string(), 15); + let result = bank.send_request(Withdraw { who: "main".into(), amount: 15 }).unwrap(); tracing::info!("Withdraw result {result:?}"); assert_eq!( result, - Ok(BankOutMessage::WidrawOk { + Ok(BankOutMessage::WithdrawOk { who: "main".to_string(), amount: 985 }) @@ -45,73 +28,58 @@ fn main() { let joe = "Joe".to_string(); - // Error on deposit for an unexistent account - let result = Bank::deposit(&mut name_server, joe.clone(), 10); + // Error on deposit for a non-existent account + let result = bank.send_request(Deposit { who: joe.clone(), amount: 10 }).unwrap(); tracing::info!("Deposit result {result:?}"); assert_eq!(result, Err(BankError::NotACustomer { who: joe.clone() })); // Account creation - let result = Bank::new_account(&mut name_server, "Joe".to_string()); + let result = bank.send_request(NewAccount { who: joe.clone() }).unwrap(); tracing::info!("New account result {result:?}"); assert_eq!(result, Ok(BankOutMessage::Welcome { who: joe.clone() })); // Deposit - let result = Bank::deposit(&mut name_server, "Joe".to_string(), 10); + let result = bank.send_request(Deposit { who: joe.clone(), amount: 10 }).unwrap(); tracing::info!("Deposit result {result:?}"); assert_eq!( result, - Ok(BankOutMessage::Balance { - who: joe.clone(), - amount: 10 - }) + Ok(BankOutMessage::Balance { who: joe.clone(), amount: 10 }) ); // Deposit - let result = Bank::deposit(&mut name_server, "Joe".to_string(), 30); + let result = bank.send_request(Deposit { who: joe.clone(), amount: 30 }).unwrap(); tracing::info!("Deposit result {result:?}"); assert_eq!( result, - Ok(BankOutMessage::Balance { - who: joe.clone(), - amount: 40 - }) + Ok(BankOutMessage::Balance { who: joe.clone(), amount: 40 }) ); // Withdrawal - let result = Bank::withdraw(&mut name_server, "Joe".to_string(), 15); + let result = bank.send_request(Withdraw { who: joe.clone(), amount: 15 }).unwrap(); tracing::info!("Withdraw result {result:?}"); assert_eq!( result, - Ok(BankOutMessage::WidrawOk { - who: joe.clone(), - amount: 25 - }) + Ok(BankOutMessage::WithdrawOk { who: joe.clone(), amount: 25 }) ); // Withdrawal with not enough balance - let result = Bank::withdraw(&mut name_server, "Joe".to_string(), 45); + let result = bank.send_request(Withdraw { who: joe.clone(), amount: 45 }).unwrap(); tracing::info!("Withdraw result {result:?}"); assert_eq!( result, - Err(BankError::InsufficientBalance { - who: joe.clone(), - amount: 25 - }) + Err(BankError::InsufficientBalance { who: joe.clone(), amount: 25 }) ); // Full withdrawal - let result = Bank::withdraw(&mut name_server, "Joe".to_string(), 25); + let result = bank.send_request(Withdraw { who: joe.clone(), amount: 25 }).unwrap(); tracing::info!("Withdraw result {result:?}"); assert_eq!( result, - Ok(BankOutMessage::WidrawOk { - who: joe, - amount: 0 - }) + Ok(BankOutMessage::WithdrawOk { who: joe, amount: 0 }) ); // Stopping the bank - let result = Bank::stop(&mut name_server); + let result = bank.send_request(Stop).unwrap(); tracing::info!("Stop result {result:?}"); assert_eq!(result, Ok(BankOutMessage::Stopped)); }) diff --git a/examples/bank_threads/src/messages.rs b/examples/bank_threads/src/messages.rs index d58ae9d..908ea45 100644 --- a/examples/bank_threads/src/messages.rs +++ b/examples/bank_threads/src/messages.rs @@ -1,21 +1,45 @@ -#[derive(Debug, Clone)] -pub enum BankInMessage { - New { who: String }, - Add { who: String, amount: i32 }, - Remove { who: String, amount: i32 }, - Stop, +use spawned_concurrency::message::Message; + +#[derive(Debug)] +pub struct NewAccount { + pub who: String, +} +impl Message for NewAccount { + type Result = Result; +} + +#[derive(Debug)] +pub struct Deposit { + pub who: String, + pub amount: i32, +} +impl Message for Deposit { + type Result = Result; +} + +#[derive(Debug)] +pub struct Withdraw { + pub who: String, + pub amount: i32, +} +impl Message for Withdraw { + type Result = Result; +} + +#[derive(Debug)] +pub struct Stop; +impl Message for Stop { + type Result = Result; } -#[allow(dead_code)] #[derive(Debug, Clone, PartialEq)] pub enum BankOutMessage { Welcome { who: String }, Balance { who: String, amount: i32 }, - WidrawOk { who: String, amount: i32 }, + WithdrawOk { who: String, amount: i32 }, Stopped, } -#[allow(dead_code)] #[derive(Debug, Clone, PartialEq)] pub enum BankError { AlreadyACustomer { who: String }, diff --git a/examples/bank_threads/src/server.rs b/examples/bank_threads/src/server.rs index 5edf5f7..f0cce1f 100644 --- a/examples/bank_threads/src/server.rs +++ b/examples/bank_threads/src/server.rs @@ -1,16 +1,11 @@ use std::collections::HashMap; -use spawned_concurrency::{ - messages::Unused, - threads::{Actor, ActorRef, InitResult, RequestResponse}, -}; +use spawned_concurrency::threads::{Actor, Context, Handler}; -use crate::messages::{BankError, BankInMessage as InMessage, BankOutMessage as OutMessage}; +use crate::messages::*; -type MsgResult = Result; -type BankHandle = ActorRef; +type MsgResult = Result; -#[derive(Clone)] pub struct Bank { accounts: HashMap, } @@ -23,86 +18,65 @@ impl Bank { } } -impl Bank { - pub fn stop(server: &mut BankHandle) -> MsgResult { - server - .request(InMessage::Stop) - .unwrap_or(Err(BankError::ServerError)) - } - - pub fn new_account(server: &mut BankHandle, who: String) -> MsgResult { - server - .request(InMessage::New { who }) - .unwrap_or(Err(BankError::ServerError)) +impl Actor for Bank { + fn started(&mut self, _ctx: &Context) { + self.accounts.insert("main".to_string(), 1000); } +} - pub fn deposit(server: &mut BankHandle, who: String, amount: i32) -> MsgResult { - server - .request(InMessage::Add { who, amount }) - .unwrap_or(Err(BankError::ServerError)) +impl Handler for Bank { + fn handle(&mut self, msg: NewAccount, _ctx: &Context) -> MsgResult { + match self.accounts.get(&msg.who) { + Some(_) => Err(BankError::AlreadyACustomer { who: msg.who }), + None => { + self.accounts.insert(msg.who.clone(), 0); + Ok(BankOutMessage::Welcome { who: msg.who }) + } + } } +} - pub fn withdraw(server: &mut BankHandle, who: String, amount: i32) -> MsgResult { - server - .request(InMessage::Remove { who, amount }) - .unwrap_or(Err(BankError::ServerError)) +impl Handler for Bank { + fn handle(&mut self, msg: Deposit, _ctx: &Context) -> MsgResult { + match self.accounts.get(&msg.who) { + Some(current) => { + let new_amount = current + msg.amount; + self.accounts.insert(msg.who.clone(), new_amount); + Ok(BankOutMessage::Balance { + who: msg.who, + amount: new_amount, + }) + } + None => Err(BankError::NotACustomer { who: msg.who }), + } } } -impl Actor for Bank { - type Request = InMessage; - type Message = Unused; - type Reply = MsgResult; - type Error = BankError; - - // Initializing "main" account with 1000 in balance to test init() callback. - fn init(mut self, _handle: &ActorRef) -> Result, Self::Error> { - self.accounts.insert("main".to_string(), 1000); - Ok(InitResult::Success(self)) +impl Handler for Bank { + fn handle(&mut self, msg: Withdraw, _ctx: &Context) -> MsgResult { + match self.accounts.get(&msg.who) { + Some(¤t) if current < msg.amount => { + Err(BankError::InsufficientBalance { + who: msg.who, + amount: current, + }) + } + Some(¤t) => { + let new_amount = current - msg.amount; + self.accounts.insert(msg.who.clone(), new_amount); + Ok(BankOutMessage::WithdrawOk { + who: msg.who, + amount: new_amount, + }) + } + None => Err(BankError::NotACustomer { who: msg.who }), + } } +} - fn handle_request( - &mut self, - message: Self::Request, - _handle: &BankHandle, - ) -> RequestResponse { - match message.clone() { - Self::Request::New { who } => match self.accounts.get(&who) { - Some(_amount) => RequestResponse::Reply(Err(BankError::AlreadyACustomer { who })), - None => { - self.accounts.insert(who.clone(), 0); - RequestResponse::Reply(Ok(OutMessage::Welcome { who })) - } - }, - Self::Request::Add { who, amount } => match self.accounts.get(&who) { - Some(current) => { - let new_amount = current + amount; - self.accounts.insert(who.clone(), new_amount); - RequestResponse::Reply(Ok(OutMessage::Balance { - who, - amount: new_amount, - })) - } - None => RequestResponse::Reply(Err(BankError::NotACustomer { who })), - }, - Self::Request::Remove { who, amount } => match self.accounts.get(&who) { - Some(¤t) => match current < amount { - true => RequestResponse::Reply(Err(BankError::InsufficientBalance { - who, - amount: current, - })), - false => { - let new_amount = current - amount; - self.accounts.insert(who.clone(), new_amount); - RequestResponse::Reply(Ok(OutMessage::WidrawOk { - who, - amount: new_amount, - })) - } - }, - None => RequestResponse::Reply(Err(BankError::NotACustomer { who })), - }, - Self::Request::Stop => RequestResponse::Stop(Ok(OutMessage::Stopped)), - } +impl Handler for Bank { + fn handle(&mut self, _msg: Stop, ctx: &Context) -> MsgResult { + ctx.stop(); + Ok(BankOutMessage::Stopped) } } diff --git a/examples/blocking_genserver/main.rs b/examples/blocking_genserver/main.rs index f1ec820..99694d4 100644 --- a/examples/blocking_genserver/main.rs +++ b/examples/blocking_genserver/main.rs @@ -1,46 +1,42 @@ +use spawned_concurrency::message::Message; +use spawned_concurrency::tasks::{ + send_after, Actor, ActorStart as _, Backend, Context, Handler, +}; use spawned_rt::tasks as rt; use std::time::Duration; use std::{process::exit, thread}; -use spawned_concurrency::tasks::{ - Actor, ActorRef, Backend, MessageResponse, RequestResponse, send_after, -}; - // We test a scenario with a badly behaved task struct BadlyBehavedTask; -impl BadlyBehavedTask { - pub fn new() -> Self { - BadlyBehavedTask - } +#[derive(Debug)] +pub struct DoBlock; +impl Message for DoBlock { + type Result = (); } -#[derive(Clone)] -pub enum InMessage { - GetCount, - Stop, +#[derive(Debug)] +pub struct GetCount; +impl Message for GetCount { + type Result = u64; } -#[derive(Clone)] -pub enum OutMsg { - Count(u64), +#[derive(Debug)] +pub struct StopActor; +impl Message for StopActor { + type Result = u64; } -impl Actor for BadlyBehavedTask { - type Request = InMessage; - type Message = (); - type Reply = (); - type Error = (); +#[derive(Debug)] +pub struct Tick; +impl Message for Tick { + type Result = (); +} - async fn handle_request( - &mut self, - _: Self::Request, - _: &ActorRef, - ) -> RequestResponse { - RequestResponse::Stop(()) - } +impl Actor for BadlyBehavedTask {} - async fn handle_message(&mut self, _: Self::Message, _: &ActorRef) -> MessageResponse { +impl Handler for BadlyBehavedTask { + async fn handle(&mut self, _msg: DoBlock, _ctx: &Context) { rt::sleep(Duration::from_millis(20)).await; loop { println!("{:?}: bad still alive", thread::current().id()); @@ -53,43 +49,26 @@ struct WellBehavedTask { count: u64, } -impl WellBehavedTask { - pub fn new(initial_count: u64) -> Self { - WellBehavedTask { - count: initial_count, - } +impl Actor for WellBehavedTask {} + +impl Handler for WellBehavedTask { + async fn handle(&mut self, _msg: GetCount, _ctx: &Context) -> u64 { + self.count } } -impl Actor for WellBehavedTask { - type Request = InMessage; - type Message = (); - type Reply = OutMsg; - type Error = (); - - async fn handle_request( - &mut self, - message: Self::Request, - _: &ActorRef, - ) -> RequestResponse { - match message { - InMessage::GetCount => { - let count = self.count; - RequestResponse::Reply(OutMsg::Count(count)) - } - InMessage::Stop => RequestResponse::Stop(OutMsg::Count(self.count)), - } +impl Handler for WellBehavedTask { + async fn handle(&mut self, _msg: StopActor, ctx: &Context) -> u64 { + ctx.stop(); + self.count } +} - async fn handle_message( - &mut self, - _: Self::Message, - handle: &ActorRef, - ) -> MessageResponse { +impl Handler for WellBehavedTask { + async fn handle(&mut self, _msg: Tick, ctx: &Context) { self.count += 1; println!("{:?}: good still alive", thread::current().id()); - send_after(Duration::from_millis(100), handle.to_owned(), ()); - MessageResponse::NoReply + send_after(Duration::from_millis(100), ctx.clone(), Tick); } } @@ -99,20 +78,16 @@ impl Actor for WellBehavedTask { pub fn main() { rt::run(async move { // If we change BadlyBehavedTask to Backend::Async instead, it can stop the entire program - let mut badboy = BadlyBehavedTask::new().start_with_backend(Backend::Thread); - let _ = badboy.send(()).await; - let mut goodboy = WellBehavedTask::new(0).start(); - let _ = goodboy.send(()).await; + let badboy = BadlyBehavedTask.start_with_backend(Backend::Thread); + let _ = badboy.send(DoBlock); + let goodboy = WellBehavedTask { count: 0 }.start(); + let _ = goodboy.send(Tick); rt::sleep(Duration::from_secs(1)).await; - let count = goodboy.request(InMessage::GetCount).await.unwrap(); + let count = goodboy.send_request(GetCount).await.unwrap(); - match count { - OutMsg::Count(num) => { - assert!(num == 10); - } - } + assert!(count == 10); - goodboy.request(InMessage::Stop).await.unwrap(); + goodboy.send_request(StopActor).await.unwrap(); exit(0); }) } diff --git a/examples/busy_genserver_warning/main.rs b/examples/busy_genserver_warning/main.rs index cf83573..f354ebb 100644 --- a/examples/busy_genserver_warning/main.rs +++ b/examples/busy_genserver_warning/main.rs @@ -1,56 +1,29 @@ +use spawned_concurrency::message::Message; +use spawned_concurrency::tasks::{Actor, ActorStart as _, Context, Handler}; use spawned_rt::tasks as rt; use std::time::Duration; use std::{process::exit, thread}; use tracing::info; -use spawned_concurrency::tasks::{Actor, ActorRef, MessageResponse, RequestResponse}; - // We test a scenario with a badly behaved task struct BusyWorker; -impl BusyWorker { - pub fn new() -> Self { - BusyWorker - } -} - -#[derive(Clone)] -pub enum InMessage { - GetCount, - Stop, +#[derive(Debug)] +pub struct DoWork; +impl Message for DoWork { + type Result = (); } -#[derive(Clone)] -pub enum OutMsg { - Count(u64), -} - -impl Actor for BusyWorker { - type Request = InMessage; - type Message = (); - type Reply = (); - type Error = (); - - async fn handle_request( - &mut self, - _: Self::Request, - _: &ActorRef, - ) -> RequestResponse { - RequestResponse::Stop(()) - } +impl Actor for BusyWorker {} - async fn handle_message( - &mut self, - _: Self::Message, - handle: &ActorRef, - ) -> MessageResponse { +impl Handler for BusyWorker { + async fn handle(&mut self, _msg: DoWork, ctx: &Context) { info!(taskid = ?rt::task_id(), "sleeping"); thread::sleep(Duration::from_millis(542)); - handle.clone().send(()).await.unwrap(); + let _ = ctx.send(DoWork); // This sleep is needed to yield control to the runtime. // If not, the future never returns and the warning isn't emitted. rt::sleep(Duration::from_millis(0)).await; - MessageResponse::NoReply } } @@ -64,8 +37,8 @@ impl Actor for BusyWorker { pub fn main() { rt::run(async move { // If we change BusyWorker to Backend::Blocking instead, it won't print the warning - let mut badboy = BusyWorker::new().start(); - let _ = badboy.send(()).await; + let badboy = BusyWorker.start(); + let _ = badboy.send(DoWork); rt::sleep(Duration::from_secs(5)).await; exit(0); diff --git a/examples/chat_room/Cargo.toml b/examples/chat_room/Cargo.toml new file mode 100644 index 0000000..4fb3882 --- /dev/null +++ b/examples/chat_room/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "chat_room" +version = "0.1.0" +edition = "2021" + +[dependencies] +spawned-rt = { workspace = true } +spawned-concurrency = { workspace = true } +tracing = { workspace = true } + +[[bin]] +name = "chat_room" +path = "src/main.rs" diff --git a/examples/chat_room/src/main.rs b/examples/chat_room/src/main.rs new file mode 100644 index 0000000..7b565f4 --- /dev/null +++ b/examples/chat_room/src/main.rs @@ -0,0 +1,71 @@ +//! Chat room example demonstrating how protocol traits solve circular dependencies. +//! +//! The problem: +//! - `ChatRoom` needs to send `Deliver` to each `User` +//! - `User` needs to send `Say` to the `ChatRoom` +//! - With concrete types, `room.rs` would import `User` and +//! `user.rs` would import `ChatRoom` — circular module dependency. +//! +//! The solution: +//! - `ChatRoom` holds `Arc` — doesn't know about `User` +//! - `User` holds `Arc` — doesn't know about `ChatRoom` +//! - Both modules only depend on the shared `protocols` module. +//! +//! Message flow: +//! main -> SayToRoom -> User -> Say -> ChatRoom -> Deliver -> User + +mod messages; +mod protocols; +mod room; +mod user; + +use messages::SayToRoom; +use protocols::ChatBroadcaster; +use room::ChatRoom; +use spawned_concurrency::tasks::ActorStart as _; +use spawned_rt::tasks as rt; +use std::sync::Arc; +use std::time::Duration; +use user::User; + +fn main() { + rt::run(async { + let room = ChatRoom::new().start(); + + let alice = User { + name: "Alice".into(), + room: Arc::new(room.clone()), + } + .start(); + + let bob = User { + name: "Bob".into(), + room: Arc::new(room.clone()), + } + .start(); + + // Register users via protocol trait — room stores Arc + room.add_member("Alice".into(), Arc::new(alice.clone())).unwrap(); + room.add_member("Bob".into(), Arc::new(bob.clone())).unwrap(); + // Small delay to let join messages be processed + rt::sleep(Duration::from_millis(10)).await; + + // Alice speaks: main -> alice (SayToRoom) -> room (Say) -> bob (Deliver) + alice + .send_request(SayToRoom { + text: "Hello everyone!".into(), + }) + .await + .unwrap(); + + // Bob replies: main -> bob (SayToRoom) -> room (Say) -> alice (Deliver) + bob.send_request(SayToRoom { + text: "Hey Alice!".into(), + }) + .await + .unwrap(); + + // Let deliveries propagate + rt::sleep(Duration::from_millis(50)).await; + }) +} diff --git a/examples/chat_room/src/messages.rs b/examples/chat_room/src/messages.rs new file mode 100644 index 0000000..2dc23d3 --- /dev/null +++ b/examples/chat_room/src/messages.rs @@ -0,0 +1,7 @@ +use spawned_concurrency::messages; + +messages! { + Say { from: String, text: String } -> (); + SayToRoom { text: String } -> (); + Deliver { from: String, text: String } -> (); +} diff --git a/examples/chat_room/src/protocols.rs b/examples/chat_room/src/protocols.rs new file mode 100644 index 0000000..823058a --- /dev/null +++ b/examples/chat_room/src/protocols.rs @@ -0,0 +1,17 @@ +//! Protocol traits — cross-actor contracts. +//! +//! Neither `ChatRoom` nor `User` appears here. The circular dependency +//! is broken because each actor holds an `Arc` +//! instead of a concrete `ActorRef`. + +use spawned_concurrency::error::ActorError; +use std::sync::Arc; + +pub trait ChatParticipant: Send + Sync { + fn deliver(&self, from: String, text: String) -> Result<(), ActorError>; +} + +pub trait ChatBroadcaster: Send + Sync { + fn say(&self, from: String, text: String) -> Result<(), ActorError>; + fn add_member(&self, name: String, inbox: Arc) -> Result<(), ActorError>; +} diff --git a/examples/chat_room/src/room.rs b/examples/chat_room/src/room.rs new file mode 100644 index 0000000..93f405d --- /dev/null +++ b/examples/chat_room/src/room.rs @@ -0,0 +1,71 @@ +//! ChatRoom actor — knows about `Say`, `Join`, and `ChatParticipant` trait. +//! Does NOT know about the `User` type. + +use spawned_concurrency::error::ActorError; +use spawned_concurrency::message::Message; +use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; +use std::sync::Arc; + +use crate::messages::Say; +use crate::protocols::{ChatBroadcaster, ChatParticipant}; + +// Join carries an Arc, so we define it here (not via macro) +pub struct Join { + pub name: String, + pub inbox: Arc, +} + +impl Message for Join { + type Result = (); +} + +impl std::fmt::Debug for Join { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Join").field("name", &self.name).finish() + } +} + +pub struct ChatRoom { + members: Vec<(String, Arc)>, +} + +impl ChatRoom { + pub fn new() -> Self { + Self { + members: Vec::new(), + } + } +} + +impl Actor for ChatRoom {} + +impl Handler for ChatRoom { + async fn handle(&mut self, msg: Join, _ctx: &Context) { + tracing::info!("[room] {} joined", msg.name); + self.members.push((msg.name, msg.inbox)); + } +} + +impl Handler for ChatRoom { + async fn handle(&mut self, msg: Say, _ctx: &Context) { + tracing::info!("[room] {} says: {}", msg.from, msg.text); + + // Broadcast to all members except the sender + for (name, inbox) in &self.members { + if *name != msg.from { + let _ = inbox.deliver(msg.from.clone(), msg.text.clone()); + } + } + } +} + +// Bridge: ActorRef implements ChatBroadcaster +impl ChatBroadcaster for ActorRef { + fn say(&self, from: String, text: String) -> Result<(), ActorError> { + self.send(Say { from, text }) + } + + fn add_member(&self, name: String, inbox: Arc) -> Result<(), ActorError> { + self.send(Join { name, inbox }) + } +} diff --git a/examples/chat_room/src/user.rs b/examples/chat_room/src/user.rs new file mode 100644 index 0000000..ab9a888 --- /dev/null +++ b/examples/chat_room/src/user.rs @@ -0,0 +1,36 @@ +//! User actor — knows about `SayToRoom`, `Deliver`, and `ChatBroadcaster` trait. +//! Does NOT know about the `ChatRoom` type. + +use spawned_concurrency::error::ActorError; +use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; +use std::sync::Arc; + +use crate::messages::{Deliver, SayToRoom}; +use crate::protocols::{ChatBroadcaster, ChatParticipant}; + +pub struct User { + pub name: String, + pub room: Arc, +} + +impl Actor for User {} + +impl Handler for User { + async fn handle(&mut self, msg: SayToRoom, _ctx: &Context) { + // Forward to the room via Arc — no ChatRoom type needed + let _ = self.room.say(self.name.clone(), msg.text); + } +} + +impl Handler for User { + async fn handle(&mut self, msg: Deliver, _ctx: &Context) { + tracing::info!("[{}] got message from {}: {}", self.name, msg.from, msg.text); + } +} + +// Bridge: ActorRef implements ChatParticipant +impl ChatParticipant for ActorRef { + fn deliver(&self, from: String, text: String) -> Result<(), ActorError> { + self.send(Deliver { from, text }) + } +} diff --git a/examples/name_server/src/main.rs b/examples/name_server/src/main.rs index 85fab9e..4daca79 100644 --- a/examples/name_server/src/main.rs +++ b/examples/name_server/src/main.rs @@ -1,44 +1,31 @@ -//! Simple example to test concurrency/Process abstraction. +//! Name server example using the new Handler API. //! //! Based on Joe's Armstrong book: Programming Erlang, Second edition //! Section 22.1 - The Road to the Generic Server -//! -//! Erlang usage example: -//! 1> server1:start(name_server, name_server). -//! true -//! 2> name_server:add(joe, "at home"). -//! ok -//! 3> name_server:find(joe). -//! {ok,"at home"} mod messages; mod server; -use messages::NameServerOutMessage; +use messages::*; use server::NameServer; -use spawned_concurrency::tasks::Actor as _; +use spawned_concurrency::tasks::ActorStart as _; use spawned_rt::tasks as rt; fn main() { rt::run(async { - let mut name_server = NameServer::new().start(); + let ns = NameServer::new().start(); - let result = - NameServer::add(&mut name_server, "Joe".to_string(), "At Home".to_string()).await; - tracing::info!("Storing value result: {result:?}"); - assert_eq!(result, NameServerOutMessage::Ok); + ns.send_request(Add { key: "Joe".into(), value: "At Home".into() }).await.unwrap(); - let result = NameServer::find(&mut name_server, "Joe".to_string()).await; + let result = ns.send_request(Find { key: "Joe".into() }).await.unwrap(); tracing::info!("Retrieving value result: {result:?}"); assert_eq!( result, - NameServerOutMessage::Found { - value: "At Home".to_string() - } + FindResult::Found { value: "At Home".to_string() } ); - let result = NameServer::find(&mut name_server, "Bob".to_string()).await; + let result = ns.send_request(Find { key: "Bob".into() }).await.unwrap(); tracing::info!("Retrieving value result: {result:?}"); - assert_eq!(result, NameServerOutMessage::NotFound); + assert_eq!(result, FindResult::NotFound); }) } diff --git a/examples/name_server/src/messages.rs b/examples/name_server/src/messages.rs index b011cb2..3b58499 100644 --- a/examples/name_server/src/messages.rs +++ b/examples/name_server/src/messages.rs @@ -1,14 +1,24 @@ -#[derive(Debug, Clone)] -pub enum NameServerInMessage { - Add { key: String, value: String }, - Find { key: String }, +use spawned_concurrency::message::Message; + +#[derive(Debug)] +pub struct Add { + pub key: String, + pub value: String, +} +impl Message for Add { + type Result = (); +} + +#[derive(Debug)] +pub struct Find { + pub key: String, +} +impl Message for Find { + type Result = FindResult; } -#[allow(dead_code)] #[derive(Debug, Clone, PartialEq)] -pub enum NameServerOutMessage { - Ok, +pub enum FindResult { Found { value: String }, NotFound, - Error, } diff --git a/examples/name_server/src/server.rs b/examples/name_server/src/server.rs index 59a5c96..5f18b23 100644 --- a/examples/name_server/src/server.rs +++ b/examples/name_server/src/server.rs @@ -1,13 +1,8 @@ use std::collections::HashMap; -use spawned_concurrency::{ - messages::Unused, - tasks::{Actor, ActorRef, RequestResponse}, -}; +use spawned_concurrency::tasks::{Actor, Context, Handler}; -use crate::messages::{NameServerInMessage as InMessage, NameServerOutMessage as OutMessage}; - -type NameServerHandle = ActorRef; +use crate::messages::*; pub struct NameServer { inner: HashMap, @@ -21,45 +16,19 @@ impl NameServer { } } -impl NameServer { - pub async fn add(server: &mut NameServerHandle, key: String, value: String) -> OutMessage { - match server.request(InMessage::Add { key, value }).await { - Ok(_) => OutMessage::Ok, - Err(_) => OutMessage::Error, - } - } +impl Actor for NameServer {} - pub async fn find(server: &mut NameServerHandle, key: String) -> OutMessage { - server - .request(InMessage::Find { key }) - .await - .unwrap_or(OutMessage::Error) +impl Handler for NameServer { + async fn handle(&mut self, msg: Add, _ctx: &Context) { + self.inner.insert(msg.key, msg.value); } } -impl Actor for NameServer { - type Request = InMessage; - type Message = Unused; - type Reply = OutMessage; - type Error = std::fmt::Error; - - async fn handle_request( - &mut self, - message: Self::Request, - _handle: &NameServerHandle, - ) -> RequestResponse { - match message.clone() { - Self::Request::Add { key, value } => { - self.inner.insert(key, value); - RequestResponse::Reply(Self::Reply::Ok) - } - Self::Request::Find { key } => match self.inner.get(&key) { - Some(result) => { - let value = result.to_string(); - RequestResponse::Reply(Self::Reply::Found { value }) - } - None => RequestResponse::Reply(Self::Reply::NotFound), - }, +impl Handler for NameServer { + async fn handle(&mut self, msg: Find, _ctx: &Context) -> FindResult { + match self.inner.get(&msg.key) { + Some(value) => FindResult::Found { value: value.clone() }, + None => FindResult::NotFound, } } } diff --git a/examples/ping_pong/src/consumer.rs b/examples/ping_pong/src/consumer.rs index 8ead269..f5e9cb8 100644 --- a/examples/ping_pong/src/consumer.rs +++ b/examples/ping_pong/src/consumer.rs @@ -1,26 +1,17 @@ -use spawned_concurrency::tasks::{self as concurrency, Process, ProcessInfo}; -use spawned_rt::tasks::mpsc::Sender; +use spawned_concurrency::tasks::{Actor, Context, Handler}; -use crate::messages::Message; +use crate::messages::Ping; +use crate::protocols::PongInbox; -pub struct Consumer {} - -impl Consumer { - pub async fn spawn_new() -> ProcessInfo { - Self {}.spawn().await - } +pub struct Consumer { + pub producer: PongInbox, } -impl Process for Consumer { - async fn handle(&mut self, message: Message, _tx: &Sender) -> Message { - tracing::info!("Consumer received {message:?}"); - match message.clone() { - Message::Ping { from } => { - tracing::info!("Consumer sent Pong"); - concurrency::send(&from, Message::Pong); - } - Message::Pong => (), - }; - message +impl Actor for Consumer {} + +impl Handler for Consumer { + async fn handle(&mut self, _msg: Ping, _ctx: &Context) { + tracing::info!("Consumer received Ping, sending Pong"); + let _ = self.producer.pong(); } } diff --git a/examples/ping_pong/src/main.rs b/examples/ping_pong/src/main.rs index 1b1599b..ed45ef3 100644 --- a/examples/ping_pong/src/main.rs +++ b/examples/ping_pong/src/main.rs @@ -1,55 +1,42 @@ -//! Simple example to test concurrency/Process abstraction +//! Ping-pong example demonstrating bidirectional communication +//! between actors using protocol traits for type-erased messaging. //! -//! Based on an Erlang example: -//! -module(ping). -//! -//! -export([ping/1, pong/0, spawn_consumer/0, spawn_producer/1, start/0]). -//! -//! ping(Pid) -> -//! Pid ! {ping, self()}, -//! receive -//! pong -> -//! io:format("Received pong!!!~n"), -//! ping(Pid) -//! end. -//! -//! pong() -> -//! receive -//! {ping, Pid} -> -//! io:format("Received ping!!~n"), -//! Pid ! pong, -//! pong(); -//! die -> -//! ok -//! end. -//! -//! spawn_consumer() -> -//! spawn(ping, pong, []). -//! -//! spawn_producer(Pid) -> -//! spawn(ping, ping, [Pid]). -//! -//! start() -> -//! Pid = spawn_consumer(), -//! spawn_producer(Pid). +//! This solves the circular dependency problem: Consumer and Producer +//! don't need to know each other's concrete types — they only know +//! about the protocol traits they implement (PingReceiver and PongReceiver). mod consumer; mod messages; mod producer; - -use std::{thread, time::Duration}; +mod protocols; use consumer::Consumer; -use producer::Producer; +use producer::{Producer, SetConsumer}; +use spawned_concurrency::tasks::ActorStart as _; use spawned_rt::tasks as rt; +use std::sync::Arc; +use std::time::Duration; fn main() { rt::run(async { - let consumer = Consumer::spawn_new().await; + // Start the producer first + let producer = Producer { consumer: None }.start(); + + // Start the consumer with an Arc pointing to the producer + let consumer = Consumer { + producer: Arc::new(producer.clone()), + } + .start(); + + // Wire up the producer with the consumer's Arc + producer + .send(SetConsumer(Arc::new(consumer.clone()))) + .unwrap(); - Producer::spawn_new(consumer.sender()).await; + // Kick off the ping-pong loop + consumer.send(messages::Ping).unwrap(); - // giving it some time before ending - thread::sleep(Duration::from_millis(1)); + // Let them ping-pong for a bit + rt::sleep(Duration::from_millis(1)).await; }) } diff --git a/examples/ping_pong/src/messages.rs b/examples/ping_pong/src/messages.rs index a22ae6c..850fd2b 100644 --- a/examples/ping_pong/src/messages.rs +++ b/examples/ping_pong/src/messages.rs @@ -1,7 +1,13 @@ -use spawned_rt::tasks::mpsc::Sender; +use spawned_concurrency::message::Message; -#[derive(Debug, Clone)] -pub enum Message { - Ping { from: Sender }, - Pong, +#[derive(Debug)] +pub struct Ping; +impl Message for Ping { + type Result = (); +} + +#[derive(Debug)] +pub struct Pong; +impl Message for Pong { + type Result = (); } diff --git a/examples/ping_pong/src/producer.rs b/examples/ping_pong/src/producer.rs index 71829a1..4514c7f 100644 --- a/examples/ping_pong/src/producer.rs +++ b/examples/ping_pong/src/producer.rs @@ -1,32 +1,36 @@ -use spawned_concurrency::tasks::{self as concurrency, Process, ProcessInfo}; -use spawned_rt::tasks::mpsc::Sender; +use spawned_concurrency::message::Message; +use spawned_concurrency::tasks::{Actor, Context, Handler}; -use crate::messages::Message; +use crate::messages::Pong; +use crate::protocols::PingInbox; -pub struct Producer { - consumer: Sender, +pub struct SetConsumer(pub PingInbox); +impl Message for SetConsumer { + type Result = (); } - -impl Producer { - pub async fn spawn_new(consumer: Sender) -> ProcessInfo { - Self { consumer }.spawn().await +impl std::fmt::Debug for SetConsumer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("SetConsumer").finish() } +} - fn send_ping(&self, tx: &Sender, consumer: &Sender) { - let message = Message::Ping { from: tx.clone() }; - tracing::info!("Producer sent Ping"); - concurrency::send(consumer, message); - } +pub struct Producer { + pub consumer: Option, } -impl Process for Producer { - async fn init(&mut self, tx: &Sender) { - self.send_ping(tx, &self.consumer); +impl Actor for Producer {} + +impl Handler for Producer { + async fn handle(&mut self, msg: SetConsumer, _ctx: &Context) { + self.consumer = Some(msg.0); } +} - async fn handle(&mut self, message: Message, tx: &Sender) -> Message { - tracing::info!("Producer received {message:?}"); - self.send_ping(tx, &self.consumer); - message +impl Handler for Producer { + async fn handle(&mut self, _msg: Pong, _ctx: &Context) { + tracing::info!("Producer received Pong, sending Ping"); + if let Some(consumer) = &self.consumer { + let _ = consumer.ping(); + } } } diff --git a/examples/ping_pong/src/protocols.rs b/examples/ping_pong/src/protocols.rs new file mode 100644 index 0000000..0361985 --- /dev/null +++ b/examples/ping_pong/src/protocols.rs @@ -0,0 +1,40 @@ +use spawned_concurrency::error::ActorError; +use spawned_concurrency::tasks::{ActorRef, Handler}; +use std::sync::Arc; + +use crate::consumer::Consumer; +use crate::messages::{Ping, Pong}; +use crate::producer::Producer; + +// --- Protocol traits: cross-actor contracts --- + +pub trait PingReceiver: Send + Sync { + fn ping(&self) -> Result<(), ActorError>; +} + +pub trait PongReceiver: Send + Sync { + fn pong(&self) -> Result<(), ActorError>; +} + +// --- Bridge impls --- + +impl PingReceiver for ActorRef +where + Consumer: Handler, +{ + fn ping(&self) -> Result<(), ActorError> { + self.send(Ping) + } +} + +impl PongReceiver for ActorRef +where + Producer: Handler, +{ + fn pong(&self) -> Result<(), ActorError> { + self.send(Pong) + } +} + +pub type PingInbox = Arc; +pub type PongInbox = Arc; diff --git a/examples/signal_test/src/main.rs b/examples/signal_test/src/main.rs index 90e6d6b..7df9037 100644 --- a/examples/signal_test/src/main.rs +++ b/examples/signal_test/src/main.rs @@ -1,7 +1,7 @@ //! Test to verify signal handling across different Actor backends (tasks version). //! //! This example demonstrates using `send_message_on` to handle Ctrl+C signals. -//! The signal handler is set up in the Actor's `init()` function. +//! The signal handler is set up in the Actor's `started()` function. //! //! Run with: cargo run --bin signal_test -- [async|blocking|thread] //! @@ -9,11 +9,9 @@ //! - Does the actor stop gracefully? //! - Does teardown run? -use spawned_concurrency::{ - messages::Unused, - tasks::{ - send_interval, send_message_on, Actor, ActorRef, Backend, InitResult, MessageResponse, - }, +use spawned_concurrency::message::Message; +use spawned_concurrency::tasks::{ + send_interval, send_message_on, Actor, ActorStart as _, Backend, Context, Handler, }; use spawned_rt::tasks::{self as rt, CancellationToken}; use std::{env, time::Duration}; @@ -34,56 +32,47 @@ impl TickingActor { } } -#[derive(Clone)] -enum Msg { - Tick, - Shutdown, +#[derive(Debug, Clone)] +struct Tick; +impl Message for Tick { + type Result = (); } -impl Actor for TickingActor { - type Request = Unused; - type Message = Msg; - type Reply = Unused; - type Error = (); +#[derive(Debug)] +struct Shutdown; +impl Message for Shutdown { + type Result = (); +} - async fn init(mut self, handle: &ActorRef) -> Result, Self::Error> { +impl Actor for TickingActor { + async fn started(&mut self, ctx: &Context) { tracing::info!("[{}] Actor initialized", self.name); - // Set up periodic ticking - let timer = send_interval(Duration::from_secs(1), handle.clone(), Msg::Tick); + // Set up periodic ticking — need an ActorRef to use with send_message_on + let timer = send_interval(Duration::from_secs(1), ctx.clone(), Tick); self.timer_token = Some(timer.cancellation_token); - - // Set up Ctrl+C handler using send_message_on - send_message_on(handle.clone(), rt::ctrl_c(), Msg::Shutdown); - - Ok(InitResult::Success(self)) } - async fn handle_message( - &mut self, - message: Self::Message, - _handle: &ActorRef, - ) -> MessageResponse { - match message { - Msg::Tick => { - self.count += 1; - tracing::info!("[{}] Tick #{}", self.name, self.count); - MessageResponse::NoReply - } - Msg::Shutdown => { - tracing::info!("[{}] Received shutdown signal", self.name); - MessageResponse::Stop - } - } - } - - async fn teardown(self, _handle: &ActorRef) -> Result<(), Self::Error> { + async fn stopped(&mut self, _ctx: &Context) { tracing::info!( "[{}] Teardown called! Final count: {}", self.name, self.count ); - Ok(()) + } +} + +impl Handler for TickingActor { + async fn handle(&mut self, _msg: Tick, _ctx: &Context) { + self.count += 1; + tracing::info!("[{}] Tick #{}", self.name, self.count); + } +} + +impl Handler for TickingActor { + async fn handle(&mut self, _msg: Shutdown, ctx: &Context) { + tracing::info!("[{}] Received shutdown signal", self.name); + ctx.stop(); } } @@ -127,6 +116,10 @@ fn main() { } }; + // Set up Ctrl+C handler using send_message_on + send_message_on(actor1.context(), rt::ctrl_c(), Shutdown); + send_message_on(actor2.context(), rt::ctrl_c(), Shutdown); + // Wait for both actors to stop actor1.join().await; actor2.join().await; diff --git a/examples/signal_test_threads/src/main.rs b/examples/signal_test_threads/src/main.rs index a0da2a0..82b96dd 100644 --- a/examples/signal_test_threads/src/main.rs +++ b/examples/signal_test_threads/src/main.rs @@ -1,7 +1,7 @@ //! Test to verify signal handling for threads Actor. //! //! This example demonstrates using `send_message_on` to handle Ctrl+C signals. -//! The signal handler is set up in the Actor's `init()` function. +//! The signal handler is set up after starting the actors. //! //! Run with: cargo run --bin signal_test_threads //! @@ -9,9 +9,9 @@ //! - Does the actor stop gracefully? //! - Does teardown run? -use spawned_concurrency::{ - messages::Unused, - threads::{send_interval, send_message_on, Actor, ActorRef, InitResult, MessageResponse}, +use spawned_concurrency::message::Message; +use spawned_concurrency::threads::{ + send_interval, send_message_on, Actor, ActorStart as _, Context, Handler, }; use spawned_rt::threads::{self as rt, CancellationToken}; use std::time::Duration; @@ -32,56 +32,47 @@ impl TickingActor { } } -#[derive(Clone)] -enum Msg { - Tick, - Shutdown, +#[derive(Debug, Clone)] +struct Tick; +impl Message for Tick { + type Result = (); } -impl Actor for TickingActor { - type Request = Unused; - type Message = Msg; - type Reply = Unused; - type Error = (); +#[derive(Debug)] +struct Shutdown; +impl Message for Shutdown { + type Result = (); +} - fn init(mut self, handle: &ActorRef) -> Result, Self::Error> { +impl Actor for TickingActor { + fn started(&mut self, ctx: &Context) { tracing::info!("[{}] Actor initialized", self.name); // Set up periodic ticking - let timer = send_interval(Duration::from_secs(1), handle.clone(), Msg::Tick); + let timer = send_interval(Duration::from_secs(1), ctx.clone(), Tick); self.timer_token = Some(timer.cancellation_token); - - // Set up Ctrl+C handler using send_message_on - send_message_on(handle.clone(), rt::ctrl_c(), Msg::Shutdown); - - Ok(InitResult::Success(self)) - } - - fn handle_message( - &mut self, - message: Self::Message, - _handle: &ActorRef, - ) -> MessageResponse { - match message { - Msg::Tick => { - self.count += 1; - tracing::info!("[{}] Tick #{}", self.name, self.count); - MessageResponse::NoReply - } - Msg::Shutdown => { - tracing::info!("[{}] Received shutdown signal", self.name); - MessageResponse::Stop - } - } } - fn teardown(self, _handle: &ActorRef) -> Result<(), Self::Error> { + fn stopped(&mut self, _ctx: &Context) { tracing::info!( "[{}] Teardown called! Final count: {}", self.name, self.count ); - Ok(()) + } +} + +impl Handler for TickingActor { + fn handle(&mut self, _msg: Tick, _ctx: &Context) { + self.count += 1; + tracing::info!("[{}] Tick #{}", self.name, self.count); + } +} + +impl Handler for TickingActor { + fn handle(&mut self, _msg: Shutdown, ctx: &Context) { + tracing::info!("[{}] Received shutdown signal", self.name); + ctx.stop(); } } @@ -94,6 +85,10 @@ fn main() { let actor1 = TickingActor::new("actor-1").start(); let actor2 = TickingActor::new("actor-2").start(); + // Set up Ctrl+C handler using send_message_on + send_message_on(actor1.context(), rt::ctrl_c(), Shutdown); + send_message_on(actor2.context(), rt::ctrl_c(), Shutdown); + // Wait for both actors to stop actor1.join(); actor2.join(); diff --git a/examples/updater/src/main.rs b/examples/updater/src/main.rs index 0a6aaf0..2058867 100644 --- a/examples/updater/src/main.rs +++ b/examples/updater/src/main.rs @@ -9,7 +9,7 @@ mod server; use std::{thread, time::Duration}; use server::UpdaterServer; -use spawned_concurrency::tasks::Actor as _; +use spawned_concurrency::tasks::ActorStart as _; use spawned_rt::tasks as rt; fn main() { diff --git a/examples/updater/src/messages.rs b/examples/updater/src/messages.rs index daa0589..5bf74d7 100644 --- a/examples/updater/src/messages.rs +++ b/examples/updater/src/messages.rs @@ -1,11 +1,7 @@ -#[derive(Debug, Clone)] -pub enum UpdaterInMessage { - Check, -} +use spawned_concurrency::message::Message; -#[allow(dead_code)] -#[derive(Debug, Clone, PartialEq)] -pub enum UpdaterOutMessage { - Ok, - Error, +#[derive(Debug, Clone)] +pub struct Check; +impl Message for Check { + type Result = (); } diff --git a/examples/updater/src/server.rs b/examples/updater/src/server.rs index 2c1b02e..6d77f88 100644 --- a/examples/updater/src/server.rs +++ b/examples/updater/src/server.rs @@ -1,18 +1,9 @@ use std::time::Duration; -use spawned_concurrency::{ - messages::Unused, - tasks::{ - send_interval, Actor, ActorRef, - InitResult::{self, Success}, - MessageResponse, - }, -}; +use spawned_concurrency::tasks::{send_interval, Actor, Context, Handler}; use spawned_rt::tasks::CancellationToken; -use crate::messages::{UpdaterInMessage as InMessage, UpdaterOutMessage as OutMessage}; - -type UpdateServerHandle = ActorRef; +use crate::messages::Check; pub struct UpdaterServer { pub url: String, @@ -31,32 +22,18 @@ impl UpdaterServer { } impl Actor for UpdaterServer { - type Request = Unused; - type Message = InMessage; - type Reply = OutMessage; - type Error = std::fmt::Error; - - // Initializing Actor to start periodic checks. - async fn init(mut self, handle: &ActorRef) -> Result, Self::Error> { - let timer = send_interval(self.periodicity, handle.clone(), InMessage::Check); + async fn started(&mut self, ctx: &Context) { + let timer = send_interval(self.periodicity, ctx.clone(), Check); self.timer_token = Some(timer.cancellation_token); - Ok(Success(self)) } +} - async fn handle_message( - &mut self, - message: Self::Message, - _handle: &UpdateServerHandle, - ) -> MessageResponse { - match message { - Self::Message::Check => { - let url = self.url.clone(); - tracing::info!("Fetching: {url}"); - let resp = req(url).await; - tracing::info!("Response: {resp:?}"); - MessageResponse::NoReply - } - } +impl Handler for UpdaterServer { + async fn handle(&mut self, _msg: Check, _ctx: &Context) { + let url = self.url.clone(); + tracing::info!("Fetching: {url}"); + let resp = req(url).await; + tracing::info!("Response: {resp:?}"); } } diff --git a/examples/updater_threads/src/main.rs b/examples/updater_threads/src/main.rs index 5b7ceb3..9711e73 100644 --- a/examples/updater_threads/src/main.rs +++ b/examples/updater_threads/src/main.rs @@ -9,7 +9,7 @@ mod server; use std::{thread, time::Duration}; use server::UpdaterServer; -use spawned_concurrency::threads::Actor as _; +use spawned_concurrency::threads::ActorStart as _; use spawned_rt::threads as rt; fn main() { diff --git a/examples/updater_threads/src/messages.rs b/examples/updater_threads/src/messages.rs index daa0589..5bf74d7 100644 --- a/examples/updater_threads/src/messages.rs +++ b/examples/updater_threads/src/messages.rs @@ -1,11 +1,7 @@ -#[derive(Debug, Clone)] -pub enum UpdaterInMessage { - Check, -} +use spawned_concurrency::message::Message; -#[allow(dead_code)] -#[derive(Debug, Clone, PartialEq)] -pub enum UpdaterOutMessage { - Ok, - Error, +#[derive(Debug, Clone)] +pub struct Check; +impl Message for Check { + type Result = (); } diff --git a/examples/updater_threads/src/server.rs b/examples/updater_threads/src/server.rs index 2a931ff..b427dfa 100644 --- a/examples/updater_threads/src/server.rs +++ b/examples/updater_threads/src/server.rs @@ -1,50 +1,28 @@ use std::time::Duration; -use spawned_concurrency::{ - messages::Unused, - threads::{send_after, Actor, ActorRef, InitResult, MessageResponse}, -}; +use spawned_concurrency::threads::{send_after, Actor, Context, Handler}; use spawned_rt::threads::block_on; -use crate::messages::{UpdaterInMessage as InMessage, UpdaterOutMessage as OutMessage}; +use crate::messages::Check; -type UpdateServerHandle = ActorRef; - -#[derive(Clone)] pub struct UpdaterServer { pub url: String, pub periodicity: Duration, } impl Actor for UpdaterServer { - type Request = Unused; - type Message = InMessage; - type Reply = OutMessage; - type Error = std::fmt::Error; - - // Initializing Actor to start periodic checks. - fn init(self, handle: &ActorRef) -> Result, Self::Error> { - send_after(self.periodicity, handle.clone(), InMessage::Check); - Ok(InitResult::Success(self)) + fn started(&mut self, ctx: &Context) { + send_after(self.periodicity, ctx.clone(), Check); } +} - fn handle_message( - &mut self, - message: Self::Message, - handle: &UpdateServerHandle, - ) -> MessageResponse { - match message { - Self::Message::Check => { - send_after(self.periodicity, handle.clone(), InMessage::Check); - let url = self.url.clone(); - tracing::info!("Fetching: {url}"); - let resp = block_on(req(url)); - - tracing::info!("Response: {resp:?}"); - - MessageResponse::NoReply - } - } +impl Handler for UpdaterServer { + fn handle(&mut self, _msg: Check, ctx: &Context) { + send_after(self.periodicity, ctx.clone(), Check); + let url = self.url.clone(); + tracing::info!("Fetching: {url}"); + let resp = block_on(req(url)); + tracing::info!("Response: {resp:?}"); } } From faf3b7068cfbc1e0dadb41140344f64ee89fe585 Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Wed, 11 Feb 2026 17:36:12 -0300 Subject: [PATCH 02/20] feat: add #[actor] macro, named registry, and Handler/Recipient API Introduces a unified API redesign addressing #144, #145, and #129: - Handler trait with per-message typed results (RPITIT, not object-safe) - Receiver/Recipient for type-erased cross-actor messaging - #[actor] proc macro that generates Handler impls from #[handler] methods - Any-based named registry (register/whereis/unregister) - messages! declarative macro for defining message structs - Context replaces ActorRef in handler signatures - New examples: chat_room (Recipient), service_discovery (registry) - All existing examples migrated to new API --- Cargo.lock | 32 + Cargo.toml | 4 + concurrency/Cargo.toml | 1 + concurrency/src/error.rs | 20 +- concurrency/src/lib.rs | 5 +- concurrency/src/message.rs | 52 + concurrency/src/messages.rs | 2 - concurrency/src/registry.rs | 91 ++ concurrency/src/tasks/actor.rs | 1297 +++++++++------------- concurrency/src/tasks/mod.rs | 13 +- concurrency/src/tasks/stream.rs | 23 +- concurrency/src/tasks/stream_tests.rs | 154 ++- concurrency/src/tasks/time.rs | 31 +- concurrency/src/tasks/timer_tests.rs | 264 ++--- concurrency/src/threads/actor.rs | 521 +++++---- concurrency/src/threads/mod.rs | 13 +- concurrency/src/threads/stream.rs | 18 +- concurrency/src/threads/time.rs | 48 +- concurrency/src/threads/timer_tests.rs | 266 ++--- examples/bank/src/main.rs | 22 +- examples/bank/src/messages.rs | 44 +- examples/bank/src/server.rs | 130 ++- examples/bank_threads/src/main.rs | 22 +- examples/bank_threads/src/messages.rs | 44 +- examples/bank_threads/src/server.rs | 127 +-- examples/blocking_genserver/main.rs | 102 +- examples/busy_genserver_warning/main.rs | 49 +- examples/chat_room/Cargo.toml | 12 + examples/chat_room/src/main.rs | 60 + examples/chat_room/src/messages.rs | 18 + examples/chat_room/src/room.rs | 39 + examples/chat_room/src/user.rs | 27 + examples/name_server/src/main.rs | 10 +- examples/name_server/src/messages.rs | 24 +- examples/name_server/src/server.rs | 52 +- examples/service_discovery/Cargo.toml | 12 + examples/service_discovery/src/main.rs | 102 ++ examples/signal_test/src/main.rs | 77 +- examples/signal_test_threads/src/main.rs | 75 +- examples/updater/src/main.rs | 2 +- examples/updater/src/messages.rs | 13 +- examples/updater/src/server.rs | 52 +- examples/updater_threads/src/main.rs | 2 +- examples/updater_threads/src/messages.rs | 13 +- examples/updater_threads/src/server.rs | 46 +- macros/Cargo.toml | 14 + macros/src/lib.rs | 106 ++ 47 files changed, 2112 insertions(+), 2039 deletions(-) create mode 100644 concurrency/src/message.rs delete mode 100644 concurrency/src/messages.rs create mode 100644 concurrency/src/registry.rs create mode 100644 examples/chat_room/Cargo.toml create mode 100644 examples/chat_room/src/main.rs create mode 100644 examples/chat_room/src/messages.rs create mode 100644 examples/chat_room/src/room.rs create mode 100644 examples/chat_room/src/user.rs create mode 100644 examples/service_discovery/Cargo.toml create mode 100644 examples/service_discovery/src/main.rs create mode 100644 macros/Cargo.toml create mode 100644 macros/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 4d962a6..bea12aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -120,6 +120,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chat_room" +version = "0.4.5" +dependencies = [ + "spawned-concurrency", + "spawned-macros", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1129,6 +1140,17 @@ dependencies = [ "serde", ] +[[package]] +name = "service_discovery" +version = "0.4.5" +dependencies = [ + "spawned-concurrency", + "spawned-macros", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1210,6 +1232,7 @@ version = "0.4.5" dependencies = [ "futures", "pin-project-lite", + "spawned-macros", "spawned-rt", "thiserror", "tokio", @@ -1217,6 +1240,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "spawned-macros" +version = "0.4.5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "spawned-rt" version = "0.4.5" diff --git a/Cargo.toml b/Cargo.toml index f234fe4..e5d4243 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ resolver = "3" members = [ "rt", "concurrency", + "macros", "examples/bank", "examples/bank_threads", "examples/name_server", @@ -15,11 +16,14 @@ members = [ "examples/busy_genserver_warning", "examples/signal_test", "examples/signal_test_threads", + "examples/chat_room", + "examples/service_discovery", ] [workspace.dependencies] spawned-rt = { path = "rt", version = "0.4.5" } spawned-concurrency = { path = "concurrency", version = "0.4.5" } +spawned-macros = { path = "macros", version = "0.4.5" } tracing = { version = "0.1.41", features = ["log"] } tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } diff --git a/concurrency/Cargo.toml b/concurrency/Cargo.toml index c2845a9..04ba8b8 100644 --- a/concurrency/Cargo.toml +++ b/concurrency/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true [dependencies] spawned-rt = { workspace = true } +spawned-macros = { workspace = true } tracing = { workspace = true } futures = "0.3.1" thiserror = "2.0.12" diff --git a/concurrency/src/error.rs b/concurrency/src/error.rs index 3b23e4b..35123ef 100644 --- a/concurrency/src/error.rs +++ b/concurrency/src/error.rs @@ -1,28 +1,20 @@ #[derive(Debug, thiserror::Error)] pub enum ActorError { - #[error("Callback Error")] - Callback, - #[error("Initialization error")] - Initialization, - #[error("Server error")] - Server, - #[error("Unsupported Request on this Actor")] - RequestUnused, - #[error("Unsupported Message on this Actor")] - MessageUnused, + #[error("Actor stopped")] + ActorStopped, #[error("Request to Actor timed out")] RequestTimeout, } impl From> for ActorError { fn from(_value: spawned_rt::threads::mpsc::SendError) -> Self { - Self::Server + Self::ActorStopped } } impl From> for ActorError { fn from(_value: spawned_rt::tasks::mpsc::SendError) -> Self { - Self::Server + Self::ActorStopped } } @@ -32,7 +24,7 @@ mod tests { #[test] fn test_error_into_std_error() { - let error: &dyn std::error::Error = &ActorError::Callback; - assert_eq!(error.to_string(), "Callback Error"); + let error: &dyn std::error::Error = &ActorError::ActorStopped; + assert_eq!(error.to_string(), "Actor stopped"); } } diff --git a/concurrency/src/lib.rs b/concurrency/src/lib.rs index 0edcab8..c470d0b 100644 --- a/concurrency/src/lib.rs +++ b/concurrency/src/lib.rs @@ -1,6 +1,5 @@ -//! spawned concurrency -//! Some basic traits and structs to implement concurrent code à-la-Erlang. pub mod error; -pub mod messages; +pub mod message; +pub mod registry; pub mod tasks; pub mod threads; diff --git a/concurrency/src/message.rs b/concurrency/src/message.rs new file mode 100644 index 0000000..a97b310 --- /dev/null +++ b/concurrency/src/message.rs @@ -0,0 +1,52 @@ +pub trait Message: Send + 'static { + type Result: Send + 'static; +} + +/// Declarative macro for defining message types. +/// +/// Supports both unit structs and structs with fields, and they can be mixed +/// in a single invocation: +/// +/// ```ignore +/// messages! { +/// GetCount -> u64; +/// Deposit { who: String, amount: i32 } -> Result; +/// Stop -> () +/// } +/// ``` +#[macro_export] +macro_rules! messages { + () => {}; + + // Base: unit message + ($(#[$meta:meta])* $name:ident -> $result:ty) => { + $(#[$meta])* + #[derive(Debug)] + pub struct $name; + impl $crate::message::Message for $name { + type Result = $result; + } + }; + + // Base: struct message + ($(#[$meta:meta])* $name:ident { $($field:ident : $ftype:ty),* $(,)? } -> $result:ty) => { + $(#[$meta])* + #[derive(Debug)] + pub struct $name { $(pub $field: $ftype,)* } + impl $crate::message::Message for $name { + type Result = $result; + } + }; + + // Recursive: unit message followed by more + ($(#[$meta:meta])* $name:ident -> $result:ty; $($rest:tt)*) => { + $crate::messages!($(#[$meta])* $name -> $result); + $crate::messages!($($rest)*); + }; + + // Recursive: struct message followed by more + ($(#[$meta:meta])* $name:ident { $($field:ident : $ftype:ty),* $(,)? } -> $result:ty; $($rest:tt)*) => { + $crate::messages!($(#[$meta])* $name { $($field : $ftype),* } -> $result); + $crate::messages!($($rest)*); + }; +} diff --git a/concurrency/src/messages.rs b/concurrency/src/messages.rs deleted file mode 100644 index e0aceb8..0000000 --- a/concurrency/src/messages.rs +++ /dev/null @@ -1,2 +0,0 @@ -#[derive(Clone, Debug)] -pub struct Unused; diff --git a/concurrency/src/registry.rs b/concurrency/src/registry.rs new file mode 100644 index 0000000..f37a5ba --- /dev/null +++ b/concurrency/src/registry.rs @@ -0,0 +1,91 @@ +use std::any::Any; +use std::collections::HashMap; +use std::sync::{OnceLock, RwLock}; + +type Store = RwLock>>; + +fn global_store() -> &'static Store { + static STORE: OnceLock = OnceLock::new(); + STORE.get_or_init(|| RwLock::new(HashMap::new())) +} + +#[derive(Debug, thiserror::Error)] +pub enum RegistryError { + #[error("name '{0}' is already registered")] + AlreadyRegistered(String), +} + +pub fn register(name: &str, value: T) -> Result<(), RegistryError> { + let mut store = global_store().write().unwrap_or_else(|p| p.into_inner()); + if store.contains_key(name) { + return Err(RegistryError::AlreadyRegistered(name.to_string())); + } + store.insert(name.to_string(), Box::new(value)); + Ok(()) +} + +pub fn whereis(name: &str) -> Option { + let store = global_store().read().unwrap_or_else(|p| p.into_inner()); + store.get(name)?.downcast_ref::().cloned() +} + +pub fn unregister(name: &str) { + let mut store = global_store().write().unwrap_or_else(|p| p.into_inner()); + store.remove(name); +} + +pub fn registered() -> Vec { + let store = global_store().read().unwrap_or_else(|p| p.into_inner()); + store.keys().cloned().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + // Use unique names per test to avoid cross-test interference with global state. + + #[test] + fn register_and_whereis() { + register("test_rw_1", 42u64).unwrap(); + let val: Option = whereis("test_rw_1"); + assert_eq!(val, Some(42)); + } + + #[test] + fn whereis_wrong_type_returns_none() { + register("test_wt_1", 42u64).unwrap(); + let val: Option = whereis("test_wt_1"); + assert_eq!(val, None); + } + + #[test] + fn whereis_missing_returns_none() { + let val: Option = whereis("nonexistent_key"); + assert_eq!(val, None); + } + + #[test] + fn duplicate_register_fails() { + register("test_dup_1", 1u32).unwrap(); + let result = register("test_dup_1", 2u32); + assert!(result.is_err()); + } + + #[test] + fn unregister_removes_entry() { + register("test_unreg_1", "hello".to_string()).unwrap(); + unregister("test_unreg_1"); + let val: Option = whereis("test_unreg_1"); + assert_eq!(val, None); + } + + #[test] + fn registered_lists_names() { + register("test_list_a", 1u32).unwrap(); + register("test_list_b", 2u32).unwrap(); + let names = registered(); + assert!(names.contains(&"test_list_a".to_string())); + assert!(names.contains(&"test_list_b".to_string())); + } +} diff --git a/concurrency/src/tasks/actor.rs b/concurrency/src/tasks/actor.rs index d41e3a3..b63f1ce 100644 --- a/concurrency/src/tasks/actor.rs +++ b/concurrency/src/tasks/actor.rs @@ -1,470 +1,429 @@ -//! Actor trait and structs to create an abstraction similar to Erlang gen_server. -//! See examples/name_server for a usage example. -use crate::{ - error::ActorError, - tasks::InitResult::{NoSuccess, Success}, -}; +use crate::error::ActorError; +use crate::message::Message; use core::pin::pin; use futures::future::{self, FutureExt as _}; use spawned_rt::{ tasks::{self as rt, mpsc, oneshot, timeout, watch, CancellationToken, JoinHandle}, threads, }; -use std::{fmt::Debug, future::Future, panic::AssertUnwindSafe, time::Duration}; +use std::{fmt::Debug, future::Future, panic::AssertUnwindSafe, pin::Pin, sync::Arc, time::Duration}; const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); -/// Execution backend for Actor. -/// -/// Determines how the Actor's async loop is executed. Choose based on -/// the nature of your workload: -/// -/// # Backend Comparison -/// -/// | Backend | Execution Model | Best For | Limitations | -/// |---------|-----------------|----------|-------------| -/// | `Async` | Tokio task | Non-blocking I/O, async operations | Blocks runtime if sync code runs too long | -/// | `Blocking` | Tokio blocking pool | Short blocking operations (file I/O, DNS) | Shared pool with limited threads | -/// | `Thread` | Dedicated OS thread with own runtime | Long-running services, isolation from main runtime | Higher memory overhead per Actor | -/// -/// **Note**: All backends use async internally. For fully synchronous code without any async -/// runtime, use [`threads::Actor`](crate::threads::Actor) instead. -/// -/// # Examples -/// -/// ```ignore -/// // For typical async workloads (HTTP handlers, database queries) -/// let handle = MyServer::new().start(); -/// -/// // For occasional blocking operations (file reads, external commands) -/// let handle = MyServer::new().start_with_backend(Backend::Blocking); -/// -/// // For CPU-intensive or permanently blocking services -/// let handle = MyServer::new().start_with_backend(Backend::Thread); -/// ``` -/// -/// # When to Use Each Backend -/// -/// ## `Backend::Async` (Default) -/// - **Advantages**: Lightweight, efficient, good for high concurrency -/// - **Use when**: Your Actor does mostly async I/O (network, database) -/// - **Avoid when**: Your code blocks (e.g., `std::thread::sleep`, heavy computation) -/// -/// ## `Backend::Blocking` -/// - **Advantages**: Prevents blocking the async runtime, uses tokio's managed pool -/// - **Use when**: You have occasional blocking operations that complete quickly -/// - **Avoid when**: You need guaranteed thread availability or long-running blocks -/// -/// ## `Backend::Thread` -/// - **Advantages**: Isolated from main runtime, dedicated thread won't affect other tasks -/// - **Use when**: Long-running singleton services that shouldn't share the main runtime -/// - **Avoid when**: You need many Actors (each gets its own OS thread + runtime) -/// - **Note**: Still uses async internally (own runtime). For sync code, use `threads::Actor` +// --------------------------------------------------------------------------- +// Backend +// --------------------------------------------------------------------------- + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum Backend { - /// Run on tokio async runtime (default). - /// - /// Best for non-blocking, async workloads. The Actor runs as a - /// lightweight tokio task, enabling high concurrency with minimal overhead. - /// - /// **Warning**: If your `handle_request` or `handle_message` blocks synchronously - /// (e.g., `std::thread::sleep`, CPU-heavy loops), it will block the entire - /// tokio runtime thread, affecting other tasks. #[default] Async, - - /// Run on tokio's blocking thread pool. - /// - /// Use for Actors that perform blocking operations like: - /// - Synchronous file I/O - /// - DNS lookups - /// - External process calls - /// - Short CPU-bound computations - /// - /// The pool is shared across all `spawn_blocking` calls and has a default - /// limit of 512 threads. If the pool is exhausted, new blocking tasks wait. Blocking, - - /// Run on a dedicated OS thread with its own async runtime. - /// - /// Use for Actors that: - /// - Need isolation from the main tokio runtime - /// - Are long-running singleton services - /// - Should not compete with other tasks for runtime resources - /// - /// Each Actor gets its own thread with a separate tokio runtime, - /// providing isolation from other async tasks. Higher memory overhead - /// (~2MB stack per thread plus runtime overhead). - /// - /// **Note**: This still uses async internally. For fully synchronous code - /// without any async runtime, use [`threads::Actor`](crate::threads::Actor). Thread, } -#[derive(Debug)] -pub struct ActorRef { - pub tx: mpsc::Sender>, - /// Cancellation token to stop the Actor +// --------------------------------------------------------------------------- +// Actor trait +// --------------------------------------------------------------------------- + +pub trait Actor: Send + Sized + 'static { + fn started(&mut self, _ctx: &Context) -> impl Future + Send { + async {} + } + + fn stopped(&mut self, _ctx: &Context) -> impl Future + Send { + async {} + } +} + +// --------------------------------------------------------------------------- +// Handler trait (per-message, uses RPITIT — NOT object-safe, that's fine) +// --------------------------------------------------------------------------- + +pub trait Handler: Actor { + fn handle( + &mut self, + msg: M, + ctx: &Context, + ) -> impl Future + Send; +} + +// --------------------------------------------------------------------------- +// Envelope (type-erasure on the actor side) +// --------------------------------------------------------------------------- + +trait Envelope: Send { + fn handle<'a>( + self: Box, + actor: &'a mut A, + ctx: &'a Context, + ) -> Pin + Send + 'a>>; +} + +struct MessageEnvelope { + msg: M, + tx: Option>, +} + +impl Envelope for MessageEnvelope +where + A: Actor + Handler, + M: Message, +{ + fn handle<'a>( + self: Box, + actor: &'a mut A, + ctx: &'a Context, + ) -> Pin + Send + 'a>> { + Box::pin(async move { + let result = actor.handle(self.msg, ctx).await; + if let Some(tx) = self.tx { + let _ = tx.send(result); + } + }) + } +} + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +pub struct Context { + sender: mpsc::Sender + Send>>, cancellation_token: CancellationToken, - /// Completion signal for waiting on actor stop (true = stopped) - completion_rx: watch::Receiver, } -impl Clone for ActorRef { +impl Clone for Context { fn clone(&self) -> Self { Self { - tx: self.tx.clone(), + sender: self.sender.clone(), cancellation_token: self.cancellation_token.clone(), - completion_rx: self.completion_rx.clone(), } } } -impl ActorRef { - fn new(actor: A) -> Self { - let (tx, mut rx) = mpsc::channel::>(); - let cancellation_token = CancellationToken::new(); - let (completion_tx, completion_rx) = watch::channel(false); - let handle = ActorRef { - tx, - cancellation_token, - completion_rx, - }; - let handle_clone = handle.clone(); - let inner_future = async move { - if let Err(error) = actor.run(&handle, &mut rx).await { - tracing::trace!(%error, "Actor crashed") - } - // Signal completion to all waiters - let _ = completion_tx.send(true); - }; +impl Debug for Context { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Context").finish_non_exhaustive() + } +} - #[cfg(debug_assertions)] - // Optionally warn if the Actor future blocks for too much time - let inner_future = warn_on_block::WarnOnBlocking::new(inner_future); +impl Context { + pub fn from_ref(actor_ref: &ActorRef) -> Self { + Self { + sender: actor_ref.sender.clone(), + cancellation_token: actor_ref.cancellation_token.clone(), + } + } - let _task_handle = rt::spawn(inner_future); + pub fn stop(&self) { + self.cancellation_token.cancel(); + } - handle_clone + pub fn send(&self, msg: M) -> Result<(), ActorError> + where + A: Handler, + M: Message, + { + let envelope = MessageEnvelope { msg, tx: None }; + self.sender + .send(Box::new(envelope)) + .map_err(|_| ActorError::ActorStopped) } - fn new_blocking(actor: A) -> Self { - let (tx, mut rx) = mpsc::channel::>(); - let cancellation_token = CancellationToken::new(); - let (completion_tx, completion_rx) = watch::channel(false); - let handle = ActorRef { - tx, - cancellation_token, - completion_rx, + pub fn request(&self, msg: M) -> Result, ActorError> + where + A: Handler, + M: Message, + { + let (tx, rx) = oneshot::channel(); + let envelope = MessageEnvelope { + msg, + tx: Some(tx), }; - let handle_clone = handle.clone(); - let _task_handle = rt::spawn_blocking(move || { - rt::block_on(async move { - if let Err(error) = actor.run(&handle, &mut rx).await { - tracing::trace!(%error, "Actor crashed") - }; - // Signal completion to all waiters - let _ = completion_tx.send(true); - }) - }); + self.sender + .send(Box::new(envelope)) + .map_err(|_| ActorError::ActorStopped)?; + Ok(rx) + } + + pub async fn send_request(&self, msg: M) -> Result + where + A: Handler, + M: Message, + { + let rx = self.request(msg)?; + match timeout(DEFAULT_REQUEST_TIMEOUT, rx).await { + Ok(Ok(result)) => Ok(result), + Ok(Err(_)) => Err(ActorError::ActorStopped), + Err(_) => Err(ActorError::RequestTimeout), + } + } - handle_clone + pub(crate) fn cancellation_token(&self) -> CancellationToken { + self.cancellation_token.clone() } +} - fn new_on_thread(actor: A) -> Self { - let (tx, mut rx) = mpsc::channel::>(); - let cancellation_token = CancellationToken::new(); - let (completion_tx, completion_rx) = watch::channel(false); - let handle = ActorRef { - tx, - cancellation_token, - completion_rx, - }; - let handle_clone = handle.clone(); - let _thread_handle = threads::spawn(move || { - threads::block_on(async move { - if let Err(error) = actor.run(&handle, &mut rx).await { - tracing::trace!(%error, "Actor crashed") - }; - // Signal completion to all waiters - let _ = completion_tx.send(true); - }) - }); +// --------------------------------------------------------------------------- +// Receiver trait (object-safe) + Recipient alias +// --------------------------------------------------------------------------- - handle_clone +pub trait Receiver: Send + Sync { + fn send(&self, msg: M) -> Result<(), ActorError>; + fn request(&self, msg: M) -> Result, ActorError>; +} + +pub type Recipient = Arc>; + +pub async fn send_request( + recipient: &dyn Receiver, + msg: M, + timeout_duration: Duration, +) -> Result { + let rx = recipient.request(msg)?; + match timeout(timeout_duration, rx).await { + Ok(Ok(result)) => Ok(result), + Ok(Err(_)) => Err(ActorError::ActorStopped), + Err(_) => Err(ActorError::RequestTimeout), } +} + +// --------------------------------------------------------------------------- +// ActorRef +// --------------------------------------------------------------------------- - pub fn sender(&self) -> mpsc::Sender> { - self.tx.clone() +pub struct ActorRef { + sender: mpsc::Sender + Send>>, + cancellation_token: CancellationToken, + completion_rx: watch::Receiver, +} + +impl Debug for ActorRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ActorRef").finish_non_exhaustive() } +} - pub async fn request(&mut self, message: A::Request) -> Result { - self.request_with_timeout(message, DEFAULT_REQUEST_TIMEOUT) - .await +impl Clone for ActorRef { + fn clone(&self) -> Self { + Self { + sender: self.sender.clone(), + cancellation_token: self.cancellation_token.clone(), + completion_rx: self.completion_rx.clone(), + } } +} - pub async fn request_with_timeout( - &mut self, - message: A::Request, +impl ActorRef { + pub fn send(&self, msg: M) -> Result<(), ActorError> + where + A: Handler, + M: Message, + { + let envelope = MessageEnvelope { msg, tx: None }; + self.sender + .send(Box::new(envelope)) + .map_err(|_| ActorError::ActorStopped) + } + + pub fn request(&self, msg: M) -> Result, ActorError> + where + A: Handler, + M: Message, + { + let (tx, rx) = oneshot::channel(); + let envelope = MessageEnvelope { + msg, + tx: Some(tx), + }; + self.sender + .send(Box::new(envelope)) + .map_err(|_| ActorError::ActorStopped)?; + Ok(rx) + } + + pub async fn send_request(&self, msg: M) -> Result + where + A: Handler, + M: Message, + { + self.send_request_with_timeout(msg, DEFAULT_REQUEST_TIMEOUT).await + } + + pub async fn send_request_with_timeout( + &self, + msg: M, duration: Duration, - ) -> Result { - let (oneshot_tx, oneshot_rx) = oneshot::channel::>(); - self.tx.send(ActorInMsg::Request { - sender: oneshot_tx, - message, - })?; - - match timeout(duration, oneshot_rx).await { - Ok(Ok(result)) => result, - Ok(Err(_)) => Err(ActorError::Server), + ) -> Result + where + A: Handler, + M: Message, + { + let rx = self.request(msg)?; + match timeout(duration, rx).await { + Ok(Ok(result)) => Ok(result), + Ok(Err(_)) => Err(ActorError::ActorStopped), Err(_) => Err(ActorError::RequestTimeout), } } - pub async fn send(&mut self, message: A::Message) -> Result<(), ActorError> { - self.tx - .send(ActorInMsg::Message { message }) - .map_err(|_error| ActorError::Server) + pub fn recipient(&self) -> Recipient + where + A: Handler, + M: Message, + { + Arc::new(self.clone()) } - pub(crate) fn cancellation_token(&self) -> CancellationToken { - self.cancellation_token.clone() + pub fn context(&self) -> Context { + Context::from_ref(self) } - /// Waits for the actor to stop. - /// - /// This method returns a future that completes when the actor has finished - /// processing and exited its main loop. Can be called multiple times from - /// different clones of the ActorRef - all callers will be notified when - /// the actor stops. pub async fn join(&self) { let mut rx = self.completion_rx.clone(); - // Wait until completion signal is true while !*rx.borrow_and_update() { if rx.changed().await.is_err() { - // Sender dropped, actor must have completed break; } } } } -pub enum ActorInMsg { - Request { - sender: oneshot::Sender>, - message: A::Request, - }, - Message { - message: A::Message, - }, -} - -pub enum RequestResponse { - Reply(A::Reply), - Unused, - Stop(A::Reply), -} +// Bridge: ActorRef implements Receiver for any M that A handles +impl Receiver for ActorRef +where + A: Actor + Handler, + M: Message, +{ + fn send(&self, msg: M) -> Result<(), ActorError> { + ActorRef::send(self, msg) + } -pub enum MessageResponse { - NoReply, - Unused, - Stop, + fn request(&self, msg: M) -> Result, ActorError> { + ActorRef::request(self, msg) + } } -pub enum InitResult { - Success(A), - NoSuccess(A), -} +// --------------------------------------------------------------------------- +// Actor startup + main loop +// --------------------------------------------------------------------------- -pub trait Actor: Send + Sized { - type Request: Clone + Send + Sized + Sync; - type Message: Clone + Send + Sized + Sync; - type Reply: Send + Sized; - type Error: Debug + Send; +impl ActorRef { + fn spawn(actor: A, backend: Backend) -> Self { + let (tx, rx) = mpsc::channel:: + Send>>(); + let cancellation_token = CancellationToken::new(); + let (completion_tx, completion_rx) = watch::channel(false); - /// Start the Actor with the default backend (Async). - fn start(self) -> ActorRef { - self.start_with_backend(Backend::default()) - } + let actor_ref = ActorRef { + sender: tx.clone(), + cancellation_token: cancellation_token.clone(), + completion_rx, + }; - /// Start the Actor with the specified backend. - /// - /// # Arguments - /// * `backend` - The execution backend to use: - /// - `Backend::Async` - Run on tokio async runtime (default, best for non-blocking workloads) - /// - `Backend::Blocking` - Run on tokio's blocking thread pool (for blocking operations) - /// - `Backend::Thread` - Run on a dedicated OS thread (for long-running blocking services) - fn start_with_backend(self, backend: Backend) -> ActorRef { - match backend { - Backend::Async => ActorRef::new(self), - Backend::Blocking => ActorRef::new_blocking(self), - Backend::Thread => ActorRef::new_on_thread(self), - } - } + let ctx = Context { + sender: tx, + cancellation_token: cancellation_token.clone(), + }; - fn run( - self, - handle: &ActorRef, - rx: &mut mpsc::Receiver>, - ) -> impl Future> + Send { - async { - let res = match self.init(handle).await { - Ok(Success(new_state)) => Ok(new_state.main_loop(handle, rx).await), - Ok(NoSuccess(intermediate_state)) => { - // new_state is NoSuccess, this means the initialization failed, but the error was handled - // in callback. No need to report the error. - // Just skip main_loop and return the state to teardown the Actor - Ok(intermediate_state) - } - Err(err) => { - tracing::error!("Initialization failed with unhandled error: {err:?}"); - Err(ActorError::Initialization) - } - }; + let inner_future = async move { + run_actor(actor, ctx, rx, cancellation_token).await; + let _ = completion_tx.send(true); + }; - handle.cancellation_token().cancel(); - if let Ok(final_state) = res { - if let Err(err) = final_state.teardown(handle).await { - tracing::error!("Error during teardown: {err:?}"); - } + match backend { + Backend::Async => { + #[cfg(debug_assertions)] + let inner_future = warn_on_block::WarnOnBlocking::new(inner_future); + let _handle = rt::spawn(inner_future); + } + Backend::Blocking => { + let _handle = rt::spawn_blocking(move || { + rt::block_on(inner_future) + }); + } + Backend::Thread => { + let _handle = threads::spawn(move || { + threads::block_on(inner_future) + }); } - Ok(()) } - } - /// Initialization function. It's called before main loop. It - /// can be overrided on implementations in case initial steps are - /// required. - fn init( - self, - _handle: &ActorRef, - ) -> impl Future, Self::Error>> + Send { - async { Ok(Success(self)) } + actor_ref } +} - fn main_loop( - mut self, - handle: &ActorRef, - rx: &mut mpsc::Receiver>, - ) -> impl Future + Send { - async { - loop { - if !self.receive(handle, rx).await { +async fn run_actor( + mut actor: A, + ctx: Context, + mut rx: mpsc::Receiver + Send>>, + cancellation_token: CancellationToken, +) { + actor.started(&ctx).await; + + if cancellation_token.is_cancelled() { + actor.stopped(&ctx).await; + return; + } + + loop { + let msg = rx.recv().await; + match msg { + Some(envelope) => { + let result = AssertUnwindSafe(envelope.handle(&mut actor, &ctx)) + .catch_unwind() + .await; + if let Err(panic) = result { + tracing::error!("Panic in message handler: {panic:?}"); + break; + } + if cancellation_token.is_cancelled() { break; } } - tracing::trace!("Stopping Actor"); - self + None => break, } } - fn receive( - &mut self, - handle: &ActorRef, - rx: &mut mpsc::Receiver>, - ) -> impl Future + Send { - async move { - let message = rx.recv().await; - - let keep_running = match message { - Some(ActorInMsg::Request { sender, message }) => { - let (keep_running, response) = - match AssertUnwindSafe(self.handle_request(message, handle)) - .catch_unwind() - .await - { - Ok(response) => match response { - RequestResponse::Reply(response) => (true, Ok(response)), - RequestResponse::Stop(response) => (false, Ok(response)), - RequestResponse::Unused => { - tracing::error!("Actor received unexpected Request"); - (false, Err(ActorError::RequestUnused)) - } - }, - Err(error) => { - tracing::error!("Error in callback: '{error:?}'"); - (false, Err(ActorError::Callback)) - } - }; - // Send response back - if sender.send(response).is_err() { - tracing::error!("Actor failed to send response back, client must have died") - }; - keep_running - } - Some(ActorInMsg::Message { message }) => { - match AssertUnwindSafe(self.handle_message(message, handle)) - .catch_unwind() - .await - { - Ok(response) => match response { - MessageResponse::NoReply => true, - MessageResponse::Stop => false, - MessageResponse::Unused => { - tracing::error!("Actor received unexpected Message"); - false - } - }, - Err(error) => { - tracing::trace!("Error in callback: '{error:?}'"); - false - } - } - } - None => { - // Channel has been closed; won't receive further messages. Stop the server. - false - } - }; - keep_running - } - } + cancellation_token.cancel(); + actor.stopped(&ctx).await; +} - fn handle_request( - &mut self, - _message: Self::Request, - _handle: &ActorRef, - ) -> impl Future> + Send { - async { RequestResponse::Unused } - } +// --------------------------------------------------------------------------- +// Actor::start +// --------------------------------------------------------------------------- - fn handle_message( - &mut self, - _message: Self::Message, - _handle: &ActorRef, - ) -> impl Future + Send { - async { MessageResponse::Unused } +pub trait ActorStart: Actor { + fn start(self) -> ActorRef { + self.start_with_backend(Backend::default()) } - /// Teardown function. It's called after the stop message is received. - /// It can be overrided on implementations in case final steps are required, - /// like closing streams, stopping timers, etc. - fn teardown( - self, - _handle: &ActorRef, - ) -> impl Future> + Send { - async { Ok(()) } + fn start_with_backend(self, backend: Backend) -> ActorRef { + ActorRef::spawn(self, backend) } } -/// Spawns a task that awaits on a future and sends a message to an Actor -/// on completion. -/// This function returns a handle to the spawned task. -pub fn send_message_on(handle: ActorRef, future: U, message: T::Message) -> JoinHandle<()> +impl ActorStart for A {} + +// --------------------------------------------------------------------------- +// send_message_on (utility) +// --------------------------------------------------------------------------- + +pub fn send_message_on(ctx: Context, future: U, msg: M) -> JoinHandle<()> where - T: Actor, + A: Actor + Handler, + M: Message, U: Future + Send + 'static, ::Output: Send, { - let cancellation_token = handle.cancellation_token(); - let mut handle_clone = handle.clone(); + let cancellation_token = ctx.cancellation_token(); let join_handle = rt::spawn(async move { let is_cancelled = pin!(cancellation_token.cancelled()); let signal = pin!(future); match future::select(is_cancelled, signal).await { future::Either::Left(_) => tracing::debug!("Actor stopped"), future::Either::Right(_) => { - if let Err(e) = handle_clone.send(message).await { + if let Err(e) = ctx.send(msg) { tracing::error!("Failed to send message: {e:?}") } } @@ -473,10 +432,13 @@ where join_handle } +// --------------------------------------------------------------------------- +// WarnOnBlocking (debug only) +// --------------------------------------------------------------------------- + #[cfg(debug_assertions)] mod warn_on_block { use super::*; - use std::time::Instant; use tracing::warn; @@ -514,229 +476,54 @@ mod warn_on_block { } } +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + #[cfg(test)] mod tests { - use super::*; - use crate::{messages::Unused, tasks::send_after}; + use crate::messages; use std::{ - sync::{Arc, Mutex}, + sync::{atomic, Arc}, thread, time::Duration, }; - struct BadlyBehavedTask; - - #[derive(Clone)] - pub enum InMessage { - GetCount, - Stop, - } - #[derive(Clone)] - pub enum OutMsg { - Count(u64), - } + // --- Counter actor for basic tests --- - impl Actor for BadlyBehavedTask { - type Request = InMessage; - type Message = Unused; - type Reply = Unused; - type Error = Unused; - - async fn handle_request( - &mut self, - _: Self::Request, - _: &ActorRef, - ) -> RequestResponse { - RequestResponse::Stop(Unused) - } - - async fn handle_message( - &mut self, - _: Self::Message, - _: &ActorRef, - ) -> MessageResponse { - rt::sleep(Duration::from_millis(20)).await; - thread::sleep(Duration::from_secs(2)); - MessageResponse::Stop - } - } - - struct WellBehavedTask { - pub count: u64, - } - - impl Actor for WellBehavedTask { - type Request = InMessage; - type Message = Unused; - type Reply = OutMsg; - type Error = Unused; - - async fn handle_request( - &mut self, - message: Self::Request, - _: &ActorRef, - ) -> RequestResponse { - match message { - InMessage::GetCount => RequestResponse::Reply(OutMsg::Count(self.count)), - InMessage::Stop => RequestResponse::Stop(OutMsg::Count(self.count)), - } - } - - async fn handle_message( - &mut self, - _: Self::Message, - handle: &ActorRef, - ) -> MessageResponse { - self.count += 1; - println!("{:?}: good still alive", thread::current().id()); - send_after(Duration::from_millis(100), handle.to_owned(), Unused); - MessageResponse::NoReply - } + struct Counter { + count: u64, } - const BLOCKING: Backend = Backend::Blocking; - - #[test] - pub fn badly_behaved_thread_non_blocking() { - let runtime = rt::Runtime::new().unwrap(); - runtime.block_on(async move { - let mut badboy = BadlyBehavedTask.start(); - let _ = badboy.send(Unused).await; - let mut goodboy = WellBehavedTask { count: 0 }.start(); - let _ = goodboy.send(Unused).await; - rt::sleep(Duration::from_secs(1)).await; - let count = goodboy.request(InMessage::GetCount).await.unwrap(); - - match count { - OutMsg::Count(num) => { - assert_ne!(num, 10); - } - } - goodboy.request(InMessage::Stop).await.unwrap(); - }); + messages! { + GetCount -> u64; + Increment -> u64; + StopCounter -> u64 } - #[test] - pub fn badly_behaved_thread() { - let runtime = rt::Runtime::new().unwrap(); - runtime.block_on(async move { - let mut badboy = BadlyBehavedTask.start_with_backend(BLOCKING); - let _ = badboy.send(Unused).await; - let mut goodboy = WellBehavedTask { count: 0 }.start(); - let _ = goodboy.send(Unused).await; - rt::sleep(Duration::from_secs(1)).await; - let count = goodboy.request(InMessage::GetCount).await.unwrap(); - - match count { - OutMsg::Count(num) => { - assert_eq!(num, 10); - } - } - goodboy.request(InMessage::Stop).await.unwrap(); - }); - } - - const TIMEOUT_DURATION: Duration = Duration::from_millis(100); - - #[derive(Debug, Default)] - struct SomeTask; - - #[derive(Clone)] - enum SomeTaskRequest { - SlowOperation, - FastOperation, - } + impl Actor for Counter {} - impl Actor for SomeTask { - type Request = SomeTaskRequest; - type Message = Unused; - type Reply = Unused; - type Error = Unused; - - async fn handle_request( - &mut self, - message: Self::Request, - _handle: &ActorRef, - ) -> RequestResponse { - match message { - SomeTaskRequest::SlowOperation => { - // Simulate a slow operation that will not resolve in time - rt::sleep(TIMEOUT_DURATION * 2).await; - RequestResponse::Reply(Unused) - } - SomeTaskRequest::FastOperation => { - // Simulate a fast operation that resolves in time - rt::sleep(TIMEOUT_DURATION / 2).await; - RequestResponse::Reply(Unused) - } - } + impl Handler for Counter { + async fn handle(&mut self, _msg: GetCount, _ctx: &Context) -> u64 { + self.count } } - #[test] - pub fn unresolving_task_times_out() { - let runtime = rt::Runtime::new().unwrap(); - runtime.block_on(async move { - let mut unresolving_task = SomeTask.start(); - - let result = unresolving_task - .request_with_timeout(SomeTaskRequest::FastOperation, TIMEOUT_DURATION) - .await; - assert!(matches!(result, Ok(Unused))); - - let result = unresolving_task - .request_with_timeout(SomeTaskRequest::SlowOperation, TIMEOUT_DURATION) - .await; - assert!(matches!(result, Err(ActorError::RequestTimeout))); - }); - } - - struct SomeTaskThatFailsOnInit { - sender_channel: Arc>>, - } - - impl SomeTaskThatFailsOnInit { - pub fn new(sender_channel: Arc>>) -> Self { - Self { sender_channel } + impl Handler for Counter { + async fn handle(&mut self, _msg: Increment, _ctx: &Context) -> u64 { + self.count += 1; + self.count } } - impl Actor for SomeTaskThatFailsOnInit { - type Request = Unused; - type Message = Unused; - type Reply = Unused; - type Error = Unused; - - async fn init(self, _handle: &ActorRef) -> Result, Self::Error> { - // Simulate an initialization failure by returning NoSuccess - Ok(NoSuccess(self)) + impl Handler for Counter { + async fn handle(&mut self, _msg: StopCounter, ctx: &Context) -> u64 { + ctx.stop(); + self.count } - - async fn teardown(self, _handle: &ActorRef) -> Result<(), Self::Error> { - self.sender_channel.lock().unwrap().close(); - Ok(()) - } - } - - #[test] - pub fn task_fails_with_intermediate_state() { - let runtime = rt::Runtime::new().unwrap(); - runtime.block_on(async move { - let (rx, tx) = mpsc::channel::(); - let sender_channel = Arc::new(Mutex::new(tx)); - let _task = SomeTaskThatFailsOnInit::new(sender_channel).start(); - - // Wait a while to ensure the task has time to run and fail - rt::sleep(Duration::from_secs(1)).await; - - // We assure that the teardown function has ran by checking that the receiver channel is closed - assert!(rx.is_closed()) - }); } - // ==================== Backend enum tests ==================== - #[test] pub fn backend_default_is_async() { assert_eq!(Backend::default(), Backend::Async); @@ -746,8 +533,8 @@ mod tests { #[allow(clippy::clone_on_copy)] pub fn backend_enum_is_copy_and_clone() { let backend = Backend::Async; - let copied = backend; // Copy - let cloned = backend.clone(); // Clone - intentionally testing Clone trait + let copied = backend; + let cloned = backend.clone(); assert_eq!(backend, copied); assert_eq!(backend, cloned); } @@ -769,284 +556,183 @@ mod tests { assert_ne!(Backend::Blocking, Backend::Thread); } - // ==================== Backend functionality tests ==================== - - /// Simple counter Actor for testing all backends - struct Counter { - count: u64, - } - - #[derive(Clone)] - enum CounterRequest { - Get, - Increment, - Stop, - } - - #[derive(Clone)] - enum CounterMessage { - Increment, - } - - impl Actor for Counter { - type Request = CounterRequest; - type Message = CounterMessage; - type Reply = u64; - type Error = (); - - async fn handle_request( - &mut self, - message: Self::Request, - _: &ActorRef, - ) -> RequestResponse { - match message { - CounterRequest::Get => RequestResponse::Reply(self.count), - CounterRequest::Increment => { - self.count += 1; - RequestResponse::Reply(self.count) - } - CounterRequest::Stop => RequestResponse::Stop(self.count), - } - } - - async fn handle_message( - &mut self, - message: Self::Message, - _: &ActorRef, - ) -> MessageResponse { - match message { - CounterMessage::Increment => { - self.count += 1; - MessageResponse::NoReply - } - } - } - } - #[test] - pub fn backend_async_handles_call_and_cast() { + pub fn backend_async_handles_send_and_request() { let runtime = rt::Runtime::new().unwrap(); runtime.block_on(async move { - let mut counter = Counter { count: 0 }.start(); + let counter = Counter { count: 0 }.start(); - // Test call - let result = counter.request(CounterRequest::Get).await.unwrap(); + let result = counter.send_request(GetCount).await.unwrap(); assert_eq!(result, 0); - let result = counter.request(CounterRequest::Increment).await.unwrap(); + let result = counter.send_request(Increment).await.unwrap(); assert_eq!(result, 1); - // Test cast - counter.send(CounterMessage::Increment).await.unwrap(); - rt::sleep(Duration::from_millis(10)).await; // Give time for cast to process + // fire-and-forget send + counter.send(Increment).unwrap(); + rt::sleep(Duration::from_millis(10)).await; - let result = counter.request(CounterRequest::Get).await.unwrap(); + let result = counter.send_request(GetCount).await.unwrap(); assert_eq!(result, 2); - // Stop - let final_count = counter.request(CounterRequest::Stop).await.unwrap(); + let final_count = counter.send_request(StopCounter).await.unwrap(); assert_eq!(final_count, 2); }); } #[test] - pub fn backend_blocking_handles_call_and_cast() { + pub fn backend_blocking_handles_send_and_request() { let runtime = rt::Runtime::new().unwrap(); runtime.block_on(async move { - let mut counter = Counter { count: 0 }.start_with_backend(Backend::Blocking); + let counter = Counter { count: 0 }.start_with_backend(Backend::Blocking); - // Test call - let result = counter.request(CounterRequest::Get).await.unwrap(); + let result = counter.send_request(GetCount).await.unwrap(); assert_eq!(result, 0); - let result = counter.request(CounterRequest::Increment).await.unwrap(); + let result = counter.send_request(Increment).await.unwrap(); assert_eq!(result, 1); - // Test cast - counter.send(CounterMessage::Increment).await.unwrap(); - rt::sleep(Duration::from_millis(50)).await; // Give time for cast to process + counter.send(Increment).unwrap(); + rt::sleep(Duration::from_millis(50)).await; - let result = counter.request(CounterRequest::Get).await.unwrap(); + let result = counter.send_request(GetCount).await.unwrap(); assert_eq!(result, 2); - // Stop - let final_count = counter.request(CounterRequest::Stop).await.unwrap(); + let final_count = counter.send_request(StopCounter).await.unwrap(); assert_eq!(final_count, 2); }); } #[test] - pub fn backend_thread_handles_call_and_cast() { + pub fn backend_thread_handles_send_and_request() { let runtime = rt::Runtime::new().unwrap(); runtime.block_on(async move { - let mut counter = Counter { count: 0 }.start_with_backend(Backend::Thread); + let counter = Counter { count: 0 }.start_with_backend(Backend::Thread); - // Test call - let result = counter.request(CounterRequest::Get).await.unwrap(); + let result = counter.send_request(GetCount).await.unwrap(); assert_eq!(result, 0); - let result = counter.request(CounterRequest::Increment).await.unwrap(); + let result = counter.send_request(Increment).await.unwrap(); assert_eq!(result, 1); - // Test cast - counter.send(CounterMessage::Increment).await.unwrap(); - rt::sleep(Duration::from_millis(50)).await; // Give time for cast to process + counter.send(Increment).unwrap(); + rt::sleep(Duration::from_millis(50)).await; - let result = counter.request(CounterRequest::Get).await.unwrap(); + let result = counter.send_request(GetCount).await.unwrap(); assert_eq!(result, 2); - // Stop - let final_count = counter.request(CounterRequest::Stop).await.unwrap(); + let final_count = counter.send_request(StopCounter).await.unwrap(); assert_eq!(final_count, 2); }); } #[test] - pub fn backend_thread_isolates_blocking_work() { - // Similar to badly_behaved_thread but using Backend::Thread + pub fn multiple_backends_concurrent() { let runtime = rt::Runtime::new().unwrap(); runtime.block_on(async move { - let mut badboy = BadlyBehavedTask.start_with_backend(Backend::Thread); - let _ = badboy.send(Unused).await; - let mut goodboy = WellBehavedTask { count: 0 }.start(); - let _ = goodboy.send(Unused).await; - rt::sleep(Duration::from_secs(1)).await; - let count = goodboy.request(InMessage::GetCount).await.unwrap(); + let async_counter = Counter { count: 0 }.start(); + let blocking_counter = Counter { count: 100 }.start_with_backend(Backend::Blocking); + let thread_counter = Counter { count: 200 }.start_with_backend(Backend::Thread); - // goodboy should have run normally because badboy is on a separate thread - match count { - OutMsg::Count(num) => { - assert_eq!(num, 10); - } - } - goodboy.request(InMessage::Stop).await.unwrap(); + async_counter.send_request(Increment).await.unwrap(); + blocking_counter.send_request(Increment).await.unwrap(); + thread_counter.send_request(Increment).await.unwrap(); + + let async_val = async_counter.send_request(GetCount).await.unwrap(); + let blocking_val = blocking_counter.send_request(GetCount).await.unwrap(); + let thread_val = thread_counter.send_request(GetCount).await.unwrap(); + + assert_eq!(async_val, 1); + assert_eq!(blocking_val, 101); + assert_eq!(thread_val, 201); + + async_counter.send_request(StopCounter).await.unwrap(); + blocking_counter.send_request(StopCounter).await.unwrap(); + thread_counter.send_request(StopCounter).await.unwrap(); }); } #[test] - pub fn multiple_backends_concurrent() { + pub fn request_timeout() { let runtime = rt::Runtime::new().unwrap(); runtime.block_on(async move { - // Start counters on all three backends - let mut async_counter = Counter { count: 0 }.start(); - let mut blocking_counter = Counter { count: 100 }.start_with_backend(Backend::Blocking); - let mut thread_counter = Counter { count: 200 }.start_with_backend(Backend::Thread); - - // Increment each - async_counter - .request(CounterRequest::Increment) - .await - .unwrap(); - blocking_counter - .request(CounterRequest::Increment) - .await - .unwrap(); - thread_counter - .request(CounterRequest::Increment) - .await - .unwrap(); - - // Verify each has independent state - let async_val = async_counter.request(CounterRequest::Get).await.unwrap(); - let blocking_val = blocking_counter.request(CounterRequest::Get).await.unwrap(); - let thread_val = thread_counter.request(CounterRequest::Get).await.unwrap(); - - assert_eq!(async_val, 1); - assert_eq!(blocking_val, 101); - assert_eq!(thread_val, 201); + struct SlowActor; + messages! { SlowOp -> () } + impl Actor for SlowActor {} + impl Handler for SlowActor { + async fn handle(&mut self, _msg: SlowOp, _ctx: &Context) { + rt::sleep(Duration::from_millis(200)).await; + } + } - // Clean up - async_counter.request(CounterRequest::Stop).await.unwrap(); - blocking_counter - .request(CounterRequest::Stop) - .await - .unwrap(); - thread_counter.request(CounterRequest::Stop).await.unwrap(); + let actor = SlowActor.start(); + let result = actor + .send_request_with_timeout(SlowOp, Duration::from_millis(50)) + .await; + assert!(matches!(result, Err(ActorError::RequestTimeout))); }); } #[test] - pub fn backend_default_works_in_start() { + pub fn recipient_type_erasure() { let runtime = rt::Runtime::new().unwrap(); runtime.block_on(async move { - // Using Backend::default() should work the same as Backend::Async - let mut counter = Counter { count: 42 }.start_with_backend(Backend::Async); + let counter = Counter { count: 42 }.start(); + let recipient: Recipient = counter.recipient(); - let result = counter.request(CounterRequest::Get).await.unwrap(); + let rx = recipient.request(GetCount).unwrap(); + let result = rx.await.unwrap(); assert_eq!(result, 42); - counter.request(CounterRequest::Stop).await.unwrap(); + // Also test send_request helper + let result = send_request(&*recipient, GetCount, Duration::from_secs(5)).await.unwrap(); + assert_eq!(result, 42); }); } - /// Actor that sleeps during teardown to simulate slow shutdown + // --- SlowShutdownActor for join tests --- + struct SlowShutdownActor; + messages! { StopSlow -> () } + impl Actor for SlowShutdownActor { - type Request = Unused; - type Message = Unused; - type Reply = Unused; - type Error = Unused; - - async fn handle_message( - &mut self, - _message: Self::Message, - _handle: &ActorRef, - ) -> MessageResponse { - MessageResponse::Stop + async fn stopped(&mut self, _ctx: &Context) { + thread::sleep(Duration::from_millis(500)); } + } - async fn teardown(self, _handle: &ActorRef) -> Result<(), Self::Error> { - // Simulate slow shutdown - this runs on the thread - std::thread::sleep(Duration::from_millis(500)); - Ok(()) + impl Handler for SlowShutdownActor { + async fn handle(&mut self, _msg: StopSlow, ctx: &Context) { + ctx.stop(); } } - /// Test that join() on a Backend::Thread actor doesn't block other async tasks. - /// - /// This test verifies that when we call join().await on an actor running on - /// Backend::Thread, it doesn't block the tokio runtime - other async tasks - /// should continue to make progress. - /// - /// Uses a single-threaded runtime to ensure we detect blocking behavior. #[test] pub fn thread_backend_join_does_not_block_runtime() { - // Use current_thread runtime to ensure blocking would be detected let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap(); runtime.block_on(async move { - // Start a thread-backend actor that takes 500ms to teardown - let mut slow_actor = SlowShutdownActor.start_with_backend(Backend::Thread); + let slow_actor = SlowShutdownActor.start_with_backend(Backend::Thread); - // Spawn an async task that increments a counter every 50ms - let tick_count = Arc::new(std::sync::atomic::AtomicU64::new(0)); + let tick_count = Arc::new(atomic::AtomicU64::new(0)); let tick_count_clone = tick_count.clone(); let _ticker = rt::spawn(async move { for _ in 0..20 { rt::sleep(Duration::from_millis(50)).await; - tick_count_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + tick_count_clone.fetch_add(1, atomic::Ordering::SeqCst); } }); - // Tell the actor to stop - it will start its slow teardown - slow_actor.send(Unused).await.unwrap(); - - // Small delay to ensure the actor received the message + slow_actor.send(StopSlow).unwrap(); rt::sleep(Duration::from_millis(10)).await; - // Now join the actor - this waits for the 500ms teardown - // If implemented correctly, the ticker should continue running DURING the join slow_actor.join().await; - // Check tick count IMMEDIATELY after join returns, before awaiting ticker. - // The actor teardown takes 500ms. In that time, the ticker should have - // completed about 10 ticks (500ms / 50ms = 10). - // If join() blocked the runtime, the ticker would have 0-1 ticks. - let count_after_join = tick_count.load(std::sync::atomic::Ordering::SeqCst); + let count_after_join = tick_count.load(atomic::Ordering::SeqCst); assert!( count_after_join >= 8, "Ticker should have completed ~10 ticks during the 500ms join(), but only got {}. \ @@ -1056,19 +742,14 @@ mod tests { }); } - /// Test that multiple callers can wait on join() simultaneously. - /// - /// This verifies that the completion signal approach works correctly - /// when multiple tasks want to wait for the same actor to stop. #[test] pub fn multiple_join_callers_all_notified() { let runtime = rt::Runtime::new().unwrap(); runtime.block_on(async move { - let mut actor = SlowShutdownActor.start(); + let actor = SlowShutdownActor.start(); let actor_clone1 = actor.clone(); let actor_clone2 = actor.clone(); - // Spawn multiple tasks that will all call join() let join1 = rt::spawn(async move { actor_clone1.join().await; 1u32 @@ -1078,19 +759,105 @@ mod tests { 2u32 }); - // Give the join tasks time to start waiting rt::sleep(Duration::from_millis(10)).await; - // Tell the actor to stop - actor.send(Unused).await.unwrap(); + actor.send(StopSlow).unwrap(); - // All join tasks should complete after the actor stops let (r1, r2) = tokio::join!(join1, join2); assert_eq!(r1.unwrap(), 1); assert_eq!(r2.unwrap(), 2); - // Calling join again should return immediately (actor already stopped) actor.join().await; }); } + + // --- Badly behaved actors for blocking tests --- + + struct BadlyBehavedTask; + + messages! { DoBlock -> () } + + impl Actor for BadlyBehavedTask {} + + impl Handler for BadlyBehavedTask { + async fn handle(&mut self, _msg: DoBlock, ctx: &Context) { + rt::sleep(Duration::from_millis(20)).await; + thread::sleep(Duration::from_secs(2)); + ctx.stop(); + } + } + + messages! { IncrementWell -> () } + + struct WellBehavedTask { + pub count: u64, + } + + impl Actor for WellBehavedTask {} + + impl Handler for WellBehavedTask { + async fn handle(&mut self, _msg: GetCount, _ctx: &Context) -> u64 { + self.count + } + } + + impl Handler for WellBehavedTask { + async fn handle(&mut self, _msg: StopCounter, ctx: &Context) -> u64 { + ctx.stop(); + self.count + } + } + + impl Handler for WellBehavedTask { + async fn handle(&mut self, _msg: IncrementWell, ctx: &Context) { + self.count += 1; + use crate::tasks::send_after; + send_after(Duration::from_millis(100), ctx.clone(), IncrementWell); + } + } + + #[test] + pub fn badly_behaved_thread_non_blocking() { + let runtime = rt::Runtime::new().unwrap(); + runtime.block_on(async move { + let badboy = BadlyBehavedTask.start(); + badboy.send(DoBlock).unwrap(); + let goodboy = WellBehavedTask { count: 0 }.start(); + goodboy.send(IncrementWell).unwrap(); + rt::sleep(Duration::from_secs(1)).await; + let count = goodboy.send_request(GetCount).await.unwrap(); + assert_ne!(count, 10); + goodboy.send_request(StopCounter).await.unwrap(); + }); + } + + #[test] + pub fn badly_behaved_thread() { + let runtime = rt::Runtime::new().unwrap(); + runtime.block_on(async move { + let badboy = BadlyBehavedTask.start_with_backend(Backend::Blocking); + badboy.send(DoBlock).unwrap(); + let goodboy = WellBehavedTask { count: 0 }.start(); + goodboy.send(IncrementWell).unwrap(); + rt::sleep(Duration::from_secs(1)).await; + let count = goodboy.send_request(GetCount).await.unwrap(); + assert_eq!(count, 10); + goodboy.send_request(StopCounter).await.unwrap(); + }); + } + + #[test] + pub fn backend_thread_isolates_blocking_work() { + let runtime = rt::Runtime::new().unwrap(); + runtime.block_on(async move { + let badboy = BadlyBehavedTask.start_with_backend(Backend::Thread); + badboy.send(DoBlock).unwrap(); + let goodboy = WellBehavedTask { count: 0 }.start(); + goodboy.send(IncrementWell).unwrap(); + rt::sleep(Duration::from_secs(1)).await; + let count = goodboy.send_request(GetCount).await.unwrap(); + assert_eq!(count, 10); + goodboy.send_request(StopCounter).await.unwrap(); + }); + } } diff --git a/concurrency/src/tasks/mod.rs b/concurrency/src/tasks/mod.rs index dbbc269..2e364fa 100644 --- a/concurrency/src/tasks/mod.rs +++ b/concurrency/src/tasks/mod.rs @@ -1,7 +1,4 @@ -//! spawned concurrency -//! Runtime tasks-based traits and structs to implement concurrent code à-la-Erlang. - -mod actor; +pub(crate) mod actor; mod process; mod stream; mod time; @@ -12,9 +9,11 @@ mod stream_tests; mod timer_tests; pub use actor::{ - send_message_on, Actor, ActorInMsg, ActorRef, Backend, InitResult, InitResult::NoSuccess, - InitResult::Success, MessageResponse, RequestResponse, + send_message_on, Actor, ActorRef, ActorStart, Backend, Context, Handler, Receiver, Recipient, + send_request, }; pub use process::{send, Process, ProcessInfo}; pub use stream::spawn_listener; -pub use time::{send_after, send_interval}; +pub use time::{send_after, send_interval, TimerHandle}; + +pub use crate::registry; diff --git a/concurrency/src/tasks/stream.rs b/concurrency/src/tasks/stream.rs index ebf09a3..afc5ce6 100644 --- a/concurrency/src/tasks/stream.rs +++ b/concurrency/src/tasks/stream.rs @@ -1,26 +1,23 @@ -use crate::tasks::{Actor, ActorRef}; +use crate::message::Message; use futures::{future::select, Stream, StreamExt}; use spawned_rt::tasks::JoinHandle; -/// Spawns a listener that listens to a stream and sends messages to an Actor. -/// -/// Items sent through the stream are required to be wrapped in a Result type. -/// -/// This function returns a handle to the spawned task and a cancellation token -/// to stop it. -pub fn spawn_listener(mut handle: ActorRef, stream: S) -> JoinHandle<()> +use super::actor::{Actor, Context, Handler}; + +pub fn spawn_listener(ctx: Context, stream: S) -> JoinHandle<()> where - T: Actor, - S: Send + Stream + 'static, + A: Actor + Handler, + M: Message, + S: Send + Stream + 'static, { - let cancellation_token = handle.cancellation_token(); + let cancellation_token = ctx.cancellation_token(); let join_handle = spawned_rt::tasks::spawn(async move { let mut pinned_stream = core::pin::pin!(stream); let is_cancelled = core::pin::pin!(cancellation_token.cancelled()); let listener_loop = core::pin::pin!(async { loop { match pinned_stream.next().await { - Some(msg) => match handle.send(msg).await { + Some(msg) => match ctx.send(msg) { Ok(_) => tracing::trace!("Message sent successfully"), Err(e) => { tracing::error!("Failed to send message: {e:?}"); @@ -36,7 +33,7 @@ where }); match select(is_cancelled, listener_loop).await { futures::future::Either::Left(_) => tracing::trace!("Actor stopped"), - futures::future::Either::Right(_) => (), // Stream finished or errored out + futures::future::Either::Right(_) => (), } }); join_handle diff --git a/concurrency/src/tasks/stream_tests.rs b/concurrency/src/tasks/stream_tests.rs index d270002..69c5a6f 100644 --- a/concurrency/src/tasks/stream_tests.rs +++ b/concurrency/src/tasks/stream_tests.rs @@ -1,11 +1,30 @@ use crate::tasks::{ - send_after, stream::spawn_listener, Actor, ActorRef, MessageResponse, RequestResponse, + send_after, Actor, ActorStart, Context, Handler, + stream::spawn_listener, }; +use crate::message::Message; use futures::{stream, StreamExt}; use spawned_rt::tasks::{self as rt, BroadcastStream, ReceiverStream}; use std::time::Duration; -type SummatoryHandle = ActorRef; +// --- Messages --- + +#[derive(Debug)] +enum StreamMsg { + Add(u16), + Error, +} +impl Message for StreamMsg { type Result = (); } + +#[derive(Debug)] +struct StopSum; +impl Message for StopSum { type Result = (); } + +#[derive(Debug)] +struct GetValue; +impl Message for GetValue { type Result = u16; } + +// --- Summatory Actor --- struct Summatory { count: u16, @@ -17,49 +36,26 @@ impl Summatory { } } -type SummatoryOutMessage = u16; - -#[derive(Clone)] -enum SummatoryCastMessage { - Add(u16), - StreamError, - Stop, -} +impl Actor for Summatory {} -impl Summatory { - pub async fn get_value(server: &mut SummatoryHandle) -> Result { - server.request(()).await.map_err(|_| ()) +impl Handler for Summatory { + async fn handle(&mut self, msg: StreamMsg, ctx: &Context) { + match msg { + StreamMsg::Add(val) => self.count += val, + StreamMsg::Error => ctx.stop(), + } } } -impl Actor for Summatory { - type Request = (); // We only handle one type of call, so there is no need for a specific message type. - type Message = SummatoryCastMessage; - type Reply = SummatoryOutMessage; - type Error = (); - - async fn handle_message( - &mut self, - message: Self::Message, - _handle: &ActorRef, - ) -> MessageResponse { - match message { - SummatoryCastMessage::Add(val) => { - self.count += val; - MessageResponse::NoReply - } - SummatoryCastMessage::StreamError => MessageResponse::Stop, - SummatoryCastMessage::Stop => MessageResponse::Stop, - } +impl Handler for Summatory { + async fn handle(&mut self, _msg: StopSum, ctx: &Context) { + ctx.stop(); } +} - async fn handle_request( - &mut self, - _message: Self::Request, - _handle: &SummatoryHandle, - ) -> RequestResponse { - let current_value = self.count; - RequestResponse::Reply(current_value) +impl Handler for Summatory { + async fn handle(&mut self, _msg: GetValue, _ctx: &Context) -> u16 { + self.count } } @@ -67,18 +63,18 @@ impl Actor for Summatory { pub fn test_sum_numbers_from_stream() { let runtime = rt::Runtime::new().unwrap(); runtime.block_on(async move { - let mut summatory_handle = Summatory::new(0).start(); + let summatory = Summatory::new(0).start(); let stream = stream::iter(vec![1u16, 2, 3, 4, 5].into_iter().map(Ok::)); + let ctx = Context::from_ref(&summatory); spawn_listener( - summatory_handle.clone(), - stream.filter_map(|result| async move { result.ok().map(SummatoryCastMessage::Add) }), + ctx, + stream.filter_map(|result| async move { result.ok().map(StreamMsg::Add) }), ); - // Wait for 1 second so the whole stream is processed rt::sleep(Duration::from_secs(1)).await; - let val = Summatory::get_value(&mut summatory_handle).await.unwrap(); + let val = summatory.send_request(GetValue).await.unwrap(); assert_eq!(val, 15); }) } @@ -87,26 +83,25 @@ pub fn test_sum_numbers_from_stream() { pub fn test_sum_numbers_from_channel() { let runtime = rt::Runtime::new().unwrap(); runtime.block_on(async move { - let mut summatory_handle = Summatory::new(0).start(); + let summatory = Summatory::new(0).start(); let (tx, rx) = spawned_rt::tasks::mpsc::channel::>(); - // Spawn a task to send numbers to the channel spawned_rt::tasks::spawn(async move { for i in 1..=5 { tx.send(Ok(i)).unwrap(); } }); + let ctx = Context::from_ref(&summatory); spawn_listener( - summatory_handle.clone(), + ctx, ReceiverStream::new(rx) - .filter_map(|result| async move { result.ok().map(SummatoryCastMessage::Add) }), + .filter_map(|result| async move { result.ok().map(StreamMsg::Add) }), ); - // Wait for 1 second so the whole stream is processed rt::sleep(Duration::from_secs(1)).await; - let val = Summatory::get_value(&mut summatory_handle).await.unwrap(); + let val = summatory.send_request(GetValue).await.unwrap(); assert_eq!(val, 15); }) } @@ -115,44 +110,40 @@ pub fn test_sum_numbers_from_channel() { pub fn test_sum_numbers_from_broadcast_channel() { let runtime = rt::Runtime::new().unwrap(); runtime.block_on(async move { - let mut summatory_handle = Summatory::new(0).start(); + let summatory = Summatory::new(0).start(); let (tx, rx) = tokio::sync::broadcast::channel::(5); - // Spawn a task to send numbers to the channel spawned_rt::tasks::spawn(async move { for i in 1u16..=5 { tx.send(i).unwrap(); } }); + let ctx = Context::from_ref(&summatory); spawn_listener( - summatory_handle.clone(), + ctx, BroadcastStream::new(rx) - .filter_map(|result| async move { result.ok().map(SummatoryCastMessage::Add) }), + .filter_map(|result| async move { result.ok().map(StreamMsg::Add) }), ); - // Wait for 1 second so the whole stream is processed rt::sleep(Duration::from_secs(1)).await; - let val = Summatory::get_value(&mut summatory_handle).await.unwrap(); + let val = summatory.send_request(GetValue).await.unwrap(); assert_eq!(val, 15); }) } #[test] pub fn test_stream_cancellation() { - // Messages sent at: t=0, t=250, t=500, t=750, t=1000ms - // We read at t=850ms (after 4th message at t=750, before 5th at t=1000) const MESSAGE_INTERVAL: u64 = 250; const READ_TIME: u64 = 850; const STOP_TIME: u64 = 1100; let runtime = rt::Runtime::new().unwrap(); runtime.block_on(async move { - let mut summatory_handle = Summatory::new(0).start(); + let summatory = Summatory::new(0).start(); let (tx, rx) = spawned_rt::tasks::mpsc::channel::>(); - // Spawn a task to send numbers to the channel spawned_rt::tasks::spawn(async move { for i in 1..=5 { tx.send(Ok(i)).unwrap(); @@ -160,34 +151,28 @@ pub fn test_stream_cancellation() { } }); + let ctx = Context::from_ref(&summatory); let listener_handle = spawn_listener( - summatory_handle.clone(), + ctx.clone(), ReceiverStream::new(rx) - .filter_map(|result| async move { result.ok().map(SummatoryCastMessage::Add) }), + .filter_map(|result| async move { result.ok().map(StreamMsg::Add) }), ); - // Start a timer to stop the actor after all messages would be sent - let summatory_handle_clone = summatory_handle.clone(); let _ = send_after( Duration::from_millis(STOP_TIME), - summatory_handle_clone, - SummatoryCastMessage::Stop, + ctx, + StopSum, ); - // Read value after 4th message (t=750) but before 5th (t=1000). - // Expected sum: 1+2+3+4 = 10, but allow some slack for timing variations. rt::sleep(Duration::from_millis(READ_TIME)).await; - let val = Summatory::get_value(&mut summatory_handle).await.unwrap(); + let val = summatory.send_request(GetValue).await.unwrap(); - // At t=850ms, we expect 4 messages processed (sum=10), but timing variations - // could result in 3 messages (sum=6) or occasionally all 5 (sum=15). assert!((1..=15).contains(&val)); assert!(listener_handle.await.is_ok()); - // Finally, we check that the server is stopped, by getting an error when trying to call it. rt::sleep(Duration::from_millis(10)).await; - assert!(Summatory::get_value(&mut summatory_handle).await.is_err()); + assert!(summatory.send_request(GetValue).await.is_err()); }) } @@ -195,22 +180,21 @@ pub fn test_stream_cancellation() { pub fn test_halting_on_stream_error() { let runtime = rt::Runtime::new().unwrap(); runtime.block_on(async move { - let mut summatory_handle = Summatory::new(0).start(); + let summatory = Summatory::new(0).start(); let stream = tokio_stream::iter(vec![Ok(1u16), Ok(2), Ok(3), Err(()), Ok(4), Ok(5)]); let msg_stream = stream.filter_map(|value| async move { match value { - Ok(number) => Some(SummatoryCastMessage::Add(number)), - Err(_) => Some(SummatoryCastMessage::StreamError), + Ok(number) => Some(StreamMsg::Add(number)), + Err(_) => Some(StreamMsg::Error), } }); - spawn_listener(summatory_handle.clone(), msg_stream); + let ctx = Context::from_ref(&summatory); + spawn_listener(ctx, msg_stream); - // Wait for 1 second so the whole stream is processed rt::sleep(Duration::from_secs(1)).await; - let result = Summatory::get_value(&mut summatory_handle).await; - // Actor should have been terminated, hence the result should be an error + let result = summatory.send_request(GetValue).await; assert!(result.is_err()); }) } @@ -219,21 +203,21 @@ pub fn test_halting_on_stream_error() { pub fn test_skipping_on_stream_error() { let runtime = rt::Runtime::new().unwrap(); runtime.block_on(async move { - let mut summatory_handle = Summatory::new(0).start(); + let summatory = Summatory::new(0).start(); let stream = tokio_stream::iter(vec![Ok(1u16), Ok(2), Ok(3), Err(()), Ok(4), Ok(5)]); let msg_stream = stream.filter_map(|value| async move { match value { - Ok(number) => Some(SummatoryCastMessage::Add(number)), + Ok(number) => Some(StreamMsg::Add(number)), Err(_) => None, } }); - spawn_listener(summatory_handle.clone(), msg_stream); + let ctx = Context::from_ref(&summatory); + spawn_listener(ctx, msg_stream); - // Wait for 1 second so the whole stream is processed rt::sleep(Duration::from_secs(1)).await; - let val = Summatory::get_value(&mut summatory_handle).await.unwrap(); + let val = summatory.send_request(GetValue).await.unwrap(); assert_eq!(val, 15); }) } diff --git a/concurrency/src/tasks/time.rs b/concurrency/src/tasks/time.rs index e334c81..69871e8 100644 --- a/concurrency/src/tasks/time.rs +++ b/concurrency/src/tasks/time.rs @@ -3,7 +3,8 @@ use std::time::Duration; use spawned_rt::tasks::{self as rt, CancellationToken, JoinHandle}; -use super::{Actor, ActorRef}; +use super::actor::{Actor, Context, Handler}; +use crate::message::Message; use core::pin::pin; pub struct TimerHandle { @@ -11,24 +12,22 @@ pub struct TimerHandle { pub cancellation_token: CancellationToken, } -// Sends a message after a given period to the specified Actor. The task terminates -// once the send has completed -pub fn send_after(period: Duration, mut handle: ActorRef, message: T::Message) -> TimerHandle +pub fn send_after(period: Duration, ctx: Context, msg: M) -> TimerHandle where - T: Actor + 'static, + A: Actor + Handler, + M: Message, { let cancellation_token = CancellationToken::new(); let cloned_token = cancellation_token.clone(); - let actor_cancellation_token = handle.cancellation_token(); + let actor_cancellation_token = ctx.cancellation_token(); let join_handle = rt::spawn(async move { - // Timer action is ignored if it was either cancelled or the associated Actor is no longer running. let cancel_token_fut = pin!(cloned_token.cancelled()); let actor_cancel_fut = pin!(actor_cancellation_token.cancelled()); let cancel_conditions = select(cancel_token_fut, actor_cancel_fut); let async_block = pin!(async { rt::sleep(period).await; - let _ = handle.send(message.clone()).await; + let _ = ctx.send(msg); }); let _ = select(cancel_conditions, async_block).await; }); @@ -38,28 +37,24 @@ where } } -// Sends a message to the specified Actor repeatedly after `Time` milliseconds. -pub fn send_interval( - period: Duration, - mut handle: ActorRef, - message: T::Message, -) -> TimerHandle +pub fn send_interval(period: Duration, ctx: Context, msg: M) -> TimerHandle where - T: Actor + 'static, + A: Actor + Handler, + M: Message + Clone, { let cancellation_token = CancellationToken::new(); let cloned_token = cancellation_token.clone(); - let actor_cancellation_token = handle.cancellation_token(); + let actor_cancellation_token = ctx.cancellation_token(); let join_handle = rt::spawn(async move { loop { - // Timer action is ignored if it was either cancelled or the associated Actor is no longer running. let cancel_token_fut = pin!(cloned_token.cancelled()); let actor_cancel_fut = pin!(actor_cancellation_token.cancelled()); let cancel_conditions = select(cancel_token_fut, actor_cancel_fut); + let msg_clone = msg.clone(); let async_block = pin!(async { rt::sleep(period).await; - let _ = handle.send(message.clone()).await; + let _ = ctx.send(msg_clone); }); let result = select(cancel_conditions, async_block).await; match result { diff --git a/concurrency/src/tasks/timer_tests.rs b/concurrency/src/tasks/timer_tests.rs index 46eb664..205d5ba 100644 --- a/concurrency/src/tasks/timer_tests.rs +++ b/concurrency/src/tasks/timer_tests.rs @@ -1,31 +1,27 @@ use super::{ - send_after, send_interval, Actor, ActorRef, InitResult, InitResult::Success, MessageResponse, - RequestResponse, + send_after, send_interval, Actor, ActorStart, Context, Handler, }; +use crate::message::Message; use spawned_rt::tasks::{self as rt, CancellationToken}; use std::time::Duration; -type RepeaterHandle = ActorRef; +// --- Repeater (interval timer test) --- -#[derive(Clone)] -enum RepeaterCastMessage { - Inc, - StopTimer, -} +#[derive(Clone, Debug)] +struct Inc; +impl Message for Inc { type Result = (); } -#[derive(Clone)] -enum RepeaterCallMessage { - GetCount, -} +#[derive(Clone, Debug)] +struct StopTimer; +impl Message for StopTimer { type Result = (); } -#[derive(PartialEq, Debug)] -enum RepeaterOutMessage { - Count(i32), -} +#[derive(Debug)] +struct GetRepCount; +impl Message for GetRepCount { type Result = i32; } struct Repeater { - pub(crate) count: i32, - pub(crate) cancellation_token: Option, + count: i32, + cancellation_token: Option, } impl Repeater { @@ -37,63 +33,34 @@ impl Repeater { } } -impl Repeater { - pub async fn stop_timer(server: &mut RepeaterHandle) -> Result<(), ()> { - server - .send(RepeaterCastMessage::StopTimer) - .await - .map_err(|_| ()) - } - - pub async fn get_count(server: &mut RepeaterHandle) -> Result { - server - .request(RepeaterCallMessage::GetCount) - .await - .map_err(|_| ()) - } -} - impl Actor for Repeater { - type Request = RepeaterCallMessage; - type Message = RepeaterCastMessage; - type Reply = RepeaterOutMessage; - type Error = (); - - async fn init(mut self, handle: &RepeaterHandle) -> Result, Self::Error> { + async fn started(&mut self, ctx: &Context) { let timer = send_interval( Duration::from_millis(100), - handle.clone(), - RepeaterCastMessage::Inc, + ctx.clone(), + Inc, ); self.cancellation_token = Some(timer.cancellation_token); - Ok(Success(self)) } +} + +impl Handler for Repeater { + async fn handle(&mut self, _msg: Inc, _ctx: &Context) { + self.count += 1; + } +} - async fn handle_request( - &mut self, - _message: Self::Request, - _handle: &RepeaterHandle, - ) -> RequestResponse { - let count = self.count; - RequestResponse::Reply(RepeaterOutMessage::Count(count)) +impl Handler for Repeater { + async fn handle(&mut self, _msg: StopTimer, _ctx: &Context) { + if let Some(ct) = self.cancellation_token.clone() { + ct.cancel(); + } } +} - async fn handle_message( - &mut self, - message: Self::Message, - _handle: &ActorRef, - ) -> MessageResponse { - match message { - RepeaterCastMessage::Inc => { - self.count += 1; - } - RepeaterCastMessage::StopTimer => { - if let Some(ct) = self.cancellation_token.clone() { - ct.cancel() - }; - } - }; - MessageResponse::NoReply +impl Handler for Repeater { + async fn handle(&mut self, _msg: GetRepCount, _ctx: &Context) -> i32 { + self.count } } @@ -101,109 +68,60 @@ impl Actor for Repeater { pub fn test_send_interval_and_cancellation() { let runtime = rt::Runtime::new().unwrap(); runtime.block_on(async move { - // Start a Repeater - let mut repeater = Repeater::new(0).start(); + let repeater = Repeater::new(0).start(); - // Wait for 1 second rt::sleep(Duration::from_secs(1)).await; - // Check count - let count = Repeater::get_count(&mut repeater).await.unwrap(); + let count = repeater.send_request(GetRepCount).await.unwrap(); + assert_eq!(9, count); - // 9 messages in 1 second (after first 100 milliseconds sleep) - assert_eq!(RepeaterOutMessage::Count(9), count); + repeater.send(StopTimer).unwrap(); - // Pause timer - Repeater::stop_timer(&mut repeater).await.unwrap(); - - // Wait another second rt::sleep(Duration::from_secs(1)).await; - // Check count again - let count2 = Repeater::get_count(&mut repeater).await.unwrap(); - - // As timer was paused, count should remain at 9 - assert_eq!(RepeaterOutMessage::Count(9), count2); + let count2 = repeater.send_request(GetRepCount).await.unwrap(); + assert_eq!(9, count2); }); } -type DelayedHandle = ActorRef; - -#[derive(Clone)] -enum DelayedCastMessage { - Inc, -} +// --- Delayed (send_after test) --- -#[derive(Clone)] -enum DelayedCallMessage { - GetCount, - Stop, -} +#[derive(Debug)] +struct GetDelCount; +impl Message for GetDelCount { type Result = i32; } -#[derive(PartialEq, Debug)] -enum DelayedOutMessage { - Count(i32), -} +#[derive(Debug)] +struct StopDelayed; +impl Message for StopDelayed { type Result = i32; } struct Delayed { - pub(crate) count: i32, + count: i32, } impl Delayed { pub fn new(initial_count: i32) -> Self { - Delayed { - count: initial_count, - } + Delayed { count: initial_count } } } -impl Delayed { - pub async fn get_count(server: &mut DelayedHandle) -> Result { - server - .request(DelayedCallMessage::GetCount) - .await - .map_err(|_| ()) - } +impl Actor for Delayed {} - pub async fn stop(server: &mut DelayedHandle) -> Result { - server - .request(DelayedCallMessage::Stop) - .await - .map_err(|_| ()) +impl Handler for Delayed { + async fn handle(&mut self, _msg: Inc, _ctx: &Context) { + self.count += 1; } } -impl Actor for Delayed { - type Request = DelayedCallMessage; - type Message = DelayedCastMessage; - type Reply = DelayedOutMessage; - type Error = (); - - async fn handle_request( - &mut self, - message: Self::Request, - _handle: &DelayedHandle, - ) -> RequestResponse { - match message { - DelayedCallMessage::GetCount => { - let count = self.count; - RequestResponse::Reply(DelayedOutMessage::Count(count)) - } - DelayedCallMessage::Stop => RequestResponse::Stop(DelayedOutMessage::Count(self.count)), - } +impl Handler for Delayed { + async fn handle(&mut self, _msg: GetDelCount, _ctx: &Context) -> i32 { + self.count } +} - async fn handle_message( - &mut self, - message: Self::Message, - _handle: &DelayedHandle, - ) -> MessageResponse { - match message { - DelayedCastMessage::Inc => { - self.count += 1; - } - }; - MessageResponse::NoReply +impl Handler for Delayed { + async fn handle(&mut self, _msg: StopDelayed, ctx: &Context) -> i32 { + ctx.stop(); + self.count } } @@ -211,43 +129,33 @@ impl Actor for Delayed { pub fn test_send_after_and_cancellation() { let runtime = rt::Runtime::new().unwrap(); runtime.block_on(async move { - // Start a Delayed - let mut repeater = Delayed::new(0).start(); + let repeater = Delayed::new(0).start(); - // Set a just once timed message + let ctx = Context::from_ref(&repeater); let _ = send_after( Duration::from_millis(100), - repeater.clone(), - DelayedCastMessage::Inc, + ctx, + Inc, ); - // Wait for 200 milliseconds rt::sleep(Duration::from_millis(200)).await; - // Check count - let count = Delayed::get_count(&mut repeater).await.unwrap(); + let count = repeater.send_request(GetDelCount).await.unwrap(); + assert_eq!(1, count); - // Only one message (no repetition) - assert_eq!(DelayedOutMessage::Count(1), count); - - // New timer + let ctx = Context::from_ref(&repeater); let timer = send_after( Duration::from_millis(100), - repeater.clone(), - DelayedCastMessage::Inc, + ctx, + Inc, ); - // Cancel the new timer before timeout timer.cancellation_token.cancel(); - // Wait another 200 milliseconds rt::sleep(Duration::from_millis(200)).await; - // Check count again - let count2 = Delayed::get_count(&mut repeater).await.unwrap(); - - // As timer was cancelled, count should remain at 1 - assert_eq!(DelayedOutMessage::Count(1), count2); + let count2 = repeater.send_request(GetDelCount).await.unwrap(); + assert_eq!(1, count2); }); } @@ -255,39 +163,31 @@ pub fn test_send_after_and_cancellation() { pub fn test_send_after_gen_server_teardown() { let runtime = rt::Runtime::new().unwrap(); runtime.block_on(async move { - // Start a Delayed - let mut repeater = Delayed::new(0).start(); + let repeater = Delayed::new(0).start(); - // Set a just once timed message + let ctx = Context::from_ref(&repeater); let _ = send_after( Duration::from_millis(100), - repeater.clone(), - DelayedCastMessage::Inc, + ctx, + Inc, ); - // Wait for 200 milliseconds rt::sleep(Duration::from_millis(200)).await; - // Check count - let count = Delayed::get_count(&mut repeater).await.unwrap(); - - // Only one message (no repetition) - assert_eq!(DelayedOutMessage::Count(1), count); + let count = repeater.send_request(GetDelCount).await.unwrap(); + assert_eq!(1, count); - // New timer + let ctx = Context::from_ref(&repeater); let _ = send_after( Duration::from_millis(100), - repeater.clone(), - DelayedCastMessage::Inc, + ctx, + Inc, ); - // Stop the Actor before timeout - let count2 = Delayed::stop(&mut repeater).await.unwrap(); + let count2 = repeater.send_request(StopDelayed).await.unwrap(); - // Wait another 200 milliseconds rt::sleep(Duration::from_millis(200)).await; - // As timer was cancelled, count should remain at 1 - assert_eq!(DelayedOutMessage::Count(1), count2); + assert_eq!(1, count2); }); } diff --git a/concurrency/src/threads/actor.rs b/concurrency/src/threads/actor.rs index 04796b7..d92a296 100644 --- a/concurrency/src/threads/actor.rs +++ b/concurrency/src/threads/actor.rs @@ -1,5 +1,3 @@ -//! Actor trait and structs to create an abstraction similar to Erlang gen_server. -//! See examples/name_server for a usage example. use spawned_rt::threads::{ self as rt, mpsc, oneshot, oneshot::RecvTimeoutError, CancellationToken, }; @@ -11,11 +9,174 @@ use std::{ }; use crate::error::ActorError; +use crate::message::Message; const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); -/// Guard that signals completion when dropped. -/// Ensures waiters are notified even if the actor thread panics. +// --------------------------------------------------------------------------- +// Actor trait +// --------------------------------------------------------------------------- + +pub trait Actor: Send + Sized + 'static { + fn started(&mut self, _ctx: &Context) {} + fn stopped(&mut self, _ctx: &Context) {} +} + +// --------------------------------------------------------------------------- +// Handler trait (per-message, sync version) +// --------------------------------------------------------------------------- + +pub trait Handler: Actor { + fn handle(&mut self, msg: M, ctx: &Context) -> M::Result; +} + +// --------------------------------------------------------------------------- +// Envelope (type-erasure) +// --------------------------------------------------------------------------- + +trait Envelope: Send { + fn handle(self: Box, actor: &mut A, ctx: &Context); +} + +struct MessageEnvelope { + msg: M, + tx: Option>, +} + +impl Envelope for MessageEnvelope +where + A: Actor + Handler, + M: Message, +{ + fn handle(self: Box, actor: &mut A, ctx: &Context) { + let result = actor.handle(self.msg, ctx); + if let Some(tx) = self.tx { + let _ = tx.send(result); + } + } +} + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +pub struct Context { + sender: mpsc::Sender + Send>>, + cancellation_token: CancellationToken, +} + +impl Clone for Context { + fn clone(&self) -> Self { + Self { + sender: self.sender.clone(), + cancellation_token: self.cancellation_token.clone(), + } + } +} + +impl Debug for Context { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Context").finish_non_exhaustive() + } +} + +impl Context { + pub fn from_ref(actor_ref: &ActorRef) -> Self { + Self { + sender: actor_ref.sender.clone(), + cancellation_token: actor_ref.cancellation_token.clone(), + } + } + + pub fn stop(&self) { + self.cancellation_token.cancel(); + } + + pub fn send(&self, msg: M) -> Result<(), ActorError> + where + A: Handler, + M: Message, + { + let envelope = MessageEnvelope { msg, tx: None }; + self.sender + .send(Box::new(envelope)) + .map_err(|_| ActorError::ActorStopped) + } + + pub fn request(&self, msg: M) -> Result, ActorError> + where + A: Handler, + M: Message, + { + let (tx, rx) = oneshot::channel(); + let envelope = MessageEnvelope { + msg, + tx: Some(tx), + }; + self.sender + .send(Box::new(envelope)) + .map_err(|_| ActorError::ActorStopped)?; + Ok(rx) + } + + pub fn send_request(&self, msg: M) -> Result + where + A: Handler, + M: Message, + { + self.send_request_with_timeout(msg, DEFAULT_REQUEST_TIMEOUT) + } + + pub fn send_request_with_timeout( + &self, + msg: M, + duration: Duration, + ) -> Result + where + A: Handler, + M: Message, + { + let rx = self.request(msg)?; + match rx.recv_timeout(duration) { + Ok(result) => Ok(result), + Err(RecvTimeoutError::Timeout) => Err(ActorError::RequestTimeout), + Err(RecvTimeoutError::Disconnected) => Err(ActorError::ActorStopped), + } + } + + pub(crate) fn cancellation_token(&self) -> CancellationToken { + self.cancellation_token.clone() + } +} + +// --------------------------------------------------------------------------- +// Receiver trait (object-safe) + Recipient alias +// --------------------------------------------------------------------------- + +pub trait Receiver: Send + Sync { + fn send(&self, msg: M) -> Result<(), ActorError>; + fn request(&self, msg: M) -> Result, ActorError>; +} + +pub type Recipient = Arc>; + +pub fn send_request( + recipient: &dyn Receiver, + msg: M, + timeout: Duration, +) -> Result { + let rx = recipient.request(msg)?; + match rx.recv_timeout(timeout) { + Ok(result) => Ok(result), + Err(RecvTimeoutError::Timeout) => Err(ActorError::RequestTimeout), + Err(RecvTimeoutError::Disconnected) => Err(ActorError::ActorStopped), + } +} + +// --------------------------------------------------------------------------- +// ActorRef +// --------------------------------------------------------------------------- + struct CompletionGuard(Arc<(Mutex, Condvar)>); impl Drop for CompletionGuard { @@ -27,94 +188,93 @@ impl Drop for CompletionGuard { } } -pub struct ActorRef { - pub tx: mpsc::Sender>, +pub struct ActorRef { + sender: mpsc::Sender + Send>>, cancellation_token: CancellationToken, - /// Completion signal: (is_completed, condvar for waiters) completion: Arc<(Mutex, Condvar)>, } +impl Debug for ActorRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ActorRef").finish_non_exhaustive() + } +} + impl Clone for ActorRef { fn clone(&self) -> Self { Self { - tx: self.tx.clone(), + sender: self.sender.clone(), cancellation_token: self.cancellation_token.clone(), completion: self.completion.clone(), } } } -impl std::fmt::Debug for ActorRef { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ActorRef") - .field("tx", &self.tx) - .field("cancellation_token", &self.cancellation_token) - .finish_non_exhaustive() - } -} - impl ActorRef { - pub(crate) fn new(actor: A) -> Self { - let (tx, mut rx) = mpsc::channel::>(); - let cancellation_token = CancellationToken::new(); - let completion = Arc::new((Mutex::new(false), Condvar::new())); - let handle = ActorRef { - tx, - cancellation_token, - completion: completion.clone(), - }; - let handle_clone = handle.clone(); - let _thread_handle = rt::spawn(move || { - // Guard ensures completion is signaled even if actor panics - let _guard = CompletionGuard(completion); - if actor.run(&handle, &mut rx).is_err() { - tracing::trace!("Actor crashed") - }; - }); - handle_clone + pub fn send(&self, msg: M) -> Result<(), ActorError> + where + A: Handler, + M: Message, + { + let envelope = MessageEnvelope { msg, tx: None }; + self.sender + .send(Box::new(envelope)) + .map_err(|_| ActorError::ActorStopped) } - pub fn sender(&self) -> mpsc::Sender> { - self.tx.clone() + pub fn request(&self, msg: M) -> Result, ActorError> + where + A: Handler, + M: Message, + { + let (tx, rx) = oneshot::channel(); + let envelope = MessageEnvelope { + msg, + tx: Some(tx), + }; + self.sender + .send(Box::new(envelope)) + .map_err(|_| ActorError::ActorStopped)?; + Ok(rx) } - pub fn request(&mut self, message: A::Request) -> Result { - self.request_with_timeout(message, DEFAULT_REQUEST_TIMEOUT) + pub fn send_request(&self, msg: M) -> Result + where + A: Handler, + M: Message, + { + self.send_request_with_timeout(msg, DEFAULT_REQUEST_TIMEOUT) } - pub fn request_with_timeout( - &mut self, - message: A::Request, + pub fn send_request_with_timeout( + &self, + msg: M, duration: Duration, - ) -> Result { - let (oneshot_tx, oneshot_rx) = oneshot::channel::>(); - self.tx.send(ActorInMsg::Request { - sender: oneshot_tx, - message, - })?; - match oneshot_rx.recv_timeout(duration) { - Ok(result) => result, + ) -> Result + where + A: Handler, + M: Message, + { + let rx = self.request(msg)?; + match rx.recv_timeout(duration) { + Ok(result) => Ok(result), Err(RecvTimeoutError::Timeout) => Err(ActorError::RequestTimeout), - Err(RecvTimeoutError::Disconnected) => Err(ActorError::Server), + Err(RecvTimeoutError::Disconnected) => Err(ActorError::ActorStopped), } } - pub fn send(&mut self, message: A::Message) -> Result<(), ActorError> { - self.tx - .send(ActorInMsg::Message { message }) - .map_err(|_error| ActorError::Server) + pub fn recipient(&self) -> Recipient + where + A: Handler, + M: Message, + { + Arc::new(self.clone()) } - pub(crate) fn cancellation_token(&self) -> CancellationToken { - self.cancellation_token.clone() + pub fn context(&self) -> Context { + Context::from_ref(self) } - /// Blocks until the actor has stopped. - /// - /// This method blocks the current thread until the actor has finished - /// processing and exited its main loop. Can be called multiple times from - /// different clones of the ActorRef - all callers will be notified when - /// the actor stops. pub fn join(&self) { let (lock, cvar) = &*self.completion; let mut completed = lock.lock().unwrap_or_else(|p| p.into_inner()); @@ -124,191 +284,114 @@ impl ActorRef { } } -pub enum ActorInMsg { - Request { - sender: oneshot::Sender>, - message: A::Request, - }, - Message { - message: A::Message, - }, -} - -pub enum RequestResponse { - Reply(A::Reply), - Unused, - Stop(A::Reply), -} - -pub enum MessageResponse { - NoReply, - Unused, - Stop, -} +// Bridge: ActorRef implements Receiver for any M that A handles +impl Receiver for ActorRef +where + A: Actor + Handler, + M: Message, +{ + fn send(&self, msg: M) -> Result<(), ActorError> { + ActorRef::send(self, msg) + } -pub enum InitResult { - Success(A), - NoSuccess(A), + fn request(&self, msg: M) -> Result, ActorError> { + ActorRef::request(self, msg) + } } -pub trait Actor: Send + Sized { - type Request: Clone + Send + Sized + Sync; - type Message: Clone + Send + Sized + Sync; - type Reply: Send + Sized; - type Error: Debug + Send; +// --------------------------------------------------------------------------- +// Actor startup + main loop +// --------------------------------------------------------------------------- - fn start(self) -> ActorRef { - ActorRef::new(self) - } +impl ActorRef { + fn spawn(actor: A) -> Self { + let (tx, rx) = mpsc::channel:: + Send>>(); + let cancellation_token = CancellationToken::new(); + let completion = Arc::new((Mutex::new(false), Condvar::new())); - fn run( - self, - handle: &ActorRef, - rx: &mut mpsc::Receiver>, - ) -> Result<(), ActorError> { - let cancellation_token = handle.cancellation_token.clone(); - - let res = match self.init(handle) { - Ok(InitResult::Success(new_state)) => { - let final_state = new_state.main_loop(handle, rx)?; - Ok(final_state) - } - Ok(InitResult::NoSuccess(intermediate_state)) => { - // Initialization failed but error was handled in callback. - // Skip main_loop and return state for teardown. - Ok(intermediate_state) - } - Err(err) => { - tracing::error!("Initialization failed with unhandled error: {err:?}"); - Err(ActorError::Initialization) - } + let actor_ref = ActorRef { + sender: tx.clone(), + cancellation_token: cancellation_token.clone(), + completion: completion.clone(), }; - cancellation_token.cancel(); + let ctx = Context { + sender: tx, + cancellation_token: cancellation_token.clone(), + }; - if let Ok(final_state) = res { - if let Err(err) = final_state.teardown(handle) { - tracing::error!("Error during teardown: {err:?}"); - } - } + let _thread_handle = rt::spawn(move || { + let _guard = CompletionGuard(completion); + run_actor(actor, ctx, rx, cancellation_token); + }); - Ok(()) + actor_ref } +} - /// Initialization function. It's called before main loop. It - /// can be overrided on implementations in case initial steps are - /// required. - fn init(self, _handle: &ActorRef) -> Result, Self::Error> { - Ok(InitResult::Success(self)) - } +fn run_actor( + mut actor: A, + ctx: Context, + rx: mpsc::Receiver + Send>>, + cancellation_token: CancellationToken, +) { + actor.started(&ctx); - fn main_loop( - mut self, - handle: &ActorRef, - rx: &mut mpsc::Receiver>, - ) -> Result { - loop { - if !self.receive(handle, rx)? { - break; - } - } - tracing::trace!("Stopping Actor"); - Ok(self) + if cancellation_token.is_cancelled() { + actor.stopped(&ctx); + return; } - fn receive( - &mut self, - handle: &ActorRef, - rx: &mut mpsc::Receiver>, - ) -> Result { - let message = rx.recv().ok(); - - let keep_running = match message { - Some(ActorInMsg::Request { sender, message }) => { - let (keep_running, response) = match catch_unwind(AssertUnwindSafe(|| { - self.handle_request(message, handle) - })) { - Ok(response) => match response { - RequestResponse::Reply(response) => (true, Ok(response)), - RequestResponse::Stop(response) => (false, Ok(response)), - RequestResponse::Unused => { - tracing::error!("Actor received unexpected Request"); - (false, Err(ActorError::RequestUnused)) - } - }, - Err(error) => { - tracing::error!("Error in callback: '{error:?}'"); - (true, Err(ActorError::Callback)) - } - }; - // Send response back - if sender.send(response).is_err() { - tracing::trace!("Actor failed to send response back, client must have died") - }; - keep_running - } - Some(ActorInMsg::Message { message }) => { - match catch_unwind(AssertUnwindSafe(|| self.handle_message(message, handle))) { - Ok(response) => match response { - MessageResponse::NoReply => true, - MessageResponse::Stop => false, - MessageResponse::Unused => { - tracing::error!("Actor received unexpected Message"); - false - } - }, - Err(error) => { - tracing::error!("Error in callback: '{error:?}'"); - true - } + loop { + let msg = rx.recv().ok(); + match msg { + Some(envelope) => { + let result = catch_unwind(AssertUnwindSafe(|| { + envelope.handle(&mut actor, &ctx); + })); + if let Err(panic) = result { + tracing::error!("Panic in message handler: {panic:?}"); + break; + } + if cancellation_token.is_cancelled() { + break; } } - None => { - // Channel has been closed; won't receive further messages. Stop the server. - false - } - }; - Ok(keep_running) + None => break, + } } - fn handle_request( - &mut self, - _message: Self::Request, - _handle: &ActorRef, - ) -> RequestResponse { - RequestResponse::Unused - } + cancellation_token.cancel(); + actor.stopped(&ctx); +} - fn handle_message( - &mut self, - _message: Self::Message, - _handle: &ActorRef, - ) -> MessageResponse { - MessageResponse::Unused - } +// --------------------------------------------------------------------------- +// Actor::start +// --------------------------------------------------------------------------- - /// Teardown function. It's called after the stop message is received. - /// It can be overrided on implementations in case final steps are required, - /// like closing streams, stopping timers, etc. - fn teardown(self, _handle: &ActorRef) -> Result<(), Self::Error> { - Ok(()) +pub trait ActorStart: Actor { + fn start(self) -> ActorRef { + ActorRef::spawn(self) } } -/// Spawns a thread that runs a blocking operation and sends a message to an Actor -/// on completion. This is the sync equivalent of tasks::send_message_on. -/// This function returns a handle to the spawned thread. -pub fn send_message_on(handle: ActorRef, f: F, message: T::Message) -> rt::JoinHandle<()> +impl ActorStart for A {} + +// --------------------------------------------------------------------------- +// send_message_on (utility) +// --------------------------------------------------------------------------- + +pub fn send_message_on(ctx: Context, f: F, msg: M) -> rt::JoinHandle<()> where - T: Actor, + A: Actor + Handler, + M: Message, F: FnOnce() + Send + 'static, { - let cancellation_token = handle.cancellation_token(); - let mut handle_clone = handle.clone(); + let cancellation_token = ctx.cancellation_token(); rt::spawn(move || { f(); if !cancellation_token.is_cancelled() { - if let Err(e) = handle_clone.send(message) { + if let Err(e) = ctx.send(msg) { tracing::error!("Failed to send message: {e:?}") } } diff --git a/concurrency/src/threads/mod.rs b/concurrency/src/threads/mod.rs index 9643a13..535423e 100644 --- a/concurrency/src/threads/mod.rs +++ b/concurrency/src/threads/mod.rs @@ -1,7 +1,4 @@ -//! spawned concurrency -//! IO threads-based traits and structs to implement concurrent code à-la-Erlang. - -mod actor; +pub(crate) mod actor; mod process; mod stream; mod time; @@ -10,9 +7,11 @@ mod time; mod timer_tests; pub use actor::{ - send_message_on, Actor, ActorInMsg, ActorRef, InitResult, InitResult::NoSuccess, - InitResult::Success, MessageResponse, RequestResponse, + send_message_on, Actor, ActorRef, ActorStart, Context, Handler, Receiver, Recipient, + send_request, }; pub use process::{send, Process, ProcessInfo}; pub use stream::spawn_listener; -pub use time::{send_after, send_interval}; +pub use time::{send_after, send_interval, TimerHandle}; + +pub use crate::registry; diff --git a/concurrency/src/threads/stream.rs b/concurrency/src/threads/stream.rs index 696c3cf..9249246 100644 --- a/concurrency/src/threads/stream.rs +++ b/concurrency/src/threads/stream.rs @@ -1,21 +1,21 @@ use std::thread::JoinHandle; -use crate::threads::{Actor, ActorRef}; +use crate::message::Message; -/// Spawns a listener that listens to a stream and sends messages to an Actor. -/// -/// Items sent through the stream are required to be wrapped in a Result type. -pub fn spawn_listener(mut handle: ActorRef, stream: I) -> JoinHandle<()> +use super::actor::{Actor, Context, Handler}; + +pub fn spawn_listener(ctx: Context, stream: I) -> JoinHandle<()> where - T: Actor, - I: IntoIterator, + A: Actor + Handler, + M: Message, + I: IntoIterator, ::IntoIter: std::marker::Send + 'static, { let mut iter = stream.into_iter(); - let cancellation_token = handle.cancellation_token(); + let cancellation_token = ctx.cancellation_token(); let join_handle = spawned_rt::threads::spawn(move || loop { match iter.next() { - Some(msg) => match handle.send(msg) { + Some(msg) => match ctx.send(msg) { Ok(_) => tracing::trace!("Message sent successfully"), Err(e) => { tracing::error!("Failed to send message: {e:?}"); diff --git a/concurrency/src/threads/time.rs b/concurrency/src/threads/time.rs index 5b4ebb8..78fb1cd 100644 --- a/concurrency/src/threads/time.rs +++ b/concurrency/src/threads/time.rs @@ -3,36 +3,30 @@ use std::time::Duration; use spawned_rt::threads::{self as rt, CancellationToken, JoinHandle}; -use super::{Actor, ActorRef}; +use super::actor::{Actor, Context, Handler}; +use crate::message::Message; pub struct TimerHandle { pub join_handle: JoinHandle<()>, pub cancellation_token: CancellationToken, } -/// Sends a message after a given period to the specified Actor. -/// -/// The timer respects both its own cancellation token and the Actor's -/// cancellation token. If either is cancelled, the timer wakes up immediately -/// and exits without sending the message. -pub fn send_after(period: Duration, mut handle: ActorRef, message: T::Message) -> TimerHandle +pub fn send_after(period: Duration, ctx: Context, msg: M) -> TimerHandle where - T: Actor + 'static, + A: Actor + Handler, + M: Message, { let cancellation_token = CancellationToken::new(); let timer_token = cancellation_token.clone(); - let actor_token = handle.cancellation_token(); + let actor_token = ctx.cancellation_token(); - // Channel to wake the timer thread on cancellation let (wake_tx, wake_rx) = mpsc::channel::<()>(); - // Register wake-up on timer cancellation let wake_tx1 = wake_tx.clone(); timer_token.on_cancel(Box::new(move || { let _ = wake_tx1.send(()); })); - // Register wake-up on actor cancellation actor_token.on_cancel(Box::new(move || { let _ = wake_tx.send(()); })); @@ -40,14 +34,11 @@ where let join_handle = rt::spawn(move || { match wake_rx.recv_timeout(period) { Err(RecvTimeoutError::Timeout) => { - // Timer expired - send if still valid if !timer_token.is_cancelled() && !actor_token.is_cancelled() { - let _ = handle.send(message); + let _ = ctx.send(msg); } } - Ok(()) | Err(RecvTimeoutError::Disconnected) => { - // Woken early by cancellation - exit without sending - } + Ok(()) | Err(RecvTimeoutError::Disconnected) => {} } }); @@ -57,46 +48,33 @@ where } } -/// Sends a message to the specified Actor repeatedly at the given interval. -/// -/// The timer respects both its own cancellation token and the Actor's -/// cancellation token. If either is cancelled, the timer wakes up immediately -/// and exits. -pub fn send_interval( - period: Duration, - mut handle: ActorRef, - message: T::Message, -) -> TimerHandle +pub fn send_interval(period: Duration, ctx: Context, msg: M) -> TimerHandle where - T: Actor + 'static, + A: Actor + Handler, + M: Message + Clone, { let cancellation_token = CancellationToken::new(); let timer_token = cancellation_token.clone(); - let actor_token = handle.cancellation_token(); + let actor_token = ctx.cancellation_token(); - // Channel to wake the timer thread on cancellation let (wake_tx, wake_rx) = mpsc::channel::<()>(); - // Register wake-up on timer cancellation let wake_tx1 = wake_tx.clone(); timer_token.on_cancel(Box::new(move || { let _ = wake_tx1.send(()); })); - // Register wake-up on actor cancellation actor_token.on_cancel(Box::new(move || { let _ = wake_tx.send(()); })); let join_handle = rt::spawn(move || { while let Err(RecvTimeoutError::Timeout) = wake_rx.recv_timeout(period) { - // Timer expired - send if still valid if timer_token.is_cancelled() || actor_token.is_cancelled() { break; } - let _ = handle.send(message.clone()); + let _ = ctx.send(msg.clone()); } - // If we exit the loop via Ok(()) or Disconnected, cancellation occurred }); TimerHandle { diff --git a/concurrency/src/threads/timer_tests.rs b/concurrency/src/threads/timer_tests.rs index e023a78..6ba13c9 100644 --- a/concurrency/src/threads/timer_tests.rs +++ b/concurrency/src/threads/timer_tests.rs @@ -1,33 +1,27 @@ use crate::threads::{ - send_interval, Actor, ActorRef, InitResult, MessageResponse, RequestResponse, + send_after, send_interval, Actor, ActorStart, Context, Handler, }; +use crate::message::Message; use spawned_rt::threads::{self as rt, CancellationToken}; use std::time::Duration; -use super::send_after; +// --- Repeater (interval timer test) --- -type RepeaterHandle = ActorRef; +#[derive(Clone, Debug)] +struct Inc; +impl Message for Inc { type Result = (); } -#[derive(Clone)] -enum RepeaterCastMessage { - Inc, - StopTimer, -} - -#[derive(Clone)] -enum RepeaterCallMessage { - GetCount, -} +#[derive(Clone, Debug)] +struct StopTimer; +impl Message for StopTimer { type Result = (); } -#[derive(PartialEq, Debug)] -enum RepeaterOutMessage { - Count(i32), -} +#[derive(Debug)] +struct GetRepCount; +impl Message for GetRepCount { type Result = i32; } -#[derive(Clone)] struct Repeater { - pub(crate) count: i32, - pub(crate) cancellation_token: Option, + count: i32, + cancellation_token: Option, } impl Repeater { @@ -39,240 +33,136 @@ impl Repeater { } } -impl Repeater { - pub fn stop_timer(server: &mut RepeaterHandle) -> Result<(), ()> { - server.send(RepeaterCastMessage::StopTimer).map_err(|_| ()) - } - - pub fn get_count(server: &mut RepeaterHandle) -> Result { - server - .request(RepeaterCallMessage::GetCount) - .map_err(|_| ()) - } -} - impl Actor for Repeater { - type Request = RepeaterCallMessage; - type Message = RepeaterCastMessage; - type Reply = RepeaterOutMessage; - type Error = (); - - fn init(mut self, handle: &RepeaterHandle) -> Result, Self::Error> { + fn started(&mut self, ctx: &Context) { let timer = send_interval( Duration::from_millis(100), - handle.clone(), - RepeaterCastMessage::Inc, + ctx.clone(), + Inc, ); self.cancellation_token = Some(timer.cancellation_token); - Ok(InitResult::Success(self)) } +} + +impl Handler for Repeater { + fn handle(&mut self, _msg: Inc, _ctx: &Context) { + self.count += 1; + } +} - fn handle_request( - &mut self, - _message: Self::Request, - _handle: &RepeaterHandle, - ) -> RequestResponse { - let count = self.count; - RequestResponse::Reply(RepeaterOutMessage::Count(count)) +impl Handler for Repeater { + fn handle(&mut self, _msg: StopTimer, _ctx: &Context) { + if let Some(ct) = self.cancellation_token.clone() { + ct.cancel(); + } } +} - fn handle_message( - &mut self, - message: Self::Message, - _handle: &ActorRef, - ) -> MessageResponse { - match message { - RepeaterCastMessage::Inc => { - self.count += 1; - } - RepeaterCastMessage::StopTimer => { - if let Some(ct) = self.cancellation_token.clone() { - ct.cancel() - }; - } - }; - MessageResponse::NoReply +impl Handler for Repeater { + fn handle(&mut self, _msg: GetRepCount, _ctx: &Context) -> i32 { + self.count } } #[test] pub fn test_send_interval_and_cancellation() { - // Start a Repeater - let mut repeater = Repeater::new(0).start(); + let repeater = Repeater::new(0).start(); - // Wait for 1 second rt::sleep(Duration::from_secs(1)); - // Check count - let count = Repeater::get_count(&mut repeater).unwrap(); - - // 9 messages in 1 second (after first 100 milliseconds sleep) - assert_eq!(RepeaterOutMessage::Count(9), count); + let count = repeater.send_request(GetRepCount).unwrap(); + assert_eq!(9, count); - // Pause timer - Repeater::stop_timer(&mut repeater).unwrap(); + repeater.send(StopTimer).unwrap(); - // Wait another second rt::sleep(Duration::from_secs(1)); - // Check count again - let count2 = Repeater::get_count(&mut repeater).unwrap(); - - // As timer was paused, count should remain at 9 - assert_eq!(RepeaterOutMessage::Count(9), count2); + let count2 = repeater.send_request(GetRepCount).unwrap(); + assert_eq!(9, count2); } -type DelayedHandle = ActorRef; - -#[derive(Clone)] -enum DelayedCastMessage { - Inc, -} +// --- Delayed (send_after test) --- -#[derive(Clone)] -enum DelayedCallMessage { - GetCount, - Stop, -} +#[derive(Debug)] +struct GetDelCount; +impl Message for GetDelCount { type Result = i32; } -#[derive(PartialEq, Debug)] -enum DelayedOutMessage { - Count(i32), -} +#[derive(Debug)] +struct StopDelayed; +impl Message for StopDelayed { type Result = i32; } -#[derive(Clone)] struct Delayed { - pub(crate) count: i32, + count: i32, } impl Delayed { pub fn new(initial_count: i32) -> Self { - Delayed { - count: initial_count, - } + Delayed { count: initial_count } } } -impl Delayed { - pub fn get_count(server: &mut DelayedHandle) -> Result { - server.request(DelayedCallMessage::GetCount).map_err(|_| ()) - } +impl Actor for Delayed {} - pub fn stop(server: &mut DelayedHandle) -> Result { - server.request(DelayedCallMessage::Stop).map_err(|_| ()) +impl Handler for Delayed { + fn handle(&mut self, _msg: Inc, _ctx: &Context) { + self.count += 1; } } -impl Actor for Delayed { - type Request = DelayedCallMessage; - type Message = DelayedCastMessage; - type Reply = DelayedOutMessage; - type Error = (); - - fn handle_request( - &mut self, - message: Self::Request, - _handle: &DelayedHandle, - ) -> RequestResponse { - match message { - DelayedCallMessage::GetCount => { - RequestResponse::Reply(DelayedOutMessage::Count(self.count)) - } - DelayedCallMessage::Stop => { - RequestResponse::Stop(DelayedOutMessage::Count(self.count)) - } - } +impl Handler for Delayed { + fn handle(&mut self, _msg: GetDelCount, _ctx: &Context) -> i32 { + self.count } +} - fn handle_message( - &mut self, - message: Self::Message, - _handle: &DelayedHandle, - ) -> MessageResponse { - match message { - DelayedCastMessage::Inc => { - self.count += 1; - } - }; - MessageResponse::NoReply +impl Handler for Delayed { + fn handle(&mut self, _msg: StopDelayed, ctx: &Context) -> i32 { + ctx.stop(); + self.count } } #[test] pub fn test_send_after_and_cancellation() { - // Start a Delayed - let mut repeater = Delayed::new(0).start(); + let actor = Delayed::new(0).start(); - // Set a just once timed message - let _ = send_after( - Duration::from_millis(100), - repeater.clone(), - DelayedCastMessage::Inc, - ); + let ctx = Context::from_ref(&actor); + let _ = send_after(Duration::from_millis(100), ctx, Inc); - // Wait for 200 milliseconds rt::sleep(Duration::from_millis(200)); - // Check count - let count = Delayed::get_count(&mut repeater).unwrap(); - - // Only one message (no repetition) - assert_eq!(DelayedOutMessage::Count(1), count); + let count = actor.send_request(GetDelCount).unwrap(); + assert_eq!(1, count); - // New timer - let timer = send_after( - Duration::from_millis(100), - repeater.clone(), - DelayedCastMessage::Inc, - ); + let ctx = Context::from_ref(&actor); + let timer = send_after(Duration::from_millis(100), ctx, Inc); - // Cancel the new timer before timeout timer.cancellation_token.cancel(); - // Wait another 200 milliseconds rt::sleep(Duration::from_millis(200)); - // Check count again - let count2 = Delayed::get_count(&mut repeater).unwrap(); - - // As timer was cancelled, count should remain at 1 - assert_eq!(DelayedOutMessage::Count(1), count2); + let count2 = actor.send_request(GetDelCount).unwrap(); + assert_eq!(1, count2); } #[test] pub fn test_send_after_actor_shutdown() { - // Start a Delayed - let mut actor = Delayed::new(0).start(); + let actor = Delayed::new(0).start(); - // Set a just once timed message - let _ = send_after( - Duration::from_millis(100), - actor.clone(), - DelayedCastMessage::Inc, - ); + let ctx = Context::from_ref(&actor); + let _ = send_after(Duration::from_millis(100), ctx, Inc); - // Wait for 200 milliseconds rt::sleep(Duration::from_millis(200)); - // Check count - let count = Delayed::get_count(&mut actor).unwrap(); - - // Only one message (no repetition) - assert_eq!(DelayedOutMessage::Count(1), count); + let count = actor.send_request(GetDelCount).unwrap(); + assert_eq!(1, count); - // New timer with long delay - let _ = send_after( - Duration::from_millis(100), - actor.clone(), - DelayedCastMessage::Inc, - ); + let ctx = Context::from_ref(&actor); + let _ = send_after(Duration::from_millis(100), ctx, Inc); - // Stop the Actor before timeout - this should wake up the timer immediately - let count2 = Delayed::stop(&mut actor).unwrap(); + let count2 = actor.send_request(StopDelayed).unwrap(); - // Wait another 200 milliseconds rt::sleep(Duration::from_millis(200)); - // As actor was stopped, count should remain at 1 (timer didn't fire) - assert_eq!(DelayedOutMessage::Count(1), count2); + assert_eq!(1, count2); } diff --git a/examples/bank/src/main.rs b/examples/bank/src/main.rs index d3321af..a71d158 100644 --- a/examples/bank/src/main.rs +++ b/examples/bank/src/main.rs @@ -24,16 +24,16 @@ mod server; use messages::{BankError, BankOutMessage}; use server::Bank; -use spawned_concurrency::tasks::Actor as _; +use spawned_concurrency::tasks::ActorStart; use spawned_rt::tasks as rt; fn main() { rt::run(async { // Starting the bank - let mut name_server = Bank::new().start(); + let name_server = Bank::new().start(); // Testing initial balance for "main" account - let result = Bank::withdraw(&mut name_server, "main".to_string(), 15).await; + let result = Bank::withdraw(&name_server, "main".to_string(), 15).await; tracing::info!("Withdraw result {result:?}"); assert_eq!( result, @@ -46,17 +46,17 @@ fn main() { let joe = "Joe".to_string(); // Error on deposit for an unexistent account - let result = Bank::deposit(&mut name_server, joe.clone(), 10).await; + let result = Bank::deposit(&name_server, joe.clone(), 10).await; tracing::info!("Deposit result {result:?}"); assert_eq!(result, Err(BankError::NotACustomer { who: joe.clone() })); // Account creation - let result = Bank::new_account(&mut name_server, "Joe".to_string()).await; + let result = Bank::new_account(&name_server, "Joe".to_string()).await; tracing::info!("New account result {result:?}"); assert_eq!(result, Ok(BankOutMessage::Welcome { who: joe.clone() })); // Deposit - let result = Bank::deposit(&mut name_server, "Joe".to_string(), 10).await; + let result = Bank::deposit(&name_server, "Joe".to_string(), 10).await; tracing::info!("Deposit result {result:?}"); assert_eq!( result, @@ -67,7 +67,7 @@ fn main() { ); // Deposit - let result = Bank::deposit(&mut name_server, "Joe".to_string(), 30).await; + let result = Bank::deposit(&name_server, "Joe".to_string(), 30).await; tracing::info!("Deposit result {result:?}"); assert_eq!( result, @@ -78,7 +78,7 @@ fn main() { ); // Withdrawal - let result = Bank::withdraw(&mut name_server, "Joe".to_string(), 15).await; + let result = Bank::withdraw(&name_server, "Joe".to_string(), 15).await; tracing::info!("Withdraw result {result:?}"); assert_eq!( result, @@ -89,7 +89,7 @@ fn main() { ); // Withdrawal with not enough balance - let result = Bank::withdraw(&mut name_server, "Joe".to_string(), 45).await; + let result = Bank::withdraw(&name_server, "Joe".to_string(), 45).await; tracing::info!("Withdraw result {result:?}"); assert_eq!( result, @@ -100,7 +100,7 @@ fn main() { ); // Full withdrawal - let result = Bank::withdraw(&mut name_server, "Joe".to_string(), 25).await; + let result = Bank::withdraw(&name_server, "Joe".to_string(), 25).await; tracing::info!("Withdraw result {result:?}"); assert_eq!( result, @@ -111,7 +111,7 @@ fn main() { ); // Stopping the bank - let result = Bank::stop(&mut name_server).await; + let result = Bank::stop(&name_server).await; tracing::info!("Stop result {result:?}"); assert_eq!(result, Ok(BankOutMessage::Stopped)); }) diff --git a/examples/bank/src/messages.rs b/examples/bank/src/messages.rs index d58ae9d..bbee592 100644 --- a/examples/bank/src/messages.rs +++ b/examples/bank/src/messages.rs @@ -1,12 +1,5 @@ -#[derive(Debug, Clone)] -pub enum BankInMessage { - New { who: String }, - Add { who: String, amount: i32 }, - Remove { who: String, amount: i32 }, - Stop, -} +use spawned_concurrency::message::Message; -#[allow(dead_code)] #[derive(Debug, Clone, PartialEq)] pub enum BankOutMessage { Welcome { who: String }, @@ -15,7 +8,6 @@ pub enum BankOutMessage { Stopped, } -#[allow(dead_code)] #[derive(Debug, Clone, PartialEq)] pub enum BankError { AlreadyACustomer { who: String }, @@ -23,3 +15,37 @@ pub enum BankError { InsufficientBalance { who: String, amount: i32 }, ServerError, } + +type MsgResult = Result; + +#[derive(Debug)] +pub struct NewAccount { + pub who: String, +} +impl Message for NewAccount { + type Result = MsgResult; +} + +#[derive(Debug)] +pub struct Deposit { + pub who: String, + pub amount: i32, +} +impl Message for Deposit { + type Result = MsgResult; +} + +#[derive(Debug)] +pub struct Withdraw { + pub who: String, + pub amount: i32, +} +impl Message for Withdraw { + type Result = MsgResult; +} + +#[derive(Debug)] +pub struct Stop; +impl Message for Stop { + type Result = MsgResult; +} diff --git a/examples/bank/src/server.rs b/examples/bank/src/server.rs index bd2bfed..8393a73 100644 --- a/examples/bank/src/server.rs +++ b/examples/bank/src/server.rs @@ -1,18 +1,10 @@ use std::collections::HashMap; -use spawned_concurrency::{ - messages::Unused, - tasks::{ - Actor, ActorRef, - InitResult::{self, Success}, - RequestResponse, - }, -}; +use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; -use crate::messages::{BankError, BankInMessage as InMessage, BankOutMessage as OutMessage}; +use crate::messages::{BankError, BankOutMessage as OutMessage, Deposit, NewAccount, Stop, Withdraw}; type MsgResult = Result; -type BankHandle = ActorRef; pub struct Bank { accounts: HashMap, @@ -27,89 +19,95 @@ impl Bank { } impl Bank { - pub async fn stop(server: &mut BankHandle) -> MsgResult { + pub async fn stop(server: &ActorRef) -> MsgResult { server - .request(InMessage::Stop) + .send_request(Stop) .await .unwrap_or(Err(BankError::ServerError)) } - pub async fn new_account(server: &mut BankHandle, who: String) -> MsgResult { + pub async fn new_account(server: &ActorRef, who: String) -> MsgResult { server - .request(InMessage::New { who }) + .send_request(NewAccount { who }) .await .unwrap_or(Err(BankError::ServerError)) } - pub async fn deposit(server: &mut BankHandle, who: String, amount: i32) -> MsgResult { + pub async fn deposit(server: &ActorRef, who: String, amount: i32) -> MsgResult { server - .request(InMessage::Add { who, amount }) + .send_request(Deposit { who, amount }) .await .unwrap_or(Err(BankError::ServerError)) } - pub async fn withdraw(server: &mut BankHandle, who: String, amount: i32) -> MsgResult { + pub async fn withdraw(server: &ActorRef, who: String, amount: i32) -> MsgResult { server - .request(InMessage::Remove { who, amount }) + .send_request(Withdraw { who, amount }) .await .unwrap_or(Err(BankError::ServerError)) } } impl Actor for Bank { - type Request = InMessage; - type Message = Unused; - type Reply = MsgResult; - type Error = BankError; - - // Initializing "main" account with 1000 in balance to test init() callback. - async fn init(mut self, _handle: &ActorRef) -> Result, Self::Error> { + async fn started(&mut self, _ctx: &Context) { self.accounts.insert("main".to_string(), 1000); - Ok(Success(self)) } +} - async fn handle_request( - &mut self, - message: Self::Request, - _handle: &BankHandle, - ) -> RequestResponse { - match message.clone() { - Self::Request::New { who } => match self.accounts.get(&who) { - Some(_amount) => RequestResponse::Reply(Err(BankError::AlreadyACustomer { who })), - None => { - self.accounts.insert(who.clone(), 0); - RequestResponse::Reply(Ok(OutMessage::Welcome { who })) - } - }, - Self::Request::Add { who, amount } => match self.accounts.get(&who) { - Some(current) => { - let new_amount = current + amount; - self.accounts.insert(who.clone(), new_amount); - RequestResponse::Reply(Ok(OutMessage::Balance { - who, +impl Handler for Bank { + async fn handle(&mut self, msg: NewAccount, _ctx: &Context) -> MsgResult { + match self.accounts.get(&msg.who) { + Some(_) => Err(BankError::AlreadyACustomer { who: msg.who }), + None => { + self.accounts.insert(msg.who.clone(), 0); + Ok(OutMessage::Welcome { who: msg.who }) + } + } + } +} + +impl Handler for Bank { + async fn handle(&mut self, msg: Deposit, _ctx: &Context) -> MsgResult { + match self.accounts.get(&msg.who) { + Some(current) => { + let new_amount = current + msg.amount; + self.accounts.insert(msg.who.clone(), new_amount); + Ok(OutMessage::Balance { + who: msg.who, + amount: new_amount, + }) + } + None => Err(BankError::NotACustomer { who: msg.who }), + } + } +} + +impl Handler for Bank { + async fn handle(&mut self, msg: Withdraw, _ctx: &Context) -> MsgResult { + match self.accounts.get(&msg.who) { + Some(¤t) => { + if current < msg.amount { + Err(BankError::InsufficientBalance { + who: msg.who, + amount: current, + }) + } else { + let new_amount = current - msg.amount; + self.accounts.insert(msg.who.clone(), new_amount); + Ok(OutMessage::WidrawOk { + who: msg.who, amount: new_amount, - })) + }) } - None => RequestResponse::Reply(Err(BankError::NotACustomer { who })), - }, - Self::Request::Remove { who, amount } => match self.accounts.get(&who) { - Some(¤t) => match current < amount { - true => RequestResponse::Reply(Err(BankError::InsufficientBalance { - who, - amount: current, - })), - false => { - let new_amount = current - amount; - self.accounts.insert(who.clone(), new_amount); - RequestResponse::Reply(Ok(OutMessage::WidrawOk { - who, - amount: new_amount, - })) - } - }, - None => RequestResponse::Reply(Err(BankError::NotACustomer { who })), - }, - Self::Request::Stop => RequestResponse::Stop(Ok(OutMessage::Stopped)), + } + None => Err(BankError::NotACustomer { who: msg.who }), } } } + +impl Handler for Bank { + async fn handle(&mut self, _msg: Stop, ctx: &Context) -> MsgResult { + ctx.stop(); + Ok(OutMessage::Stopped) + } +} diff --git a/examples/bank_threads/src/main.rs b/examples/bank_threads/src/main.rs index 9b89c54..aa67b4b 100644 --- a/examples/bank_threads/src/main.rs +++ b/examples/bank_threads/src/main.rs @@ -24,16 +24,16 @@ mod server; use messages::{BankError, BankOutMessage}; use server::Bank; -use spawned_concurrency::threads::Actor as _; +use spawned_concurrency::threads::ActorStart; use spawned_rt::threads as rt; fn main() { rt::run(|| { // Starting the bank - let mut name_server = Bank::new().start(); + let name_server = Bank::new().start(); // Testing initial balance for "main" account - let result = Bank::withdraw(&mut name_server, "main".to_string(), 15); + let result = Bank::withdraw(&name_server, "main".to_string(), 15); tracing::info!("Withdraw result {result:?}"); assert_eq!( result, @@ -46,17 +46,17 @@ fn main() { let joe = "Joe".to_string(); // Error on deposit for an unexistent account - let result = Bank::deposit(&mut name_server, joe.clone(), 10); + let result = Bank::deposit(&name_server, joe.clone(), 10); tracing::info!("Deposit result {result:?}"); assert_eq!(result, Err(BankError::NotACustomer { who: joe.clone() })); // Account creation - let result = Bank::new_account(&mut name_server, "Joe".to_string()); + let result = Bank::new_account(&name_server, "Joe".to_string()); tracing::info!("New account result {result:?}"); assert_eq!(result, Ok(BankOutMessage::Welcome { who: joe.clone() })); // Deposit - let result = Bank::deposit(&mut name_server, "Joe".to_string(), 10); + let result = Bank::deposit(&name_server, "Joe".to_string(), 10); tracing::info!("Deposit result {result:?}"); assert_eq!( result, @@ -67,7 +67,7 @@ fn main() { ); // Deposit - let result = Bank::deposit(&mut name_server, "Joe".to_string(), 30); + let result = Bank::deposit(&name_server, "Joe".to_string(), 30); tracing::info!("Deposit result {result:?}"); assert_eq!( result, @@ -78,7 +78,7 @@ fn main() { ); // Withdrawal - let result = Bank::withdraw(&mut name_server, "Joe".to_string(), 15); + let result = Bank::withdraw(&name_server, "Joe".to_string(), 15); tracing::info!("Withdraw result {result:?}"); assert_eq!( result, @@ -89,7 +89,7 @@ fn main() { ); // Withdrawal with not enough balance - let result = Bank::withdraw(&mut name_server, "Joe".to_string(), 45); + let result = Bank::withdraw(&name_server, "Joe".to_string(), 45); tracing::info!("Withdraw result {result:?}"); assert_eq!( result, @@ -100,7 +100,7 @@ fn main() { ); // Full withdrawal - let result = Bank::withdraw(&mut name_server, "Joe".to_string(), 25); + let result = Bank::withdraw(&name_server, "Joe".to_string(), 25); tracing::info!("Withdraw result {result:?}"); assert_eq!( result, @@ -111,7 +111,7 @@ fn main() { ); // Stopping the bank - let result = Bank::stop(&mut name_server); + let result = Bank::stop(&name_server); tracing::info!("Stop result {result:?}"); assert_eq!(result, Ok(BankOutMessage::Stopped)); }) diff --git a/examples/bank_threads/src/messages.rs b/examples/bank_threads/src/messages.rs index d58ae9d..bbee592 100644 --- a/examples/bank_threads/src/messages.rs +++ b/examples/bank_threads/src/messages.rs @@ -1,12 +1,5 @@ -#[derive(Debug, Clone)] -pub enum BankInMessage { - New { who: String }, - Add { who: String, amount: i32 }, - Remove { who: String, amount: i32 }, - Stop, -} +use spawned_concurrency::message::Message; -#[allow(dead_code)] #[derive(Debug, Clone, PartialEq)] pub enum BankOutMessage { Welcome { who: String }, @@ -15,7 +8,6 @@ pub enum BankOutMessage { Stopped, } -#[allow(dead_code)] #[derive(Debug, Clone, PartialEq)] pub enum BankError { AlreadyACustomer { who: String }, @@ -23,3 +15,37 @@ pub enum BankError { InsufficientBalance { who: String, amount: i32 }, ServerError, } + +type MsgResult = Result; + +#[derive(Debug)] +pub struct NewAccount { + pub who: String, +} +impl Message for NewAccount { + type Result = MsgResult; +} + +#[derive(Debug)] +pub struct Deposit { + pub who: String, + pub amount: i32, +} +impl Message for Deposit { + type Result = MsgResult; +} + +#[derive(Debug)] +pub struct Withdraw { + pub who: String, + pub amount: i32, +} +impl Message for Withdraw { + type Result = MsgResult; +} + +#[derive(Debug)] +pub struct Stop; +impl Message for Stop { + type Result = MsgResult; +} diff --git a/examples/bank_threads/src/server.rs b/examples/bank_threads/src/server.rs index 5edf5f7..ec231dc 100644 --- a/examples/bank_threads/src/server.rs +++ b/examples/bank_threads/src/server.rs @@ -1,16 +1,11 @@ use std::collections::HashMap; -use spawned_concurrency::{ - messages::Unused, - threads::{Actor, ActorRef, InitResult, RequestResponse}, -}; +use spawned_concurrency::threads::{Actor, ActorRef, Context, Handler}; -use crate::messages::{BankError, BankInMessage as InMessage, BankOutMessage as OutMessage}; +use crate::messages::{BankError, BankOutMessage as OutMessage, Deposit, NewAccount, Stop, Withdraw}; type MsgResult = Result; -type BankHandle = ActorRef; -#[derive(Clone)] pub struct Bank { accounts: HashMap, } @@ -24,85 +19,91 @@ impl Bank { } impl Bank { - pub fn stop(server: &mut BankHandle) -> MsgResult { + pub fn stop(server: &ActorRef) -> MsgResult { server - .request(InMessage::Stop) + .send_request(Stop) .unwrap_or(Err(BankError::ServerError)) } - pub fn new_account(server: &mut BankHandle, who: String) -> MsgResult { + pub fn new_account(server: &ActorRef, who: String) -> MsgResult { server - .request(InMessage::New { who }) + .send_request(NewAccount { who }) .unwrap_or(Err(BankError::ServerError)) } - pub fn deposit(server: &mut BankHandle, who: String, amount: i32) -> MsgResult { + pub fn deposit(server: &ActorRef, who: String, amount: i32) -> MsgResult { server - .request(InMessage::Add { who, amount }) + .send_request(Deposit { who, amount }) .unwrap_or(Err(BankError::ServerError)) } - pub fn withdraw(server: &mut BankHandle, who: String, amount: i32) -> MsgResult { + pub fn withdraw(server: &ActorRef, who: String, amount: i32) -> MsgResult { server - .request(InMessage::Remove { who, amount }) + .send_request(Withdraw { who, amount }) .unwrap_or(Err(BankError::ServerError)) } } impl Actor for Bank { - type Request = InMessage; - type Message = Unused; - type Reply = MsgResult; - type Error = BankError; - - // Initializing "main" account with 1000 in balance to test init() callback. - fn init(mut self, _handle: &ActorRef) -> Result, Self::Error> { + fn started(&mut self, _ctx: &Context) { self.accounts.insert("main".to_string(), 1000); - Ok(InitResult::Success(self)) } +} - fn handle_request( - &mut self, - message: Self::Request, - _handle: &BankHandle, - ) -> RequestResponse { - match message.clone() { - Self::Request::New { who } => match self.accounts.get(&who) { - Some(_amount) => RequestResponse::Reply(Err(BankError::AlreadyACustomer { who })), - None => { - self.accounts.insert(who.clone(), 0); - RequestResponse::Reply(Ok(OutMessage::Welcome { who })) - } - }, - Self::Request::Add { who, amount } => match self.accounts.get(&who) { - Some(current) => { - let new_amount = current + amount; - self.accounts.insert(who.clone(), new_amount); - RequestResponse::Reply(Ok(OutMessage::Balance { - who, +impl Handler for Bank { + fn handle(&mut self, msg: NewAccount, _ctx: &Context) -> MsgResult { + match self.accounts.get(&msg.who) { + Some(_) => Err(BankError::AlreadyACustomer { who: msg.who }), + None => { + self.accounts.insert(msg.who.clone(), 0); + Ok(OutMessage::Welcome { who: msg.who }) + } + } + } +} + +impl Handler for Bank { + fn handle(&mut self, msg: Deposit, _ctx: &Context) -> MsgResult { + match self.accounts.get(&msg.who) { + Some(current) => { + let new_amount = current + msg.amount; + self.accounts.insert(msg.who.clone(), new_amount); + Ok(OutMessage::Balance { + who: msg.who, + amount: new_amount, + }) + } + None => Err(BankError::NotACustomer { who: msg.who }), + } + } +} + +impl Handler for Bank { + fn handle(&mut self, msg: Withdraw, _ctx: &Context) -> MsgResult { + match self.accounts.get(&msg.who) { + Some(¤t) => { + if current < msg.amount { + Err(BankError::InsufficientBalance { + who: msg.who, + amount: current, + }) + } else { + let new_amount = current - msg.amount; + self.accounts.insert(msg.who.clone(), new_amount); + Ok(OutMessage::WidrawOk { + who: msg.who, amount: new_amount, - })) + }) } - None => RequestResponse::Reply(Err(BankError::NotACustomer { who })), - }, - Self::Request::Remove { who, amount } => match self.accounts.get(&who) { - Some(¤t) => match current < amount { - true => RequestResponse::Reply(Err(BankError::InsufficientBalance { - who, - amount: current, - })), - false => { - let new_amount = current - amount; - self.accounts.insert(who.clone(), new_amount); - RequestResponse::Reply(Ok(OutMessage::WidrawOk { - who, - amount: new_amount, - })) - } - }, - None => RequestResponse::Reply(Err(BankError::NotACustomer { who })), - }, - Self::Request::Stop => RequestResponse::Stop(Ok(OutMessage::Stopped)), + } + None => Err(BankError::NotACustomer { who: msg.who }), } } } + +impl Handler for Bank { + fn handle(&mut self, _msg: Stop, ctx: &Context) -> MsgResult { + ctx.stop(); + Ok(OutMessage::Stopped) + } +} diff --git a/examples/blocking_genserver/main.rs b/examples/blocking_genserver/main.rs index f1ec820..37d00d0 100644 --- a/examples/blocking_genserver/main.rs +++ b/examples/blocking_genserver/main.rs @@ -2,11 +2,15 @@ use spawned_rt::tasks as rt; use std::time::Duration; use std::{process::exit, thread}; -use spawned_concurrency::tasks::{ - Actor, ActorRef, Backend, MessageResponse, RequestResponse, send_after, -}; +use spawned_concurrency::tasks::{Actor, ActorStart, Backend, Context, Handler, send_after}; + +spawned_concurrency::messages! { + GetCount -> u64; + StopActor -> u64; + BadWork -> (); + GoodWork -> () +} -// We test a scenario with a badly behaved task struct BadlyBehavedTask; impl BadlyBehavedTask { @@ -15,32 +19,17 @@ impl BadlyBehavedTask { } } -#[derive(Clone)] -pub enum InMessage { - GetCount, - Stop, -} +impl Actor for BadlyBehavedTask {} -#[derive(Clone)] -pub enum OutMsg { - Count(u64), -} - -impl Actor for BadlyBehavedTask { - type Request = InMessage; - type Message = (); - type Reply = (); - type Error = (); - - async fn handle_request( - &mut self, - _: Self::Request, - _: &ActorRef, - ) -> RequestResponse { - RequestResponse::Stop(()) +impl Handler for BadlyBehavedTask { + async fn handle(&mut self, _: StopActor, ctx: &Context) -> u64 { + ctx.stop(); + 0 } +} - async fn handle_message(&mut self, _: Self::Message, _: &ActorRef) -> MessageResponse { +impl Handler for BadlyBehavedTask { + async fn handle(&mut self, _: BadWork, _ctx: &Context) { rt::sleep(Duration::from_millis(20)).await; loop { println!("{:?}: bad still alive", thread::current().id()); @@ -61,35 +50,26 @@ impl WellBehavedTask { } } -impl Actor for WellBehavedTask { - type Request = InMessage; - type Message = (); - type Reply = OutMsg; - type Error = (); - - async fn handle_request( - &mut self, - message: Self::Request, - _: &ActorRef, - ) -> RequestResponse { - match message { - InMessage::GetCount => { - let count = self.count; - RequestResponse::Reply(OutMsg::Count(count)) - } - InMessage::Stop => RequestResponse::Stop(OutMsg::Count(self.count)), - } +impl Actor for WellBehavedTask {} + +impl Handler for WellBehavedTask { + async fn handle(&mut self, _: GetCount, _ctx: &Context) -> u64 { + self.count + } +} + +impl Handler for WellBehavedTask { + async fn handle(&mut self, _: StopActor, ctx: &Context) -> u64 { + ctx.stop(); + self.count } +} - async fn handle_message( - &mut self, - _: Self::Message, - handle: &ActorRef, - ) -> MessageResponse { +impl Handler for WellBehavedTask { + async fn handle(&mut self, _: GoodWork, ctx: &Context) { self.count += 1; println!("{:?}: good still alive", thread::current().id()); - send_after(Duration::from_millis(100), handle.to_owned(), ()); - MessageResponse::NoReply + send_after(Duration::from_millis(100), ctx.clone(), GoodWork); } } @@ -99,20 +79,16 @@ impl Actor for WellBehavedTask { pub fn main() { rt::run(async move { // If we change BadlyBehavedTask to Backend::Async instead, it can stop the entire program - let mut badboy = BadlyBehavedTask::new().start_with_backend(Backend::Thread); - let _ = badboy.send(()).await; - let mut goodboy = WellBehavedTask::new(0).start(); - let _ = goodboy.send(()).await; + let badboy = BadlyBehavedTask::new().start_with_backend(Backend::Thread); + let _ = badboy.send(BadWork); + let goodboy = WellBehavedTask::new(0).start(); + let _ = goodboy.send(GoodWork); rt::sleep(Duration::from_secs(1)).await; - let count = goodboy.request(InMessage::GetCount).await.unwrap(); + let count = goodboy.send_request(GetCount).await.unwrap(); - match count { - OutMsg::Count(num) => { - assert!(num == 10); - } - } + assert!(count == 10); - goodboy.request(InMessage::Stop).await.unwrap(); + goodboy.send_request(StopActor).await.unwrap(); exit(0); }) } diff --git a/examples/busy_genserver_warning/main.rs b/examples/busy_genserver_warning/main.rs index cf83573..62c026f 100644 --- a/examples/busy_genserver_warning/main.rs +++ b/examples/busy_genserver_warning/main.rs @@ -3,9 +3,13 @@ use std::time::Duration; use std::{process::exit, thread}; use tracing::info; -use spawned_concurrency::tasks::{Actor, ActorRef, MessageResponse, RequestResponse}; +use spawned_concurrency::tasks::{Actor, ActorStart, Context, Handler}; + +spawned_concurrency::messages! { + StopBusy -> (); + BusyWork -> () +} -// We test a scenario with a badly behaved task struct BusyWorker; impl BusyWorker { @@ -14,43 +18,22 @@ impl BusyWorker { } } -#[derive(Clone)] -pub enum InMessage { - GetCount, - Stop, -} +impl Actor for BusyWorker {} -#[derive(Clone)] -pub enum OutMsg { - Count(u64), -} - -impl Actor for BusyWorker { - type Request = InMessage; - type Message = (); - type Reply = (); - type Error = (); - - async fn handle_request( - &mut self, - _: Self::Request, - _: &ActorRef, - ) -> RequestResponse { - RequestResponse::Stop(()) +impl Handler for BusyWorker { + async fn handle(&mut self, _: StopBusy, ctx: &Context) { + ctx.stop(); } +} - async fn handle_message( - &mut self, - _: Self::Message, - handle: &ActorRef, - ) -> MessageResponse { +impl Handler for BusyWorker { + async fn handle(&mut self, _: BusyWork, ctx: &Context) { info!(taskid = ?rt::task_id(), "sleeping"); thread::sleep(Duration::from_millis(542)); - handle.clone().send(()).await.unwrap(); + ctx.send(BusyWork).unwrap(); // This sleep is needed to yield control to the runtime. // If not, the future never returns and the warning isn't emitted. rt::sleep(Duration::from_millis(0)).await; - MessageResponse::NoReply } } @@ -64,8 +47,8 @@ impl Actor for BusyWorker { pub fn main() { rt::run(async move { // If we change BusyWorker to Backend::Blocking instead, it won't print the warning - let mut badboy = BusyWorker::new().start(); - let _ = badboy.send(()).await; + let badboy = BusyWorker::new().start(); + let _ = badboy.send(BusyWork); rt::sleep(Duration::from_secs(5)).await; exit(0); diff --git a/examples/chat_room/Cargo.toml b/examples/chat_room/Cargo.toml new file mode 100644 index 0000000..caedc35 --- /dev/null +++ b/examples/chat_room/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "chat_room" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +spawned-concurrency = { workspace = true } +spawned-macros = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +tokio = { version = "1", features = ["full"] } diff --git a/examples/chat_room/src/main.rs b/examples/chat_room/src/main.rs new file mode 100644 index 0000000..9171b9a --- /dev/null +++ b/examples/chat_room/src/main.rs @@ -0,0 +1,60 @@ +mod messages; +mod room; +mod user; + +use messages::{Deliver, Join, SayToRoom}; +use room::ChatRoom; +use spawned_concurrency::tasks::ActorStart; +use user::User; + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + + let room = ChatRoom::new().start(); + + let alice = User { + name: "Alice".into(), + room: room.recipient(), + } + .start(); + + let bob = User { + name: "Bob".into(), + room: room.recipient(), + } + .start(); + + // Register users in the room + room.send(Join { + name: "Alice".into(), + inbox: alice.recipient::(), + }) + .unwrap(); + + room.send(Join { + name: "Bob".into(), + inbox: bob.recipient::(), + }) + .unwrap(); + + // Alice says something + alice + .send(SayToRoom { + text: "Hello everyone!".into(), + }) + .unwrap(); + + // Bob says something + bob.send(SayToRoom { + text: "Hi Alice!".into(), + }) + .unwrap(); + + // Give time for messages to propagate + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + tracing::info!("Chat room demo complete"); +} diff --git a/examples/chat_room/src/messages.rs b/examples/chat_room/src/messages.rs new file mode 100644 index 0000000..a085f5a --- /dev/null +++ b/examples/chat_room/src/messages.rs @@ -0,0 +1,18 @@ +use spawned_concurrency::message::Message; +use spawned_concurrency::tasks::Recipient; + +spawned_concurrency::messages! { + Say { from: String, text: String } -> (); + Deliver { from: String, text: String } -> (); + SayToRoom { text: String } -> () +} + +// Join has a Recipient field — define manually since messages! doesn't support it +pub struct Join { + pub name: String, + pub inbox: Recipient, +} + +impl Message for Join { + type Result = (); +} diff --git a/examples/chat_room/src/room.rs b/examples/chat_room/src/room.rs new file mode 100644 index 0000000..2ad97bb --- /dev/null +++ b/examples/chat_room/src/room.rs @@ -0,0 +1,39 @@ +use spawned_concurrency::tasks::{Actor, Context, Handler, Recipient}; +use spawned_macros::actor; + +use crate::messages::{Deliver, Join, Say}; + +pub struct ChatRoom { + members: Vec<(String, Recipient)>, +} + +impl ChatRoom { + pub fn new() -> Self { + Self { + members: Vec::new(), + } + } +} + +impl Actor for ChatRoom {} + +#[actor] +impl ChatRoom { + #[handler] + async fn on_say(&mut self, msg: Say, _ctx: &Context) { + for (name, inbox) in &self.members { + if *name != msg.from { + let _ = inbox.send(Deliver { + from: msg.from.clone(), + text: msg.text.clone(), + }); + } + } + } + + #[handler] + async fn on_join(&mut self, msg: Join, _ctx: &Context) { + tracing::info!("[room] {} joined", msg.name); + self.members.push((msg.name, msg.inbox)); + } +} diff --git a/examples/chat_room/src/user.rs b/examples/chat_room/src/user.rs new file mode 100644 index 0000000..79a94e8 --- /dev/null +++ b/examples/chat_room/src/user.rs @@ -0,0 +1,27 @@ +use spawned_concurrency::tasks::{Actor, Context, Handler, Recipient}; +use spawned_macros::actor; + +use crate::messages::{Deliver, Say, SayToRoom}; + +pub struct User { + pub name: String, + pub room: Recipient, +} + +impl Actor for User {} + +#[actor] +impl User { + #[handler] + async fn on_say_to_room(&mut self, msg: SayToRoom, _ctx: &Context) { + let _ = self.room.send(Say { + from: self.name.clone(), + text: msg.text, + }); + } + + #[handler] + async fn on_deliver(&mut self, msg: Deliver, _ctx: &Context) { + tracing::info!("[{}] got: {} says '{}'", self.name, msg.from, msg.text); + } +} diff --git a/examples/name_server/src/main.rs b/examples/name_server/src/main.rs index 85fab9e..810d540 100644 --- a/examples/name_server/src/main.rs +++ b/examples/name_server/src/main.rs @@ -16,19 +16,19 @@ mod server; use messages::NameServerOutMessage; use server::NameServer; -use spawned_concurrency::tasks::Actor as _; +use spawned_concurrency::tasks::ActorStart; use spawned_rt::tasks as rt; fn main() { rt::run(async { - let mut name_server = NameServer::new().start(); + let name_server = NameServer::new().start(); let result = - NameServer::add(&mut name_server, "Joe".to_string(), "At Home".to_string()).await; + NameServer::add(&name_server, "Joe".to_string(), "At Home".to_string()).await; tracing::info!("Storing value result: {result:?}"); assert_eq!(result, NameServerOutMessage::Ok); - let result = NameServer::find(&mut name_server, "Joe".to_string()).await; + let result = NameServer::find(&name_server, "Joe".to_string()).await; tracing::info!("Retrieving value result: {result:?}"); assert_eq!( result, @@ -37,7 +37,7 @@ fn main() { } ); - let result = NameServer::find(&mut name_server, "Bob".to_string()).await; + let result = NameServer::find(&name_server, "Bob".to_string()).await; tracing::info!("Retrieving value result: {result:?}"); assert_eq!(result, NameServerOutMessage::NotFound); }) diff --git a/examples/name_server/src/messages.rs b/examples/name_server/src/messages.rs index b011cb2..6324c8f 100644 --- a/examples/name_server/src/messages.rs +++ b/examples/name_server/src/messages.rs @@ -1,10 +1,5 @@ -#[derive(Debug, Clone)] -pub enum NameServerInMessage { - Add { key: String, value: String }, - Find { key: String }, -} +use spawned_concurrency::message::Message; -#[allow(dead_code)] #[derive(Debug, Clone, PartialEq)] pub enum NameServerOutMessage { Ok, @@ -12,3 +7,20 @@ pub enum NameServerOutMessage { NotFound, Error, } + +#[derive(Debug)] +pub struct Add { + pub key: String, + pub value: String, +} +impl Message for Add { + type Result = NameServerOutMessage; +} + +#[derive(Debug)] +pub struct Find { + pub key: String, +} +impl Message for Find { + type Result = NameServerOutMessage; +} diff --git a/examples/name_server/src/server.rs b/examples/name_server/src/server.rs index 59a5c96..c8e74fe 100644 --- a/examples/name_server/src/server.rs +++ b/examples/name_server/src/server.rs @@ -1,13 +1,8 @@ use std::collections::HashMap; -use spawned_concurrency::{ - messages::Unused, - tasks::{Actor, ActorRef, RequestResponse}, -}; +use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; -use crate::messages::{NameServerInMessage as InMessage, NameServerOutMessage as OutMessage}; - -type NameServerHandle = ActorRef; +use crate::messages::{Add, Find, NameServerOutMessage as OutMessage}; pub struct NameServer { inner: HashMap, @@ -22,44 +17,37 @@ impl NameServer { } impl NameServer { - pub async fn add(server: &mut NameServerHandle, key: String, value: String) -> OutMessage { - match server.request(InMessage::Add { key, value }).await { + pub async fn add(server: &ActorRef, key: String, value: String) -> OutMessage { + match server.send_request(Add { key, value }).await { Ok(_) => OutMessage::Ok, Err(_) => OutMessage::Error, } } - pub async fn find(server: &mut NameServerHandle, key: String) -> OutMessage { + pub async fn find(server: &ActorRef, key: String) -> OutMessage { server - .request(InMessage::Find { key }) + .send_request(Find { key }) .await .unwrap_or(OutMessage::Error) } } -impl Actor for NameServer { - type Request = InMessage; - type Message = Unused; - type Reply = OutMessage; - type Error = std::fmt::Error; +impl Actor for NameServer {} + +impl Handler for NameServer { + async fn handle(&mut self, msg: Add, _ctx: &Context) -> OutMessage { + self.inner.insert(msg.key, msg.value); + OutMessage::Ok + } +} - async fn handle_request( - &mut self, - message: Self::Request, - _handle: &NameServerHandle, - ) -> RequestResponse { - match message.clone() { - Self::Request::Add { key, value } => { - self.inner.insert(key, value); - RequestResponse::Reply(Self::Reply::Ok) - } - Self::Request::Find { key } => match self.inner.get(&key) { - Some(result) => { - let value = result.to_string(); - RequestResponse::Reply(Self::Reply::Found { value }) - } - None => RequestResponse::Reply(Self::Reply::NotFound), +impl Handler for NameServer { + async fn handle(&mut self, msg: Find, _ctx: &Context) -> OutMessage { + match self.inner.get(&msg.key) { + Some(result) => OutMessage::Found { + value: result.to_string(), }, + None => OutMessage::NotFound, } } } diff --git a/examples/service_discovery/Cargo.toml b/examples/service_discovery/Cargo.toml new file mode 100644 index 0000000..9377625 --- /dev/null +++ b/examples/service_discovery/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "service_discovery" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +spawned-concurrency = { workspace = true } +spawned-macros = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +tokio = { version = "1", features = ["full"] } diff --git a/examples/service_discovery/src/main.rs b/examples/service_discovery/src/main.rs new file mode 100644 index 0000000..b546b5e --- /dev/null +++ b/examples/service_discovery/src/main.rs @@ -0,0 +1,102 @@ +use spawned_concurrency::registry; +use spawned_concurrency::tasks::{Actor, ActorStart, Context, Handler, Recipient, send_request}; +use spawned_macros::actor; +use std::collections::HashMap; +use std::time::Duration; + +// --- Messages --- + +spawned_concurrency::messages! { + Register { name: String, address: String } -> (); + Lookup { name: String } -> Option; + ListAll -> Vec<(String, String)> +} + +// --- ServiceRegistry actor --- + +struct ServiceRegistry { + services: HashMap, +} + +impl ServiceRegistry { + fn new() -> Self { + Self { + services: HashMap::new(), + } + } +} + +impl Actor for ServiceRegistry {} + +#[actor] +impl ServiceRegistry { + #[handler] + async fn on_register(&mut self, msg: Register, _ctx: &Context) { + tracing::info!("Registered service '{}' at {}", msg.name, msg.address); + self.services.insert(msg.name, msg.address); + } + + #[handler] + async fn on_lookup(&mut self, msg: Lookup, _ctx: &Context) -> Option { + self.services.get(&msg.name).cloned() + } + + #[handler] + async fn on_list_all(&mut self, _msg: ListAll, _ctx: &Context) -> Vec<(String, String)> { + self.services.iter().map(|(k, v)| (k.clone(), v.clone())).collect() + } +} + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + + // Start the service registry actor + let svc = ServiceRegistry::new().start(); + + // Register it by name — other actors can discover it + registry::register("service_registry", svc.recipient::()).unwrap(); + + // Register some services + svc.send(Register { + name: "web".into(), + address: "http://localhost:8080".into(), + }) + .unwrap(); + + svc.send(Register { + name: "db".into(), + address: "postgres://localhost:5432".into(), + }) + .unwrap(); + + // A consumer discovers the registry by name (doesn't need to know ServiceRegistry type) + let lookup_recipient: Recipient = registry::whereis("service_registry").unwrap(); + + // Look up a service + let addr = send_request( + &*lookup_recipient, + Lookup { + name: "web".into(), + }, + Duration::from_secs(5), + ) + .await + .unwrap(); + tracing::info!("Looked up 'web': {:?}", addr); + + // List all registered names in the registry + let names = registry::registered(); + tracing::info!("Registry contains: {:?}", names); + + // Direct request for all services + let all = svc.send_request(ListAll).await.unwrap(); + tracing::info!("All services: {:?}", all); + + // Clean up + registry::unregister("service_registry"); + + tracing::info!("Service discovery demo complete"); +} diff --git a/examples/signal_test/src/main.rs b/examples/signal_test/src/main.rs index 90e6d6b..e4aa465 100644 --- a/examples/signal_test/src/main.rs +++ b/examples/signal_test/src/main.rs @@ -1,27 +1,24 @@ //! Test to verify signal handling across different Actor backends (tasks version). //! //! This example demonstrates using `send_message_on` to handle Ctrl+C signals. -//! The signal handler is set up in the Actor's `init()` function. +//! The signal handler is set up in the Actor's `started()` function. //! //! Run with: cargo run --bin signal_test -- [async|blocking|thread] //! //! Then press Ctrl+C and observe: //! - Does the actor stop gracefully? -//! - Does teardown run? +//! - Does stopped run? -use spawned_concurrency::{ - messages::Unused, - tasks::{ - send_interval, send_message_on, Actor, ActorRef, Backend, InitResult, MessageResponse, - }, +use spawned_concurrency::tasks::{ + send_interval, send_message_on, Actor, ActorStart, Backend, Context, Handler, TimerHandle, }; -use spawned_rt::tasks::{self as rt, CancellationToken}; +use spawned_rt::tasks as rt; use std::{env, time::Duration}; struct TickingActor { name: String, count: u64, - timer_token: Option, + timer: Option, } impl TickingActor { @@ -29,61 +26,49 @@ impl TickingActor { Self { name: name.to_string(), count: 0, - timer_token: None, + timer: None, } } } -#[derive(Clone)] -enum Msg { - Tick, - Shutdown, +spawned_concurrency::messages! { + #[derive(Clone)] + Tick -> (); + Shutdown -> () } impl Actor for TickingActor { - type Request = Unused; - type Message = Msg; - type Reply = Unused; - type Error = (); - - async fn init(mut self, handle: &ActorRef) -> Result, Self::Error> { + async fn started(&mut self, ctx: &Context) { tracing::info!("[{}] Actor initialized", self.name); // Set up periodic ticking - let timer = send_interval(Duration::from_secs(1), handle.clone(), Msg::Tick); - self.timer_token = Some(timer.cancellation_token); + let timer = send_interval(Duration::from_secs(1), ctx.clone(), Tick); + self.timer = Some(timer); // Set up Ctrl+C handler using send_message_on - send_message_on(handle.clone(), rt::ctrl_c(), Msg::Shutdown); - - Ok(InitResult::Success(self)) - } - - async fn handle_message( - &mut self, - message: Self::Message, - _handle: &ActorRef, - ) -> MessageResponse { - match message { - Msg::Tick => { - self.count += 1; - tracing::info!("[{}] Tick #{}", self.name, self.count); - MessageResponse::NoReply - } - Msg::Shutdown => { - tracing::info!("[{}] Received shutdown signal", self.name); - MessageResponse::Stop - } - } + send_message_on(ctx.clone(), rt::ctrl_c(), Shutdown); } - async fn teardown(self, _handle: &ActorRef) -> Result<(), Self::Error> { + async fn stopped(&mut self, _ctx: &Context) { tracing::info!( - "[{}] Teardown called! Final count: {}", + "[{}] Stopped called! Final count: {}", self.name, self.count ); - Ok(()) + } +} + +impl Handler for TickingActor { + async fn handle(&mut self, _msg: Tick, _ctx: &Context) { + self.count += 1; + tracing::info!("[{}] Tick #{}", self.name, self.count); + } +} + +impl Handler for TickingActor { + async fn handle(&mut self, _msg: Shutdown, ctx: &Context) { + tracing::info!("[{}] Received shutdown signal", self.name); + ctx.stop(); } } diff --git a/examples/signal_test_threads/src/main.rs b/examples/signal_test_threads/src/main.rs index a0da2a0..ba1b90b 100644 --- a/examples/signal_test_threads/src/main.rs +++ b/examples/signal_test_threads/src/main.rs @@ -1,25 +1,24 @@ //! Test to verify signal handling for threads Actor. //! //! This example demonstrates using `send_message_on` to handle Ctrl+C signals. -//! The signal handler is set up in the Actor's `init()` function. +//! The signal handler is set up in the Actor's `started()` function. //! //! Run with: cargo run --bin signal_test_threads //! //! Then press Ctrl+C and observe: //! - Does the actor stop gracefully? -//! - Does teardown run? +//! - Does stopped run? -use spawned_concurrency::{ - messages::Unused, - threads::{send_interval, send_message_on, Actor, ActorRef, InitResult, MessageResponse}, +use spawned_concurrency::threads::{ + send_interval, send_message_on, Actor, ActorStart, Context, Handler, TimerHandle, }; -use spawned_rt::threads::{self as rt, CancellationToken}; +use spawned_rt::threads as rt; use std::time::Duration; struct TickingActor { name: String, count: u64, - timer_token: Option, + timer: Option, } impl TickingActor { @@ -27,61 +26,49 @@ impl TickingActor { Self { name: name.to_string(), count: 0, - timer_token: None, + timer: None, } } } -#[derive(Clone)] -enum Msg { - Tick, - Shutdown, +spawned_concurrency::messages! { + #[derive(Clone)] + Tick -> (); + Shutdown -> () } impl Actor for TickingActor { - type Request = Unused; - type Message = Msg; - type Reply = Unused; - type Error = (); - - fn init(mut self, handle: &ActorRef) -> Result, Self::Error> { + fn started(&mut self, ctx: &Context) { tracing::info!("[{}] Actor initialized", self.name); // Set up periodic ticking - let timer = send_interval(Duration::from_secs(1), handle.clone(), Msg::Tick); - self.timer_token = Some(timer.cancellation_token); + let timer = send_interval(Duration::from_secs(1), ctx.clone(), Tick); + self.timer = Some(timer); // Set up Ctrl+C handler using send_message_on - send_message_on(handle.clone(), rt::ctrl_c(), Msg::Shutdown); - - Ok(InitResult::Success(self)) - } - - fn handle_message( - &mut self, - message: Self::Message, - _handle: &ActorRef, - ) -> MessageResponse { - match message { - Msg::Tick => { - self.count += 1; - tracing::info!("[{}] Tick #{}", self.name, self.count); - MessageResponse::NoReply - } - Msg::Shutdown => { - tracing::info!("[{}] Received shutdown signal", self.name); - MessageResponse::Stop - } - } + send_message_on(ctx.clone(), rt::ctrl_c(), Shutdown); } - fn teardown(self, _handle: &ActorRef) -> Result<(), Self::Error> { + fn stopped(&mut self, _ctx: &Context) { tracing::info!( - "[{}] Teardown called! Final count: {}", + "[{}] Stopped called! Final count: {}", self.name, self.count ); - Ok(()) + } +} + +impl Handler for TickingActor { + fn handle(&mut self, _msg: Tick, _ctx: &Context) { + self.count += 1; + tracing::info!("[{}] Tick #{}", self.name, self.count); + } +} + +impl Handler for TickingActor { + fn handle(&mut self, _msg: Shutdown, ctx: &Context) { + tracing::info!("[{}] Received shutdown signal", self.name); + ctx.stop(); } } diff --git a/examples/updater/src/main.rs b/examples/updater/src/main.rs index 0a6aaf0..d046b33 100644 --- a/examples/updater/src/main.rs +++ b/examples/updater/src/main.rs @@ -9,7 +9,7 @@ mod server; use std::{thread, time::Duration}; use server::UpdaterServer; -use spawned_concurrency::tasks::Actor as _; +use spawned_concurrency::tasks::ActorStart; use spawned_rt::tasks as rt; fn main() { diff --git a/examples/updater/src/messages.rs b/examples/updater/src/messages.rs index daa0589..91cba26 100644 --- a/examples/updater/src/messages.rs +++ b/examples/updater/src/messages.rs @@ -1,11 +1,4 @@ -#[derive(Debug, Clone)] -pub enum UpdaterInMessage { - Check, -} - -#[allow(dead_code)] -#[derive(Debug, Clone, PartialEq)] -pub enum UpdaterOutMessage { - Ok, - Error, +spawned_concurrency::messages! { + #[derive(Clone)] + Check -> () } diff --git a/examples/updater/src/server.rs b/examples/updater/src/server.rs index 2c1b02e..ce4021b 100644 --- a/examples/updater/src/server.rs +++ b/examples/updater/src/server.rs @@ -1,23 +1,13 @@ use std::time::Duration; -use spawned_concurrency::{ - messages::Unused, - tasks::{ - send_interval, Actor, ActorRef, - InitResult::{self, Success}, - MessageResponse, - }, -}; -use spawned_rt::tasks::CancellationToken; +use spawned_concurrency::tasks::{send_interval, Actor, Context, Handler, TimerHandle}; -use crate::messages::{UpdaterInMessage as InMessage, UpdaterOutMessage as OutMessage}; - -type UpdateServerHandle = ActorRef; +use crate::messages::Check; pub struct UpdaterServer { pub url: String, pub periodicity: Duration, - pub timer_token: Option, + pub timer: Option, } impl UpdaterServer { @@ -25,38 +15,24 @@ impl UpdaterServer { UpdaterServer { url, periodicity, - timer_token: None, + timer: None, } } } impl Actor for UpdaterServer { - type Request = Unused; - type Message = InMessage; - type Reply = OutMessage; - type Error = std::fmt::Error; - - // Initializing Actor to start periodic checks. - async fn init(mut self, handle: &ActorRef) -> Result, Self::Error> { - let timer = send_interval(self.periodicity, handle.clone(), InMessage::Check); - self.timer_token = Some(timer.cancellation_token); - Ok(Success(self)) + async fn started(&mut self, ctx: &Context) { + let timer = send_interval(self.periodicity, ctx.clone(), Check); + self.timer = Some(timer); } +} - async fn handle_message( - &mut self, - message: Self::Message, - _handle: &UpdateServerHandle, - ) -> MessageResponse { - match message { - Self::Message::Check => { - let url = self.url.clone(); - tracing::info!("Fetching: {url}"); - let resp = req(url).await; - tracing::info!("Response: {resp:?}"); - MessageResponse::NoReply - } - } +impl Handler for UpdaterServer { + async fn handle(&mut self, _msg: Check, _ctx: &Context) { + let url = self.url.clone(); + tracing::info!("Fetching: {url}"); + let resp = req(url).await; + tracing::info!("Response: {resp:?}"); } } diff --git a/examples/updater_threads/src/main.rs b/examples/updater_threads/src/main.rs index 5b7ceb3..50255fe 100644 --- a/examples/updater_threads/src/main.rs +++ b/examples/updater_threads/src/main.rs @@ -9,7 +9,7 @@ mod server; use std::{thread, time::Duration}; use server::UpdaterServer; -use spawned_concurrency::threads::Actor as _; +use spawned_concurrency::threads::ActorStart; use spawned_rt::threads as rt; fn main() { diff --git a/examples/updater_threads/src/messages.rs b/examples/updater_threads/src/messages.rs index daa0589..91cba26 100644 --- a/examples/updater_threads/src/messages.rs +++ b/examples/updater_threads/src/messages.rs @@ -1,11 +1,4 @@ -#[derive(Debug, Clone)] -pub enum UpdaterInMessage { - Check, -} - -#[allow(dead_code)] -#[derive(Debug, Clone, PartialEq)] -pub enum UpdaterOutMessage { - Ok, - Error, +spawned_concurrency::messages! { + #[derive(Clone)] + Check -> () } diff --git a/examples/updater_threads/src/server.rs b/examples/updater_threads/src/server.rs index 2a931ff..b427dfa 100644 --- a/examples/updater_threads/src/server.rs +++ b/examples/updater_threads/src/server.rs @@ -1,50 +1,28 @@ use std::time::Duration; -use spawned_concurrency::{ - messages::Unused, - threads::{send_after, Actor, ActorRef, InitResult, MessageResponse}, -}; +use spawned_concurrency::threads::{send_after, Actor, Context, Handler}; use spawned_rt::threads::block_on; -use crate::messages::{UpdaterInMessage as InMessage, UpdaterOutMessage as OutMessage}; +use crate::messages::Check; -type UpdateServerHandle = ActorRef; - -#[derive(Clone)] pub struct UpdaterServer { pub url: String, pub periodicity: Duration, } impl Actor for UpdaterServer { - type Request = Unused; - type Message = InMessage; - type Reply = OutMessage; - type Error = std::fmt::Error; - - // Initializing Actor to start periodic checks. - fn init(self, handle: &ActorRef) -> Result, Self::Error> { - send_after(self.periodicity, handle.clone(), InMessage::Check); - Ok(InitResult::Success(self)) + fn started(&mut self, ctx: &Context) { + send_after(self.periodicity, ctx.clone(), Check); } +} - fn handle_message( - &mut self, - message: Self::Message, - handle: &UpdateServerHandle, - ) -> MessageResponse { - match message { - Self::Message::Check => { - send_after(self.periodicity, handle.clone(), InMessage::Check); - let url = self.url.clone(); - tracing::info!("Fetching: {url}"); - let resp = block_on(req(url)); - - tracing::info!("Response: {resp:?}"); - - MessageResponse::NoReply - } - } +impl Handler for UpdaterServer { + fn handle(&mut self, _msg: Check, ctx: &Context) { + send_after(self.periodicity, ctx.clone(), Check); + let url = self.url.clone(); + tracing::info!("Fetching: {url}"); + let resp = block_on(req(url)); + tracing::info!("Response: {resp:?}"); } } diff --git a/macros/Cargo.toml b/macros/Cargo.toml new file mode 100644 index 0000000..c1e3e15 --- /dev/null +++ b/macros/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "spawned-macros" +description = "Proc macros for the Spawned actor framework" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +proc-macro = true + +[dependencies] +syn = { version = "2", features = ["full"] } +quote = "1" +proc-macro2 = "1" diff --git a/macros/src/lib.rs b/macros/src/lib.rs new file mode 100644 index 0000000..0cc9530 --- /dev/null +++ b/macros/src/lib.rs @@ -0,0 +1,106 @@ +use proc_macro::TokenStream; +use quote::quote; +use syn::{parse_macro_input, FnArg, ImplItem, ItemImpl, Pat, ReturnType, Type}; + +/// Attribute macro for actor impl blocks. +/// +/// Place `#[actor]` on an `impl MyActor` block containing methods annotated +/// with `#[handler]`. For each `#[handler]` method, the macro generates a +/// corresponding `impl Handler for MyActor` block. +/// +/// # Handler method signature +/// +/// ```ignore +/// #[handler] +/// async fn on_deposit(&mut self, msg: Deposit, ctx: &Context) { ... } +/// // or with explicit return: +/// #[handler] +/// async fn on_withdraw(&mut self, msg: Withdraw, ctx: &Context) -> Result { ... } +/// ``` +/// +/// Sync handlers (for the `threads` module) omit `async`: +/// +/// ```ignore +/// #[handler] +/// fn on_deposit(&mut self, msg: Deposit, ctx: &Context) { ... } +/// ``` +/// +/// The generated `Handler` impl delegates to the original method. +#[proc_macro_attribute] +pub fn actor(_attr: TokenStream, item: TokenStream) -> TokenStream { + let mut impl_block = parse_macro_input!(item as ItemImpl); + + let self_ty = &impl_block.self_ty; + let (impl_generics, _, where_clause) = impl_block.generics.split_for_impl(); + + let mut handler_impls = Vec::new(); + + for item in &mut impl_block.items { + if let ImplItem::Fn(method) = item { + // Check for #[handler] attribute + let handler_idx = method.attrs.iter().position(|attr| { + attr.path().is_ident("handler") + }); + + if let Some(idx) = handler_idx { + method.attrs.remove(idx); + + let method_name = &method.sig.ident; + let is_async = method.sig.asyncness.is_some(); + + // Extract message type from 2nd parameter (index 1, after &mut self) + let msg_ty = match method.sig.inputs.iter().nth(1) { + Some(FnArg::Typed(pat_type)) => { + if let Pat::Ident(pat_ident) = &*pat_type.pat { + if pat_ident.ident == "_" || pat_ident.ident.to_string().starts_with('_') { + // Still use the type + } + } + &*pat_type.ty + } + _ => { + return syn::Error::new_spanned( + &method.sig, + "#[handler] method must have signature: fn(&mut self, msg: M, ctx: &Context) -> R", + ) + .to_compile_error() + .into(); + } + }; + + // Extract return type (default to () if omitted) + let ret_ty: Box = match &method.sig.output { + ReturnType::Default => syn::parse_quote! { () }, + ReturnType::Type(_, ty) => ty.clone(), + }; + + let handler_impl = if is_async { + quote! { + impl #impl_generics Handler<#msg_ty> for #self_ty #where_clause { + async fn handle(&mut self, msg: #msg_ty, ctx: &Context) -> #ret_ty { + self.#method_name(msg, ctx).await + } + } + } + } else { + quote! { + impl #impl_generics Handler<#msg_ty> for #self_ty #where_clause { + fn handle(&mut self, msg: #msg_ty, ctx: &Context) -> #ret_ty { + self.#method_name(msg, ctx) + } + } + } + }; + + handler_impls.push(handler_impl); + } + } + } + + let output = quote! { + #impl_block + #(#handler_impls)* + }; + + output.into() +} From 548008e33805c6fe0bfdafe840e4f17c14b6d35d Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Fri, 13 Feb 2026 16:21:25 -0300 Subject: [PATCH 03/20] =?UTF-8?q?feat:=20rename=20send=5Frequest=20?= =?UTF-8?q?=E2=86=92=20request,=20add=20send/request=20message=20and=20han?= =?UTF-8?q?dler=20macros?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- concurrency/src/message.rs | 88 +++++++++++++++++++- concurrency/src/tasks/actor.rs | 111 +++++++++++++++---------- concurrency/src/tasks/mod.rs | 2 +- concurrency/src/tasks/stream_tests.rs | 14 ++-- concurrency/src/tasks/timer_tests.rs | 12 +-- concurrency/src/threads/actor.rs | 53 ++++++++---- concurrency/src/threads/mod.rs | 2 +- concurrency/src/threads/timer_tests.rs | 12 +-- examples/bank/src/server.rs | 8 +- examples/bank_threads/src/server.rs | 8 +- examples/blocking_genserver/main.rs | 4 +- examples/chat_room/src/main.rs | 65 +++++---------- examples/chat_room/src/messages.rs | 18 ---- examples/chat_room/src/room.rs | 50 +++++++++-- examples/chat_room/src/user.rs | 56 ++++++++++--- examples/name_server/src/server.rs | 4 +- examples/service_discovery/src/main.rs | 17 ++-- macros/src/lib.rs | 28 ++++--- 18 files changed, 363 insertions(+), 189 deletions(-) delete mode 100644 examples/chat_room/src/messages.rs diff --git a/concurrency/src/message.rs b/concurrency/src/message.rs index a97b310..1a2b542 100644 --- a/concurrency/src/message.rs +++ b/concurrency/src/message.rs @@ -21,7 +21,6 @@ macro_rules! messages { // Base: unit message ($(#[$meta:meta])* $name:ident -> $result:ty) => { $(#[$meta])* - #[derive(Debug)] pub struct $name; impl $crate::message::Message for $name { type Result = $result; @@ -31,7 +30,6 @@ macro_rules! messages { // Base: struct message ($(#[$meta:meta])* $name:ident { $($field:ident : $ftype:ty),* $(,)? } -> $result:ty) => { $(#[$meta])* - #[derive(Debug)] pub struct $name { $(pub $field: $ftype,)* } impl $crate::message::Message for $name { type Result = $result; @@ -50,3 +48,89 @@ macro_rules! messages { $crate::messages!($($rest)*); }; } + +/// Fire-and-forget messages (Result type is always `()`). +/// +/// ```ignore +/// send_messages! { +/// Increment; +/// Deposit { who: String, amount: i32 } +/// } +/// ``` +#[macro_export] +macro_rules! send_messages { + () => {}; + + // Base: unit message + ($(#[$meta:meta])* $name:ident) => { + $(#[$meta])* + pub struct $name; + impl $crate::message::Message for $name { + type Result = (); + } + }; + + // Base: struct message + ($(#[$meta:meta])* $name:ident { $($field:ident : $ftype:ty),* $(,)? }) => { + $(#[$meta])* + pub struct $name { $(pub $field: $ftype,)* } + impl $crate::message::Message for $name { + type Result = (); + } + }; + + // Recursive: unit message followed by more + ($(#[$meta:meta])* $name:ident; $($rest:tt)*) => { + $crate::send_messages!($(#[$meta])* $name); + $crate::send_messages!($($rest)*); + }; + + // Recursive: struct message followed by more + ($(#[$meta:meta])* $name:ident { $($field:ident : $ftype:ty),* $(,)? }; $($rest:tt)*) => { + $crate::send_messages!($(#[$meta])* $name { $($field : $ftype),* }); + $crate::send_messages!($($rest)*); + }; +} + +/// Request-response messages (Result type is explicitly specified). +/// +/// ```ignore +/// request_messages! { +/// GetCount -> u64; +/// Lookup { key: String } -> Option +/// } +/// ``` +#[macro_export] +macro_rules! request_messages { + () => {}; + + // Base: unit message + ($(#[$meta:meta])* $name:ident -> $result:ty) => { + $(#[$meta])* + pub struct $name; + impl $crate::message::Message for $name { + type Result = $result; + } + }; + + // Base: struct message + ($(#[$meta:meta])* $name:ident { $($field:ident : $ftype:ty),* $(,)? } -> $result:ty) => { + $(#[$meta])* + pub struct $name { $(pub $field: $ftype,)* } + impl $crate::message::Message for $name { + type Result = $result; + } + }; + + // Recursive: unit message followed by more + ($(#[$meta:meta])* $name:ident -> $result:ty; $($rest:tt)*) => { + $crate::request_messages!($(#[$meta])* $name -> $result); + $crate::request_messages!($($rest)*); + }; + + // Recursive: struct message followed by more + ($(#[$meta:meta])* $name:ident { $($field:ident : $ftype:ty),* $(,)? } -> $result:ty; $($rest:tt)*) => { + $crate::request_messages!($(#[$meta])* $name { $($field : $ftype),* } -> $result); + $crate::request_messages!($($rest)*); + }; +} diff --git a/concurrency/src/tasks/actor.rs b/concurrency/src/tasks/actor.rs index b63f1ce..c0a90a9 100644 --- a/concurrency/src/tasks/actor.rs +++ b/concurrency/src/tasks/actor.rs @@ -131,7 +131,7 @@ impl Context { .map_err(|_| ActorError::ActorStopped) } - pub fn request(&self, msg: M) -> Result, ActorError> + pub fn request_raw(&self, msg: M) -> Result, ActorError> where A: Handler, M: Message, @@ -147,12 +147,12 @@ impl Context { Ok(rx) } - pub async fn send_request(&self, msg: M) -> Result + pub async fn request(&self, msg: M) -> Result where A: Handler, M: Message, { - let rx = self.request(msg)?; + let rx = self.request_raw(msg)?; match timeout(DEFAULT_REQUEST_TIMEOUT, rx).await { Ok(Ok(result)) => Ok(result), Ok(Err(_)) => Err(ActorError::ActorStopped), @@ -160,28 +160,51 @@ impl Context { } } + pub fn recipient(&self) -> Recipient + where + A: Handler, + M: Message, + { + Arc::new(self.clone()) + } + pub(crate) fn cancellation_token(&self) -> CancellationToken { self.cancellation_token.clone() } } +// Bridge: Context implements Receiver for any M that A handles +impl Receiver for Context +where + A: Actor + Handler, + M: Message, +{ + fn send(&self, msg: M) -> Result<(), ActorError> { + Context::send(self, msg) + } + + fn request_raw(&self, msg: M) -> Result, ActorError> { + Context::request_raw(self, msg) + } +} + // --------------------------------------------------------------------------- // Receiver trait (object-safe) + Recipient alias // --------------------------------------------------------------------------- pub trait Receiver: Send + Sync { fn send(&self, msg: M) -> Result<(), ActorError>; - fn request(&self, msg: M) -> Result, ActorError>; + fn request_raw(&self, msg: M) -> Result, ActorError>; } pub type Recipient = Arc>; -pub async fn send_request( +pub async fn request( recipient: &dyn Receiver, msg: M, timeout_duration: Duration, ) -> Result { - let rx = recipient.request(msg)?; + let rx = recipient.request_raw(msg)?; match timeout(timeout_duration, rx).await { Ok(Ok(result)) => Ok(result), Ok(Err(_)) => Err(ActorError::ActorStopped), @@ -227,7 +250,7 @@ impl ActorRef { .map_err(|_| ActorError::ActorStopped) } - pub fn request(&self, msg: M) -> Result, ActorError> + pub fn request_raw(&self, msg: M) -> Result, ActorError> where A: Handler, M: Message, @@ -243,15 +266,15 @@ impl ActorRef { Ok(rx) } - pub async fn send_request(&self, msg: M) -> Result + pub async fn request(&self, msg: M) -> Result where A: Handler, M: Message, { - self.send_request_with_timeout(msg, DEFAULT_REQUEST_TIMEOUT).await + self.request_with_timeout(msg, DEFAULT_REQUEST_TIMEOUT).await } - pub async fn send_request_with_timeout( + pub async fn request_with_timeout( &self, msg: M, duration: Duration, @@ -260,7 +283,7 @@ impl ActorRef { A: Handler, M: Message, { - let rx = self.request(msg)?; + let rx = self.request_raw(msg)?; match timeout(duration, rx).await { Ok(Ok(result)) => Ok(result), Ok(Err(_)) => Err(ActorError::ActorStopped), @@ -300,8 +323,8 @@ where ActorRef::send(self, msg) } - fn request(&self, msg: M) -> Result, ActorError> { - ActorRef::request(self, msg) + fn request_raw(&self, msg: M) -> Result, ActorError> { + ActorRef::request_raw(self, msg) } } @@ -562,20 +585,20 @@ mod tests { runtime.block_on(async move { let counter = Counter { count: 0 }.start(); - let result = counter.send_request(GetCount).await.unwrap(); + let result = counter.request(GetCount).await.unwrap(); assert_eq!(result, 0); - let result = counter.send_request(Increment).await.unwrap(); + let result = counter.request(Increment).await.unwrap(); assert_eq!(result, 1); // fire-and-forget send counter.send(Increment).unwrap(); rt::sleep(Duration::from_millis(10)).await; - let result = counter.send_request(GetCount).await.unwrap(); + let result = counter.request(GetCount).await.unwrap(); assert_eq!(result, 2); - let final_count = counter.send_request(StopCounter).await.unwrap(); + let final_count = counter.request(StopCounter).await.unwrap(); assert_eq!(final_count, 2); }); } @@ -586,19 +609,19 @@ mod tests { runtime.block_on(async move { let counter = Counter { count: 0 }.start_with_backend(Backend::Blocking); - let result = counter.send_request(GetCount).await.unwrap(); + let result = counter.request(GetCount).await.unwrap(); assert_eq!(result, 0); - let result = counter.send_request(Increment).await.unwrap(); + let result = counter.request(Increment).await.unwrap(); assert_eq!(result, 1); counter.send(Increment).unwrap(); rt::sleep(Duration::from_millis(50)).await; - let result = counter.send_request(GetCount).await.unwrap(); + let result = counter.request(GetCount).await.unwrap(); assert_eq!(result, 2); - let final_count = counter.send_request(StopCounter).await.unwrap(); + let final_count = counter.request(StopCounter).await.unwrap(); assert_eq!(final_count, 2); }); } @@ -609,19 +632,19 @@ mod tests { runtime.block_on(async move { let counter = Counter { count: 0 }.start_with_backend(Backend::Thread); - let result = counter.send_request(GetCount).await.unwrap(); + let result = counter.request(GetCount).await.unwrap(); assert_eq!(result, 0); - let result = counter.send_request(Increment).await.unwrap(); + let result = counter.request(Increment).await.unwrap(); assert_eq!(result, 1); counter.send(Increment).unwrap(); rt::sleep(Duration::from_millis(50)).await; - let result = counter.send_request(GetCount).await.unwrap(); + let result = counter.request(GetCount).await.unwrap(); assert_eq!(result, 2); - let final_count = counter.send_request(StopCounter).await.unwrap(); + let final_count = counter.request(StopCounter).await.unwrap(); assert_eq!(final_count, 2); }); } @@ -634,21 +657,21 @@ mod tests { let blocking_counter = Counter { count: 100 }.start_with_backend(Backend::Blocking); let thread_counter = Counter { count: 200 }.start_with_backend(Backend::Thread); - async_counter.send_request(Increment).await.unwrap(); - blocking_counter.send_request(Increment).await.unwrap(); - thread_counter.send_request(Increment).await.unwrap(); + async_counter.request(Increment).await.unwrap(); + blocking_counter.request(Increment).await.unwrap(); + thread_counter.request(Increment).await.unwrap(); - let async_val = async_counter.send_request(GetCount).await.unwrap(); - let blocking_val = blocking_counter.send_request(GetCount).await.unwrap(); - let thread_val = thread_counter.send_request(GetCount).await.unwrap(); + let async_val = async_counter.request(GetCount).await.unwrap(); + let blocking_val = blocking_counter.request(GetCount).await.unwrap(); + let thread_val = thread_counter.request(GetCount).await.unwrap(); assert_eq!(async_val, 1); assert_eq!(blocking_val, 101); assert_eq!(thread_val, 201); - async_counter.send_request(StopCounter).await.unwrap(); - blocking_counter.send_request(StopCounter).await.unwrap(); - thread_counter.send_request(StopCounter).await.unwrap(); + async_counter.request(StopCounter).await.unwrap(); + blocking_counter.request(StopCounter).await.unwrap(); + thread_counter.request(StopCounter).await.unwrap(); }); } @@ -667,7 +690,7 @@ mod tests { let actor = SlowActor.start(); let result = actor - .send_request_with_timeout(SlowOp, Duration::from_millis(50)) + .request_with_timeout(SlowOp, Duration::from_millis(50)) .await; assert!(matches!(result, Err(ActorError::RequestTimeout))); }); @@ -680,12 +703,12 @@ mod tests { let counter = Counter { count: 42 }.start(); let recipient: Recipient = counter.recipient(); - let rx = recipient.request(GetCount).unwrap(); + let rx = recipient.request_raw(GetCount).unwrap(); let result = rx.await.unwrap(); assert_eq!(result, 42); - // Also test send_request helper - let result = send_request(&*recipient, GetCount, Duration::from_secs(5)).await.unwrap(); + // Also test request helper + let result = request(&*recipient, GetCount, Duration::from_secs(5)).await.unwrap(); assert_eq!(result, 42); }); } @@ -825,9 +848,9 @@ mod tests { let goodboy = WellBehavedTask { count: 0 }.start(); goodboy.send(IncrementWell).unwrap(); rt::sleep(Duration::from_secs(1)).await; - let count = goodboy.send_request(GetCount).await.unwrap(); + let count = goodboy.request(GetCount).await.unwrap(); assert_ne!(count, 10); - goodboy.send_request(StopCounter).await.unwrap(); + goodboy.request(StopCounter).await.unwrap(); }); } @@ -840,9 +863,9 @@ mod tests { let goodboy = WellBehavedTask { count: 0 }.start(); goodboy.send(IncrementWell).unwrap(); rt::sleep(Duration::from_secs(1)).await; - let count = goodboy.send_request(GetCount).await.unwrap(); + let count = goodboy.request(GetCount).await.unwrap(); assert_eq!(count, 10); - goodboy.send_request(StopCounter).await.unwrap(); + goodboy.request(StopCounter).await.unwrap(); }); } @@ -855,9 +878,9 @@ mod tests { let goodboy = WellBehavedTask { count: 0 }.start(); goodboy.send(IncrementWell).unwrap(); rt::sleep(Duration::from_secs(1)).await; - let count = goodboy.send_request(GetCount).await.unwrap(); + let count = goodboy.request(GetCount).await.unwrap(); assert_eq!(count, 10); - goodboy.send_request(StopCounter).await.unwrap(); + goodboy.request(StopCounter).await.unwrap(); }); } } diff --git a/concurrency/src/tasks/mod.rs b/concurrency/src/tasks/mod.rs index 2e364fa..c92e4cf 100644 --- a/concurrency/src/tasks/mod.rs +++ b/concurrency/src/tasks/mod.rs @@ -10,7 +10,7 @@ mod timer_tests; pub use actor::{ send_message_on, Actor, ActorRef, ActorStart, Backend, Context, Handler, Receiver, Recipient, - send_request, + request, }; pub use process::{send, Process, ProcessInfo}; pub use stream::spawn_listener; diff --git a/concurrency/src/tasks/stream_tests.rs b/concurrency/src/tasks/stream_tests.rs index 69c5a6f..75d1000 100644 --- a/concurrency/src/tasks/stream_tests.rs +++ b/concurrency/src/tasks/stream_tests.rs @@ -74,7 +74,7 @@ pub fn test_sum_numbers_from_stream() { rt::sleep(Duration::from_secs(1)).await; - let val = summatory.send_request(GetValue).await.unwrap(); + let val = summatory.request(GetValue).await.unwrap(); assert_eq!(val, 15); }) } @@ -101,7 +101,7 @@ pub fn test_sum_numbers_from_channel() { rt::sleep(Duration::from_secs(1)).await; - let val = summatory.send_request(GetValue).await.unwrap(); + let val = summatory.request(GetValue).await.unwrap(); assert_eq!(val, 15); }) } @@ -128,7 +128,7 @@ pub fn test_sum_numbers_from_broadcast_channel() { rt::sleep(Duration::from_secs(1)).await; - let val = summatory.send_request(GetValue).await.unwrap(); + let val = summatory.request(GetValue).await.unwrap(); assert_eq!(val, 15); }) } @@ -165,14 +165,14 @@ pub fn test_stream_cancellation() { ); rt::sleep(Duration::from_millis(READ_TIME)).await; - let val = summatory.send_request(GetValue).await.unwrap(); + let val = summatory.request(GetValue).await.unwrap(); assert!((1..=15).contains(&val)); assert!(listener_handle.await.is_ok()); rt::sleep(Duration::from_millis(10)).await; - assert!(summatory.send_request(GetValue).await.is_err()); + assert!(summatory.request(GetValue).await.is_err()); }) } @@ -194,7 +194,7 @@ pub fn test_halting_on_stream_error() { rt::sleep(Duration::from_secs(1)).await; - let result = summatory.send_request(GetValue).await; + let result = summatory.request(GetValue).await; assert!(result.is_err()); }) } @@ -217,7 +217,7 @@ pub fn test_skipping_on_stream_error() { rt::sleep(Duration::from_secs(1)).await; - let val = summatory.send_request(GetValue).await.unwrap(); + let val = summatory.request(GetValue).await.unwrap(); assert_eq!(val, 15); }) } diff --git a/concurrency/src/tasks/timer_tests.rs b/concurrency/src/tasks/timer_tests.rs index 205d5ba..a1ddff7 100644 --- a/concurrency/src/tasks/timer_tests.rs +++ b/concurrency/src/tasks/timer_tests.rs @@ -72,14 +72,14 @@ pub fn test_send_interval_and_cancellation() { rt::sleep(Duration::from_secs(1)).await; - let count = repeater.send_request(GetRepCount).await.unwrap(); + let count = repeater.request(GetRepCount).await.unwrap(); assert_eq!(9, count); repeater.send(StopTimer).unwrap(); rt::sleep(Duration::from_secs(1)).await; - let count2 = repeater.send_request(GetRepCount).await.unwrap(); + let count2 = repeater.request(GetRepCount).await.unwrap(); assert_eq!(9, count2); }); } @@ -140,7 +140,7 @@ pub fn test_send_after_and_cancellation() { rt::sleep(Duration::from_millis(200)).await; - let count = repeater.send_request(GetDelCount).await.unwrap(); + let count = repeater.request(GetDelCount).await.unwrap(); assert_eq!(1, count); let ctx = Context::from_ref(&repeater); @@ -154,7 +154,7 @@ pub fn test_send_after_and_cancellation() { rt::sleep(Duration::from_millis(200)).await; - let count2 = repeater.send_request(GetDelCount).await.unwrap(); + let count2 = repeater.request(GetDelCount).await.unwrap(); assert_eq!(1, count2); }); } @@ -174,7 +174,7 @@ pub fn test_send_after_gen_server_teardown() { rt::sleep(Duration::from_millis(200)).await; - let count = repeater.send_request(GetDelCount).await.unwrap(); + let count = repeater.request(GetDelCount).await.unwrap(); assert_eq!(1, count); let ctx = Context::from_ref(&repeater); @@ -184,7 +184,7 @@ pub fn test_send_after_gen_server_teardown() { Inc, ); - let count2 = repeater.send_request(StopDelayed).await.unwrap(); + let count2 = repeater.request(StopDelayed).await.unwrap(); rt::sleep(Duration::from_millis(200)).await; diff --git a/concurrency/src/threads/actor.rs b/concurrency/src/threads/actor.rs index d92a296..f7e7a53 100644 --- a/concurrency/src/threads/actor.rs +++ b/concurrency/src/threads/actor.rs @@ -103,7 +103,7 @@ impl Context { .map_err(|_| ActorError::ActorStopped) } - pub fn request(&self, msg: M) -> Result, ActorError> + pub fn request_raw(&self, msg: M) -> Result, ActorError> where A: Handler, M: Message, @@ -119,15 +119,15 @@ impl Context { Ok(rx) } - pub fn send_request(&self, msg: M) -> Result + pub fn request(&self, msg: M) -> Result where A: Handler, M: Message, { - self.send_request_with_timeout(msg, DEFAULT_REQUEST_TIMEOUT) + self.request_with_timeout(msg, DEFAULT_REQUEST_TIMEOUT) } - pub fn send_request_with_timeout( + pub fn request_with_timeout( &self, msg: M, duration: Duration, @@ -136,7 +136,7 @@ impl Context { A: Handler, M: Message, { - let rx = self.request(msg)?; + let rx = self.request_raw(msg)?; match rx.recv_timeout(duration) { Ok(result) => Ok(result), Err(RecvTimeoutError::Timeout) => Err(ActorError::RequestTimeout), @@ -144,28 +144,51 @@ impl Context { } } + pub fn recipient(&self) -> Recipient + where + A: Handler, + M: Message, + { + Arc::new(self.clone()) + } + pub(crate) fn cancellation_token(&self) -> CancellationToken { self.cancellation_token.clone() } } +// Bridge: Context implements Receiver for any M that A handles +impl Receiver for Context +where + A: Actor + Handler, + M: Message, +{ + fn send(&self, msg: M) -> Result<(), ActorError> { + Context::send(self, msg) + } + + fn request_raw(&self, msg: M) -> Result, ActorError> { + Context::request_raw(self, msg) + } +} + // --------------------------------------------------------------------------- // Receiver trait (object-safe) + Recipient alias // --------------------------------------------------------------------------- pub trait Receiver: Send + Sync { fn send(&self, msg: M) -> Result<(), ActorError>; - fn request(&self, msg: M) -> Result, ActorError>; + fn request_raw(&self, msg: M) -> Result, ActorError>; } pub type Recipient = Arc>; -pub fn send_request( +pub fn request( recipient: &dyn Receiver, msg: M, timeout: Duration, ) -> Result { - let rx = recipient.request(msg)?; + let rx = recipient.request_raw(msg)?; match rx.recv_timeout(timeout) { Ok(result) => Ok(result), Err(RecvTimeoutError::Timeout) => Err(ActorError::RequestTimeout), @@ -222,7 +245,7 @@ impl ActorRef { .map_err(|_| ActorError::ActorStopped) } - pub fn request(&self, msg: M) -> Result, ActorError> + pub fn request_raw(&self, msg: M) -> Result, ActorError> where A: Handler, M: Message, @@ -238,15 +261,15 @@ impl ActorRef { Ok(rx) } - pub fn send_request(&self, msg: M) -> Result + pub fn request(&self, msg: M) -> Result where A: Handler, M: Message, { - self.send_request_with_timeout(msg, DEFAULT_REQUEST_TIMEOUT) + self.request_with_timeout(msg, DEFAULT_REQUEST_TIMEOUT) } - pub fn send_request_with_timeout( + pub fn request_with_timeout( &self, msg: M, duration: Duration, @@ -255,7 +278,7 @@ impl ActorRef { A: Handler, M: Message, { - let rx = self.request(msg)?; + let rx = self.request_raw(msg)?; match rx.recv_timeout(duration) { Ok(result) => Ok(result), Err(RecvTimeoutError::Timeout) => Err(ActorError::RequestTimeout), @@ -294,8 +317,8 @@ where ActorRef::send(self, msg) } - fn request(&self, msg: M) -> Result, ActorError> { - ActorRef::request(self, msg) + fn request_raw(&self, msg: M) -> Result, ActorError> { + ActorRef::request_raw(self, msg) } } diff --git a/concurrency/src/threads/mod.rs b/concurrency/src/threads/mod.rs index 535423e..0e6758c 100644 --- a/concurrency/src/threads/mod.rs +++ b/concurrency/src/threads/mod.rs @@ -8,7 +8,7 @@ mod timer_tests; pub use actor::{ send_message_on, Actor, ActorRef, ActorStart, Context, Handler, Receiver, Recipient, - send_request, + request, }; pub use process::{send, Process, ProcessInfo}; pub use stream::spawn_listener; diff --git a/concurrency/src/threads/timer_tests.rs b/concurrency/src/threads/timer_tests.rs index 6ba13c9..0339d02 100644 --- a/concurrency/src/threads/timer_tests.rs +++ b/concurrency/src/threads/timer_tests.rs @@ -70,14 +70,14 @@ pub fn test_send_interval_and_cancellation() { rt::sleep(Duration::from_secs(1)); - let count = repeater.send_request(GetRepCount).unwrap(); + let count = repeater.request(GetRepCount).unwrap(); assert_eq!(9, count); repeater.send(StopTimer).unwrap(); rt::sleep(Duration::from_secs(1)); - let count2 = repeater.send_request(GetRepCount).unwrap(); + let count2 = repeater.request(GetRepCount).unwrap(); assert_eq!(9, count2); } @@ -131,7 +131,7 @@ pub fn test_send_after_and_cancellation() { rt::sleep(Duration::from_millis(200)); - let count = actor.send_request(GetDelCount).unwrap(); + let count = actor.request(GetDelCount).unwrap(); assert_eq!(1, count); let ctx = Context::from_ref(&actor); @@ -141,7 +141,7 @@ pub fn test_send_after_and_cancellation() { rt::sleep(Duration::from_millis(200)); - let count2 = actor.send_request(GetDelCount).unwrap(); + let count2 = actor.request(GetDelCount).unwrap(); assert_eq!(1, count2); } @@ -154,13 +154,13 @@ pub fn test_send_after_actor_shutdown() { rt::sleep(Duration::from_millis(200)); - let count = actor.send_request(GetDelCount).unwrap(); + let count = actor.request(GetDelCount).unwrap(); assert_eq!(1, count); let ctx = Context::from_ref(&actor); let _ = send_after(Duration::from_millis(100), ctx, Inc); - let count2 = actor.send_request(StopDelayed).unwrap(); + let count2 = actor.request(StopDelayed).unwrap(); rt::sleep(Duration::from_millis(200)); diff --git a/examples/bank/src/server.rs b/examples/bank/src/server.rs index 8393a73..e56cc7b 100644 --- a/examples/bank/src/server.rs +++ b/examples/bank/src/server.rs @@ -21,28 +21,28 @@ impl Bank { impl Bank { pub async fn stop(server: &ActorRef) -> MsgResult { server - .send_request(Stop) + .request(Stop) .await .unwrap_or(Err(BankError::ServerError)) } pub async fn new_account(server: &ActorRef, who: String) -> MsgResult { server - .send_request(NewAccount { who }) + .request(NewAccount { who }) .await .unwrap_or(Err(BankError::ServerError)) } pub async fn deposit(server: &ActorRef, who: String, amount: i32) -> MsgResult { server - .send_request(Deposit { who, amount }) + .request(Deposit { who, amount }) .await .unwrap_or(Err(BankError::ServerError)) } pub async fn withdraw(server: &ActorRef, who: String, amount: i32) -> MsgResult { server - .send_request(Withdraw { who, amount }) + .request(Withdraw { who, amount }) .await .unwrap_or(Err(BankError::ServerError)) } diff --git a/examples/bank_threads/src/server.rs b/examples/bank_threads/src/server.rs index ec231dc..c36f884 100644 --- a/examples/bank_threads/src/server.rs +++ b/examples/bank_threads/src/server.rs @@ -21,25 +21,25 @@ impl Bank { impl Bank { pub fn stop(server: &ActorRef) -> MsgResult { server - .send_request(Stop) + .request(Stop) .unwrap_or(Err(BankError::ServerError)) } pub fn new_account(server: &ActorRef, who: String) -> MsgResult { server - .send_request(NewAccount { who }) + .request(NewAccount { who }) .unwrap_or(Err(BankError::ServerError)) } pub fn deposit(server: &ActorRef, who: String, amount: i32) -> MsgResult { server - .send_request(Deposit { who, amount }) + .request(Deposit { who, amount }) .unwrap_or(Err(BankError::ServerError)) } pub fn withdraw(server: &ActorRef, who: String, amount: i32) -> MsgResult { server - .send_request(Withdraw { who, amount }) + .request(Withdraw { who, amount }) .unwrap_or(Err(BankError::ServerError)) } } diff --git a/examples/blocking_genserver/main.rs b/examples/blocking_genserver/main.rs index 37d00d0..54f5f0f 100644 --- a/examples/blocking_genserver/main.rs +++ b/examples/blocking_genserver/main.rs @@ -84,11 +84,11 @@ pub fn main() { let goodboy = WellBehavedTask::new(0).start(); let _ = goodboy.send(GoodWork); rt::sleep(Duration::from_secs(1)).await; - let count = goodboy.send_request(GetCount).await.unwrap(); + let count = goodboy.request(GetCount).await.unwrap(); assert!(count == 10); - goodboy.send_request(StopActor).await.unwrap(); + goodboy.request(StopActor).await.unwrap(); exit(0); }) } diff --git a/examples/chat_room/src/main.rs b/examples/chat_room/src/main.rs index 9171b9a..5f929f4 100644 --- a/examples/chat_room/src/main.rs +++ b/examples/chat_room/src/main.rs @@ -1,57 +1,38 @@ -mod messages; mod room; mod user; -use messages::{Deliver, Join, SayToRoom}; -use room::ChatRoom; +use room::{ChatRoom, ChatRoomApi}; use spawned_concurrency::tasks::ActorStart; -use user::User; +use user::{User, UserApi}; #[tokio::main] async fn main() { tracing_subscriber::fmt() - .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) .init(); let room = ChatRoom::new().start(); - let alice = User { - name: "Alice".into(), - room: room.recipient(), - } - .start(); - - let bob = User { - name: "Bob".into(), - room: room.recipient(), - } - .start(); - - // Register users in the room - room.send(Join { - name: "Alice".into(), - inbox: alice.recipient::(), - }) - .unwrap(); - - room.send(Join { - name: "Bob".into(), - inbox: bob.recipient::(), - }) - .unwrap(); - - // Alice says something - alice - .send(SayToRoom { - text: "Hello everyone!".into(), - }) - .unwrap(); - - // Bob says something - bob.send(SayToRoom { - text: "Hi Alice!".into(), - }) - .unwrap(); + let alice = User::new("Alice".into()).start(); + let bob = User::new("Bob".into()).start(); + + // Register users in the room (send — fire-and-forget) + alice.join_room(&room).unwrap(); + bob.join_room(&room).unwrap(); + + // Let join messages propagate (user → room) + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + + // Query members (request — awaits a response) + let members = room.members().await.unwrap(); + tracing::info!("Members in room: {:?}", members); + + // Chat (send — fire-and-forget) + alice.say("Hello everyone!".into()).unwrap(); + bob.say("Hi Alice!".into()).unwrap(); // Give time for messages to propagate tokio::time::sleep(std::time::Duration::from_millis(100)).await; diff --git a/examples/chat_room/src/messages.rs b/examples/chat_room/src/messages.rs deleted file mode 100644 index a085f5a..0000000 --- a/examples/chat_room/src/messages.rs +++ /dev/null @@ -1,18 +0,0 @@ -use spawned_concurrency::message::Message; -use spawned_concurrency::tasks::Recipient; - -spawned_concurrency::messages! { - Say { from: String, text: String } -> (); - Deliver { from: String, text: String } -> (); - SayToRoom { text: String } -> () -} - -// Join has a Recipient field — define manually since messages! doesn't support it -pub struct Join { - pub name: String, - pub inbox: Recipient, -} - -impl Message for Join { - type Result = (); -} diff --git a/examples/chat_room/src/room.rs b/examples/chat_room/src/room.rs index 2ad97bb..b332fc5 100644 --- a/examples/chat_room/src/room.rs +++ b/examples/chat_room/src/room.rs @@ -1,7 +1,20 @@ -use spawned_concurrency::tasks::{Actor, Context, Handler, Recipient}; +use spawned_concurrency::error::ActorError; +use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler, Recipient}; use spawned_macros::actor; -use crate::messages::{Deliver, Join, Say}; +// -- ChatRoom messages -- + +spawned_concurrency::send_messages! { + Say { from: String, text: String }; + Deliver { from: String, text: String }; + Join { name: String, inbox: Recipient } +} + +spawned_concurrency::request_messages! { + Members -> Vec +} + +// -- ChatRoom actor -- pub struct ChatRoom { members: Vec<(String, Recipient)>, @@ -15,12 +28,32 @@ impl ChatRoom { } } +pub trait ChatRoomApi { + fn say(&self, from: String, text: String) -> Result<(), ActorError>; + fn add_member(&self, name: String, inbox: Recipient) -> Result<(), ActorError>; + async fn members(&self) -> Result, ActorError>; +} + +impl ChatRoomApi for ActorRef { + fn say(&self, from: String, text: String) -> Result<(), ActorError> { + self.send(Say { from, text }) + } + + fn add_member(&self, name: String, inbox: Recipient) -> Result<(), ActorError> { + self.send(Join { name, inbox }) + } + + async fn members(&self) -> Result, ActorError> { + self.request(Members).await + } +} + impl Actor for ChatRoom {} #[actor] impl ChatRoom { - #[handler] - async fn on_say(&mut self, msg: Say, _ctx: &Context) { + #[send_handler] + async fn handle_say(&mut self, msg: Say, _ctx: &Context) { for (name, inbox) in &self.members { if *name != msg.from { let _ = inbox.send(Deliver { @@ -31,9 +64,14 @@ impl ChatRoom { } } - #[handler] - async fn on_join(&mut self, msg: Join, _ctx: &Context) { + #[send_handler] + async fn handle_join(&mut self, msg: Join, _ctx: &Context) { tracing::info!("[room] {} joined", msg.name); self.members.push((msg.name, msg.inbox)); } + + #[request_handler] + async fn handle_members(&mut self, _msg: Members, _ctx: &Context) -> Vec { + self.members.iter().map(|(name, _)| name.clone()).collect() + } } diff --git a/examples/chat_room/src/user.rs b/examples/chat_room/src/user.rs index 79a94e8..2ff3fbc 100644 --- a/examples/chat_room/src/user.rs +++ b/examples/chat_room/src/user.rs @@ -1,27 +1,61 @@ -use spawned_concurrency::tasks::{Actor, Context, Handler, Recipient}; +use spawned_concurrency::error::ActorError; +use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; use spawned_macros::actor; -use crate::messages::{Deliver, Say, SayToRoom}; +use crate::room::{ChatRoom, ChatRoomApi, Deliver}; + +// -- User messages -- + +spawned_concurrency::send_messages! { + SayToRoom { text: String }; + JoinRoom { room: ActorRef } +} pub struct User { pub name: String, - pub room: Recipient, + room: Option>, +} + +impl User { + pub fn new(name: String) -> Self { + Self { name, room: None } + } +} + +pub trait UserApi { + fn say(&self, text: String) -> Result<(), ActorError>; + fn join_room(&self, room: &ActorRef) -> Result<(), ActorError>; +} + +impl UserApi for ActorRef { + fn say(&self, text: String) -> Result<(), ActorError> { + self.send(SayToRoom { text }) + } + + fn join_room(&self, room: &ActorRef) -> Result<(), ActorError> { + self.send(JoinRoom { room: room.clone() }) + } } impl Actor for User {} #[actor] impl User { - #[handler] - async fn on_say_to_room(&mut self, msg: SayToRoom, _ctx: &Context) { - let _ = self.room.send(Say { - from: self.name.clone(), - text: msg.text, - }); + #[send_handler] + async fn handle_say_to_room(&mut self, msg: SayToRoom, _ctx: &Context) { + if let Some(ref room) = self.room { + let _ = room.say(self.name.clone(), msg.text); + } + } + + #[send_handler] + async fn handle_join_room(&mut self, msg: JoinRoom, ctx: &Context) { + let _ = msg.room.add_member(self.name.clone(), ctx.recipient::()); + self.room = Some(msg.room); } - #[handler] - async fn on_deliver(&mut self, msg: Deliver, _ctx: &Context) { + #[send_handler] + async fn handle_deliver(&mut self, msg: Deliver, _ctx: &Context) { tracing::info!("[{}] got: {} says '{}'", self.name, msg.from, msg.text); } } diff --git a/examples/name_server/src/server.rs b/examples/name_server/src/server.rs index c8e74fe..ae75f03 100644 --- a/examples/name_server/src/server.rs +++ b/examples/name_server/src/server.rs @@ -18,7 +18,7 @@ impl NameServer { impl NameServer { pub async fn add(server: &ActorRef, key: String, value: String) -> OutMessage { - match server.send_request(Add { key, value }).await { + match server.request(Add { key, value }).await { Ok(_) => OutMessage::Ok, Err(_) => OutMessage::Error, } @@ -26,7 +26,7 @@ impl NameServer { pub async fn find(server: &ActorRef, key: String) -> OutMessage { server - .send_request(Find { key }) + .request(Find { key }) .await .unwrap_or(OutMessage::Error) } diff --git a/examples/service_discovery/src/main.rs b/examples/service_discovery/src/main.rs index b546b5e..80d3239 100644 --- a/examples/service_discovery/src/main.rs +++ b/examples/service_discovery/src/main.rs @@ -1,5 +1,5 @@ use spawned_concurrency::registry; -use spawned_concurrency::tasks::{Actor, ActorStart, Context, Handler, Recipient, send_request}; +use spawned_concurrency::tasks::{Actor, ActorStart, Context, Handler, Recipient, request}; use spawned_macros::actor; use std::collections::HashMap; use std::time::Duration; @@ -31,18 +31,18 @@ impl Actor for ServiceRegistry {} #[actor] impl ServiceRegistry { #[handler] - async fn on_register(&mut self, msg: Register, _ctx: &Context) { + async fn handle_register(&mut self, msg: Register, _ctx: &Context) { tracing::info!("Registered service '{}' at {}", msg.name, msg.address); self.services.insert(msg.name, msg.address); } #[handler] - async fn on_lookup(&mut self, msg: Lookup, _ctx: &Context) -> Option { + async fn handle_lookup(&mut self, msg: Lookup, _ctx: &Context) -> Option { self.services.get(&msg.name).cloned() } #[handler] - async fn on_list_all(&mut self, _msg: ListAll, _ctx: &Context) -> Vec<(String, String)> { + async fn handle_list_all(&mut self, _msg: ListAll, _ctx: &Context) -> Vec<(String, String)> { self.services.iter().map(|(k, v)| (k.clone(), v.clone())).collect() } } @@ -50,7 +50,10 @@ impl ServiceRegistry { #[tokio::main] async fn main() { tracing_subscriber::fmt() - .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) .init(); // Start the service registry actor @@ -76,7 +79,7 @@ async fn main() { let lookup_recipient: Recipient = registry::whereis("service_registry").unwrap(); // Look up a service - let addr = send_request( + let addr = request( &*lookup_recipient, Lookup { name: "web".into(), @@ -92,7 +95,7 @@ async fn main() { tracing::info!("Registry contains: {:?}", names); // Direct request for all services - let all = svc.send_request(ListAll).await.unwrap(); + let all = svc.request(ListAll).await.unwrap(); tracing::info!("All services: {:?}", all); // Clean up diff --git a/macros/src/lib.rs b/macros/src/lib.rs index 0cc9530..f8dda17 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -5,27 +5,32 @@ use syn::{parse_macro_input, FnArg, ImplItem, ItemImpl, Pat, ReturnType, Type}; /// Attribute macro for actor impl blocks. /// /// Place `#[actor]` on an `impl MyActor` block containing methods annotated -/// with `#[handler]`. For each `#[handler]` method, the macro generates a -/// corresponding `impl Handler for MyActor` block. +/// with `#[send_handler]` or `#[request_handler]`. For each annotated method, +/// the macro generates a corresponding `impl Handler for MyActor` block. /// -/// # Handler method signature +/// Use `#[send_handler]` for fire-and-forget messages (no return value): /// /// ```ignore -/// #[handler] +/// #[send_handler] /// async fn on_deposit(&mut self, msg: Deposit, ctx: &Context) { ... } -/// // or with explicit return: -/// #[handler] -/// async fn on_withdraw(&mut self, msg: Withdraw, ctx: &Context) -> Result { ... } +/// ``` +/// +/// Use `#[request_handler]` for request-response messages (returns a value): +/// +/// ```ignore +/// #[request_handler] +/// async fn on_balance(&mut self, msg: GetBalance, ctx: &Context) -> u64 { ... } /// ``` /// /// Sync handlers (for the `threads` module) omit `async`: /// /// ```ignore -/// #[handler] +/// #[send_handler] /// fn on_deposit(&mut self, msg: Deposit, ctx: &Context) { ... } /// ``` /// -/// The generated `Handler` impl delegates to the original method. +/// The generic `#[handler]` attribute is also supported for backwards +/// compatibility and works for both send and request handlers. #[proc_macro_attribute] pub fn actor(_attr: TokenStream, item: TokenStream) -> TokenStream { let mut impl_block = parse_macro_input!(item as ItemImpl); @@ -37,9 +42,10 @@ pub fn actor(_attr: TokenStream, item: TokenStream) -> TokenStream { for item in &mut impl_block.items { if let ImplItem::Fn(method) = item { - // Check for #[handler] attribute let handler_idx = method.attrs.iter().position(|attr| { attr.path().is_ident("handler") + || attr.path().is_ident("send_handler") + || attr.path().is_ident("request_handler") }); if let Some(idx) = handler_idx { @@ -61,7 +67,7 @@ pub fn actor(_attr: TokenStream, item: TokenStream) -> TokenStream { _ => { return syn::Error::new_spanned( &method.sig, - "#[handler] method must have signature: fn(&mut self, msg: M, ctx: &Context) -> R", + "handler method must have signature: fn(&mut self, msg: M, ctx: &Context) -> R", ) .to_compile_error() .into(); From 73cda3b9d5770bac3510b138821e60e7dfc7eb22 Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Fri, 13 Feb 2026 17:50:31 -0300 Subject: [PATCH 04/20] docs: add API alternatives summary with chat room comparison --- docs/API_ALTERNATIVES_SUMMARY.md | 961 +++++++++++++++++++++++++++++++ 1 file changed, 961 insertions(+) create mode 100644 docs/API_ALTERNATIVES_SUMMARY.md diff --git a/docs/API_ALTERNATIVES_SUMMARY.md b/docs/API_ALTERNATIVES_SUMMARY.md new file mode 100644 index 0000000..9c109eb --- /dev/null +++ b/docs/API_ALTERNATIVES_SUMMARY.md @@ -0,0 +1,961 @@ +# API Redesign: Alternatives Summary + +This document summarizes the different approaches explored for solving two critical API issues in spawned's actor framework. Each approach is illustrated with the **same example** — a chat room with bidirectional communication — so the trade-offs in expressivity, readability, and ease of use can be compared directly. + +## Table of Contents + +- [The Two Problems](#the-two-problems) +- [The Chat Room Example](#the-chat-room-example) +- [Baseline: The Old API](#baseline-the-old-api-whats-on-main-today) +- [Approach A: Handler\ + Recipient\](#approach-a-handlerm--recipientm-actix-style) +- [Approach B: Protocol Traits](#approach-b-protocol-traits-user-defined-contracts) +- [Approach C: Typed Wrappers](#approach-c-typed-wrappers-non-breaking) +- [Approach D: Derive Macro](#approach-d-derive-macro) +- [Approach E: AnyActorRef](#approach-e-anyactorref-fully-type-erased) +- [Approach F: PID Addressing](#approach-f-pid-addressing-erlang-style) +- [Comparison Matrix](#comparison-matrix) +- [Recommendation](#recommendation) +- [Branch Reference](#branch-reference) + +--- + +## The Two Problems + +### #144: No per-message type safety + +The original API uses a single enum for all request types and another for all reply types. Every `match` must handle variants that are structurally impossible for the message sent: + +```rust +// Old API — every request returns the full Reply enum +match actor.request(Request::GetName).await? { + Reply::Name(n) => println!("{}", n), + Reply::NotFound => println!("not found"), + Reply::Age(_) => unreachable!(), // impossible, but the compiler demands it +} +``` + +### #145: Circular dependencies between actors + +When two actors need bidirectional communication, storing `ActorRef` and `ActorRef` creates a circular module dependency: + +```rust +// room.rs — needs to send Deliver to Users +struct ChatRoom { members: Vec> } // imports User + +// user.rs — needs to send Say to the Room +struct User { room: ActorRef } // imports ChatRoom → circular! +``` + +--- + +## The Chat Room Example + +Every approach below implements the same scenario: + +- **ChatRoom** actor holds a list of members and broadcasts messages +- **User** actor receives messages and can speak to the room +- The room sends `Deliver` to users; users send `Say` to the room → **bidirectional** +- `Members` is a request-reply message that returns the current member list + +This exercises both #144 (typed request-reply) and #145 (circular dependency breaking). + +--- + +## Baseline: The Old API (what's on `main` today) + +Single-enum approach inspired by Erlang's gen_server callbacks: + +```rust +trait Actor: Send + Sized + 'static { + type Request: Clone + Send; // single enum for all call messages + type Message: Clone + Send; // single enum for all cast messages + type Reply: Send; // single enum for all responses + type Error: Debug + Send; + + async fn handle_request(&mut self, msg: Self::Request, ...) -> RequestResponse; + async fn handle_message(&mut self, msg: Self::Message, ...) -> MessageResponse; +} +``` + +**The chat room cannot be built** with the old API as separate modules. There's no type-erasure mechanism, so `ChatRoom` must store `ActorRef` (imports User) while `User` must store `ActorRef` (imports ChatRoom) — circular. You'd have to put everything in a single file or use raw channels. + +Even ignoring #145, the #144 problem means this: + +```rust +// room.rs — all messages in one enum, all replies in another +#[derive(Clone)] +enum RoomRequest { Say { from: String, text: String }, Members } + +#[derive(Clone)] +enum RoomReply { Ack, MemberList(Vec) } + +impl Actor for ChatRoom { + type Request = RoomRequest; + type Reply = RoomReply; + // ... + + async fn handle_request(&mut self, msg: RoomRequest, handle: &ActorRef) -> RequestResponse { + match msg { + RoomRequest::Say { from, text } => { /* broadcast */ RequestResponse::Reply(RoomReply::Ack) } + RoomRequest::Members => RequestResponse::Reply(RoomReply::MemberList(self.member_names())), + } + } +} + +// Caller — must match impossible variants +match room.request(RoomRequest::Members).await? { + RoomReply::MemberList(names) => println!("{:?}", names), + RoomReply::Ack => unreachable!(), // ← impossible but required +} +``` + +**Readability:** The trait signature is self-contained but the enum matching is noisy. New team members must mentally map which reply variants are valid for each request variant — the compiler won't help. + +--- + +## Approach A: Handler\ + Recipient\ (Actix-style) + +**Branches:** [`feat/handler-api-v0.5`](../../tree/feat/handler-api-v0.5) (implementation), [`feat/critical-api-issues`](../../tree/feat/critical-api-issues) (design doc), [`feat/actor-macro-registry`](../../tree/feat/actor-macro-registry) (adds macro + registry) + +**Status:** Fully implemented and working. 34 tests passing. Multiple examples ported. + +Each message is its own struct with an associated `Result` type. Actors implement `Handler` per message. Type erasure uses `Recipient = Arc>`. + +### Without macro (manual `impl Handler`) + +
+messages.rs — shared types, no actor types mentioned + +```rust +use spawned_concurrency::message::Message; +use spawned_concurrency::messages; +use spawned_concurrency::tasks::Recipient; + +pub struct Join { + pub name: String, + pub inbox: Recipient, +} +impl Message for Join { type Result = (); } + +messages! { + Say { from: String, text: String } -> (); + SayToRoom { text: String } -> (); + Deliver { from: String, text: String } -> (); +} +``` +
+ +
+room.rs — knows messages, not User + +```rust +use spawned_concurrency::tasks::{Actor, Context, Handler, Recipient}; +use crate::messages::{Deliver, Join, Say}; + +pub struct ChatRoom { + members: Vec<(String, Recipient)>, +} + +impl Actor for ChatRoom {} + +impl Handler for ChatRoom { + async fn handle(&mut self, msg: Join, _ctx: &Context) { + self.members.push((msg.name, msg.inbox)); + } +} + +impl Handler for ChatRoom { + async fn handle(&mut self, msg: Say, _ctx: &Context) { + for (name, inbox) in &self.members { + if *name != msg.from { + let _ = inbox.send(Deliver { from: msg.from.clone(), text: msg.text.clone() }); + } + } + } +} +``` +
+ +
+user.rs — knows messages, not ChatRoom + +```rust +use spawned_concurrency::tasks::{Actor, Context, Handler, Recipient}; +use crate::messages::{Deliver, Say, SayToRoom}; + +pub struct User { + pub name: String, + pub room: Recipient, +} + +impl Actor for User {} + +impl Handler for User { + async fn handle(&mut self, msg: SayToRoom, _ctx: &Context) { + let _ = self.room.send(Say { from: self.name.clone(), text: msg.text }); + } +} + +impl Handler for User { + async fn handle(&mut self, msg: Deliver, _ctx: &Context) { + tracing::info!("[{}] got: {} says '{}'", self.name, msg.from, msg.text); + } +} +``` +
+ +
+main.rs + +```rust +let room = ChatRoom::new().start(); +let alice = User { name: "Alice".into(), room: room.recipient() }.start(); +let bob = User { name: "Bob".into(), room: room.recipient() }.start(); + +room.send_request(Join { name: "Alice".into(), inbox: alice.recipient::() }).await?; +room.send_request(Join { name: "Bob".into(), inbox: bob.recipient::() }).await?; + +alice.send_request(SayToRoom { text: "Hello everyone!".into() }).await?; +``` +
+ +### With `#[actor]` macro + extension traits + +
+room.rs — macro eliminates Handler boilerplate + +```rust +use spawned_concurrency::error::ActorError; +use spawned_concurrency::send_messages; +use spawned_concurrency::request_messages; +use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler, Recipient}; +use spawned_macros::actor; + +send_messages! { + Say { from: String, text: String }; + Deliver { from: String, text: String }; + Join { name: String, inbox: Recipient } +} + +request_messages! { + Members -> Vec +} + +pub struct ChatRoom { + members: Vec<(String, Recipient)>, +} + +pub trait ChatRoomApi { + fn say(&self, from: String, text: String) -> Result<(), ActorError>; + fn add_member(&self, name: String, inbox: Recipient) -> Result<(), ActorError>; + async fn members(&self) -> Result, ActorError>; +} + +impl ChatRoomApi for ActorRef { + fn say(&self, from: String, text: String) -> Result<(), ActorError> { self.send(Say { from, text }) } + fn add_member(&self, name: String, inbox: Recipient) -> Result<(), ActorError> { self.send(Join { name, inbox }) } + async fn members(&self) -> Result, ActorError> { self.request(Members).await } +} + +impl Actor for ChatRoom {} + +#[actor] +impl ChatRoom { + #[send_handler] + async fn handle_say(&mut self, msg: Say, _ctx: &Context) { + for (name, inbox) in &self.members { + if *name != msg.from { + let _ = inbox.send(Deliver { from: msg.from.clone(), text: msg.text.clone() }); + } + } + } + + #[send_handler] + async fn handle_join(&mut self, msg: Join, _ctx: &Context) { + self.members.push((msg.name, msg.inbox)); + } + + #[request_handler] + async fn handle_members(&mut self, _msg: Members, _ctx: &Context) -> Vec { + self.members.iter().map(|(name, _)| name.clone()).collect() + } +} +``` +
+ +
+user.rs — macro version + +```rust +use spawned_concurrency::error::ActorError; +use spawned_concurrency::send_messages; +use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; +use spawned_macros::actor; +use crate::room::{ChatRoom, ChatRoomApi, Deliver}; + +send_messages! { + SayToRoom { text: String }; + JoinRoom { room: ActorRef } +} + +pub struct User { + pub name: String, + room: Option>, +} + +pub trait UserApi { + fn say(&self, text: String) -> Result<(), ActorError>; + fn join_room(&self, room: &ActorRef) -> Result<(), ActorError>; +} + +impl UserApi for ActorRef { + fn say(&self, text: String) -> Result<(), ActorError> { self.send(SayToRoom { text }) } + fn join_room(&self, room: &ActorRef) -> Result<(), ActorError> { self.send(JoinRoom { room: room.clone() }) } +} + +impl Actor for User {} + +#[actor] +impl User { + #[send_handler] + async fn handle_say_to_room(&mut self, msg: SayToRoom, _ctx: &Context) { + if let Some(ref room) = self.room { + let _ = room.say(self.name.clone(), msg.text); + } + } + + #[send_handler] + async fn handle_join_room(&mut self, msg: JoinRoom, ctx: &Context) { + let _ = msg.room.add_member(self.name.clone(), ctx.recipient::()); + self.room = Some(msg.room); + } + + #[send_handler] + async fn handle_deliver(&mut self, msg: Deliver, _ctx: &Context) { + tracing::info!("[{}] got: {} says '{}'", self.name, msg.from, msg.text); + } +} +``` +
+ +
+main.rs — extension traits make it read like plain method calls + +```rust +let room = ChatRoom::new().start(); +let alice = User::new("Alice".into()).start(); +let bob = User::new("Bob".into()).start(); + +alice.join_room(&room).unwrap(); +bob.join_room(&room).unwrap(); + +let members = room.members().await.unwrap(); + +alice.say("Hello everyone!".into()).unwrap(); +bob.say("Hi Alice!".into()).unwrap(); +``` +
+ +### Analysis + +| Dimension | Non-macro | With `#[actor]` macro | +|-----------|-----------|----------------------| +| **Readability** | Each `impl Handler` block is self-contained. You see the message type and return type in the trait bound. But many small impl blocks can feel scattered. | `#[send_handler]`/`#[request_handler]` attributes inside a single `#[actor] impl` block group all handlers together. Reads top-to-bottom like a class. | +| **API at a glance** | Must scan all `impl Handler` blocks to know what messages an actor handles. | Same — the `#[actor]` block groups them, but the extension trait (`ChatRoomApi`) is the real "at-a-glance" API surface. | +| **Boilerplate** | One `impl Handler` block per message × per actor. Message structs need manual `impl Message`. | `send_messages!`/`request_messages!` macros eliminate `Message` impls. `#[actor]` eliminates `Handler` impls. Extension traits add some code but improve caller ergonomics. | +| **main.rs expressivity** | Raw message structs: `room.send_request(Join { ... })` — explicit but verbose. | Extension traits: `alice.join_room(&room)` — reads like natural API calls. | +| **Circular dep solution** | `Recipient` — room stores `Recipient`, user stores `Recipient`. Neither knows the other's concrete type. | Same mechanism. The macro doesn't change how type erasure works. | +| **Discoverability** | Standard Rust patterns. Any Rust developer can read `impl Handler`. | `#[actor]` is custom — new developers need to learn what the attribute does, but the pattern is common (Actix uses the same). | + +**Key insight:** The non-macro version is already concise for handler code. The macro's main value is eliminating the `impl Handler for X { fn handle(...) { self.method_name(msg, ctx) } }` delegation wrapper — a small but real reduction per handler. The extension traits (optional, orthogonal to the macro) are what transform `main.rs` from "send raw structs" to "call named methods." + +--- + +## Approach B: Protocol Traits (user-defined contracts) + +**Branch:** [`feat/145-protocol-trait`](../../tree/feat/145-protocol-trait) (WIP, committed) + +**Status:** WIP. Full implementation + migrated examples committed. + +Uses the same `Handler` and `#[actor]` macro as Approach A for #144. Solves #145 differently: instead of `Recipient`, actors communicate across boundaries via explicit user-defined trait objects. + +### Full chat room code + +
+protocols.rs — shared contracts, neither actor type mentioned + +```rust +use spawned_concurrency::error::ActorError; +use std::sync::Arc; + +pub trait ChatParticipant: Send + Sync { + fn deliver(&self, from: String, text: String) -> Result<(), ActorError>; +} + +pub trait ChatBroadcaster: Send + Sync { + fn say(&self, from: String, text: String) -> Result<(), ActorError>; + fn add_member(&self, name: String, inbox: Arc) -> Result<(), ActorError>; +} +``` +
+ +
+messages.rs — internal message structs + +```rust +use spawned_concurrency::messages; + +messages! { + Say { from: String, text: String } -> (); + SayToRoom { text: String } -> (); + Deliver { from: String, text: String } -> (); +} +``` +
+ +
+room.rs — actor + bridge impl + +```rust +use spawned_concurrency::error::ActorError; +use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; +use std::sync::Arc; +use crate::messages::Say; +use crate::protocols::{ChatBroadcaster, ChatParticipant}; + +// Join carries an Arc — manually defined (can't use message macro) +pub struct Join { + pub name: String, + pub inbox: Arc, +} +impl Message for Join { type Result = (); } + +pub struct ChatRoom { + members: Vec<(String, Arc)>, +} + +impl Actor for ChatRoom {} + +impl Handler for ChatRoom { + async fn handle(&mut self, msg: Join, _ctx: &Context) { + self.members.push((msg.name, msg.inbox)); + } +} + +impl Handler for ChatRoom { + async fn handle(&mut self, msg: Say, _ctx: &Context) { + for (name, inbox) in &self.members { + if *name != msg.from { + let _ = inbox.deliver(msg.from.clone(), msg.text.clone()); + } + } + } +} + +// Bridge: ActorRef implements the protocol trait +impl ChatBroadcaster for ActorRef { + fn say(&self, from: String, text: String) -> Result<(), ActorError> { + self.send(Say { from, text }) + } + fn add_member(&self, name: String, inbox: Arc) -> Result<(), ActorError> { + self.send(Join { name, inbox }) + } +} +``` +
+ +
+user.rs — actor + bridge impl + +```rust +use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; +use std::sync::Arc; +use crate::messages::{Deliver, SayToRoom}; +use crate::protocols::{ChatBroadcaster, ChatParticipant}; + +pub struct User { + pub name: String, + pub room: Arc, // protocol trait, not ActorRef +} + +impl Actor for User {} + +impl Handler for User { + async fn handle(&mut self, msg: SayToRoom, _ctx: &Context) { + let _ = self.room.say(self.name.clone(), msg.text); + } +} + +impl Handler for User { + async fn handle(&mut self, msg: Deliver, _ctx: &Context) { + tracing::info!("[{}] got: {} says '{}'", self.name, msg.from, msg.text); + } +} + +// Bridge: ActorRef implements the protocol trait +impl ChatParticipant for ActorRef { + fn deliver(&self, from: String, text: String) -> Result<(), ActorError> { + self.send(Deliver { from, text }) + } +} +``` +
+ +
+main.rs + +```rust +let room = ChatRoom::new().start(); +let alice = User { name: "Alice".into(), room: Arc::new(room.clone()) }.start(); +let bob = User { name: "Bob".into(), room: Arc::new(room.clone()) }.start(); + +room.add_member("Alice".into(), Arc::new(alice.clone())).unwrap(); +room.add_member("Bob".into(), Arc::new(bob.clone())).unwrap(); + +alice.send_request(SayToRoom { text: "Hello everyone!".into() }).await?; +``` +
+ +### Analysis + +| Dimension | Assessment | +|-----------|-----------| +| **Readability** | `protocols.rs` is an excellent summary of what crosses the actor boundary. But the bridge impls (`impl ChatBroadcaster for ActorRef`) are pure boilerplate — each method just wraps `self.send(MessageStruct { ... })`. | +| **API at a glance** | The protocol traits serve as a natural API contract. Looking at `ChatBroadcaster` tells you exactly what a room can do, with named methods and their signatures. This is the strongest "at-a-glance" surface of all approaches. | +| **Boilerplate** | Higher than Approach A. For each cross-actor boundary you need: (1) a protocol trait, (2) a bridge impl, (3) the message structs, and (4) the Handler impls. That's 4 layers of code. With Approach A's `Recipient`, the bridge layer disappears entirely. | +| **main.rs expressivity** | Protocol methods are directly callable: `room.say(...)`, `room.add_member(...)`. But wiring requires `Arc::new()` wrapping: `Arc::new(room.clone())`, `Arc::new(alice.clone())`. | +| **Circular dep solution** | Actors hold `Arc` instead of `ActorRef`. Clean in concept but each new message type crossing the boundary requires adding a method to the protocol trait + updating the bridge impl. | +| **Macro compatibility** | The `#[actor]` macro works for the Handler impls, but bridge impls must still be written manually. The protocol trait itself has no macro support. | +| **Testability** | Best of all approaches — you can mock `ChatBroadcaster` or `ChatParticipant` directly in unit tests without running an actor system. | + +**Key insight:** Protocol traits excel as documentation and testing contracts. But they duplicate information: the protocol trait method `fn say(&self, from: String, text: String)` mirrors the message struct `Say { from: String, text: String }` and the bridge impl just connects them. In Approach A, `Recipient` removes this duplication — the message struct *is* the contract. + +**Scaling concern:** In a system with N actor types and M message types crossing boundaries, Approach A needs M message structs. Approach B needs M message structs + P protocol traits + P bridge impls, where P grows with the number of distinct actor-to-actor interaction patterns. + +--- + +## Approach C: Typed Wrappers (non-breaking) + +**Branch:** Not implemented. Documented in [`docs/ALTERNATIVE_APPROACHES.md`](../../blob/feat/145-protocol-trait/docs/ALTERNATIVE_APPROACHES.md). + +Keeps the old enum-based `Actor` trait unchanged. Adds typed convenience methods that hide the enum matching. For #145, adds a second envelope-based channel to `ActorRef` alongside the existing enum channel. + +### What the chat room would look like + +
+room.rs — enum Actor + typed wrappers + dual channel + +```rust +// Old-style enum messages (unchanged from baseline) +#[derive(Clone)] +pub enum RoomMessage { + Say { from: String, text: String }, + Join { name: String }, +} + +#[derive(Clone)] +pub enum RoomRequest { Members } + +#[derive(Clone)] +pub enum RoomReply { Ack, MemberList(Vec) } + +pub struct ChatRoom { + members: Vec<(String, Recipient)>, // Recipient comes from new dual-channel +} + +impl Actor for ChatRoom { + type Request = RoomRequest; + type Message = RoomMessage; + type Reply = RoomReply; + type Error = std::fmt::Error; + + async fn handle_message(&mut self, msg: RoomMessage, handle: &ActorRef) -> MessageResponse { + match msg { + RoomMessage::Say { from, text } => { + for (name, inbox) in &self.members { + if *name != from { + let _ = inbox.send(Deliver { from: from.clone(), text: text.clone() }); + } + } + MessageResponse::NoReply + } + RoomMessage::Join { name } => { + // But wait — where does the Recipient come from? + // The enum variant can't carry it (Clone bound on Message). + // This is a fundamental limitation. + MessageResponse::NoReply + } + } + } + + async fn handle_request(&mut self, msg: RoomRequest, _: &ActorRef) -> RequestResponse { + match msg { + RoomRequest::Members => { + let names = self.members.iter().map(|(n, _)| n.clone()).collect(); + RequestResponse::Reply(RoomReply::MemberList(names)) + } + } + } +} + +// Typed wrappers hide the enum matching from callers +impl ChatRoom { + pub fn say(handle: &ActorRef, from: String, text: String) -> Result<(), ActorError> { + handle.send(RoomMessage::Say { from, text }) + } + pub async fn members(handle: &ActorRef) -> Result, ActorError> { + match handle.request(RoomRequest::Members).await? { + RoomReply::MemberList(names) => Ok(names), + _ => unreachable!(), // still exists, just hidden inside the wrapper + } + } +} + +// For #145: Handler impl on the SECOND channel (envelope-based) +// The actor loop select!s on both the enum channel and the envelope channel +impl Handler for ChatRoom { /* ... */ } +``` +
+ +### Analysis + +| Dimension | Assessment | +|-----------|-----------| +| **Readability** | Poor. Two dispatch mechanisms coexist: the old `match msg { ... }` for enum messages and `Handler` impls on the envelope channel. A reader must understand both systems and how they interact. | +| **API at a glance** | The typed wrappers (`ChatRoom::say(...)`, `ChatRoom::members(...)`) provide a clean caller API. But the implementation behind them is messy. | +| **Boilerplate** | High. Every message needs: enum variant + typed wrapper + match arm. And `unreachable!()` branches still exist inside the wrappers. Cross-boundary messages also need `Handler` impls. | +| **main.rs expressivity** | `ChatRoom::say(&room, from, text)` — associated functions, not method syntax on ActorRef. Less ergonomic than extension traits. | +| **Fundamental problem** | The old `Message` type requires `Clone`, but `Recipient` is `Arc` which doesn't implement `Clone` in all contexts. The `Join` message can't carry a Recipient through the enum channel. This forces cross-boundary messages onto the second channel, splitting the actor's logic across two systems. | + +**Key insight:** This approach tries to preserve backward compatibility, but the dual-channel architecture creates more confusion than a clean break would. The `Clone` bound on the old `Message` associated type is fundamentally incompatible with carrying type-erased handles, making the split between channels unavoidable and arbitrary. + +--- + +## Approach D: Derive Macro + +**Branch:** Not implemented. Documented in [`docs/ALTERNATIVE_APPROACHES.md`](../../blob/feat/145-protocol-trait/docs/ALTERNATIVE_APPROACHES.md). + +A proc macro `#[derive(ActorMessages)]` auto-generates per-variant message structs, `Message` impls, typed wrappers, and `Handler` delegation from an annotated enum. + +### What the chat room would look like + +
+room.rs — derive macro generates everything from the enum + +```rust +use spawned_derive::ActorMessages; + +// The macro generates: struct Say, struct Join, struct Members, +// impl Message for each, typed wrapper methods, and Handler delegation +#[derive(ActorMessages)] +#[actor(ChatRoom)] +pub enum RoomMessages { + #[send] + Say { from: String, text: String }, + + #[send] + Join { name: String, inbox: Recipient }, + + #[request(Vec)] + Members, +} + +pub struct ChatRoom { + members: Vec<(String, Recipient)>, +} + +impl Actor for ChatRoom {} + +// You still write the old-style handle_request/handle_message, +// but the macro routes per-struct Handler calls into it. +// OR: the macro generates Handler impls that call per-variant methods: +impl ChatRoom { + fn on_say(&mut self, msg: Say, ctx: &Context) { /* ... */ } + fn on_join(&mut self, msg: Join, ctx: &Context) { /* ... */ } + fn on_members(&mut self, msg: Members, ctx: &Context) -> Vec { /* ... */ } +} +``` +
+ +
+main.rs — generated wrapper methods + +```rust +let room = ChatRoom::new().start(); +// Generated methods (associated functions on ActorRef): +room.say("Alice".into(), "Hello!".into()).unwrap(); +let members = room.members().await.unwrap(); +``` +
+ +### Analysis + +| Dimension | Assessment | +|-----------|-----------| +| **Readability** | The enum definition is compact, but what the macro generates is invisible. Reading `room.rs` tells you the message *names*, but you can't see the generated Handler impls, wrapper methods, or error handling without running `cargo expand`. | +| **API at a glance** | The annotated enum is a good summary of all messages. `#[send]` vs `#[request(ReturnType)]` makes the distinction clear. | +| **Boilerplate** | Lowest of all approaches for defining messages — one enum covers everything. But debugging generated code is costly when things go wrong (compile errors point to generated code). | +| **main.rs expressivity** | Generated wrappers would provide method-call syntax. Comparable to Approach A's extension traits, but with less control over the API shape. | +| **Complexity** | A new proc macro crate (compilation cost). The macro must handle edge cases: messages carrying `Recipient`, mixed send/request variants, `Clone` bounds for the enum vs non-Clone fields. This is the most complex approach to implement correctly. | +| **Macro compatibility** | This IS the macro — it replaces both `send_messages!`/`request_messages!` and `#[actor]`. Larger blast radius means more things that can break. | + +**Key insight:** The derive macro trades visibility for conciseness. Approach A's `#[actor]` macro is lighter — it only generates `impl Handler` delegation from visibly-written handler methods. The derive macro tries to generate the handler methods too, making the actor's behavior harder to trace. + +--- + +## Approach E: AnyActorRef (fully type-erased) + +**Branch:** Not implemented. Documented in [`docs/ALTERNATIVE_APPROACHES.md`](../../blob/feat/145-protocol-trait/docs/ALTERNATIVE_APPROACHES.md). + +Replaces `Recipient` with a single fully type-erased handle `AnyActorRef = Arc` using `Box`. + +### What the chat room would look like + +
+room.rs + +```rust +pub struct ChatRoom { + members: Vec<(String, AnyActorRef)>, // no type parameter — stores anything +} + +impl Handler for ChatRoom { + async fn handle(&mut self, msg: Say, _ctx: &Context) { + for (name, inbox) in &self.members { + if *name != msg.from { + // Runtime type dispatch — if inbox can't handle Deliver, it's a silent error + let _ = inbox.send_any(Box::new(Deliver { + from: msg.from.clone(), + text: msg.text.clone(), + })); + } + } + } +} + +impl Handler for ChatRoom { + async fn handle(&mut self, msg: Join, _ctx: &Context) { + self.members.push((msg.name, msg.inbox)); // just stores AnyActorRef + } +} +``` +
+ +
+user.rs + +```rust +pub struct User { + pub name: String, + pub room: AnyActorRef, // no type safety — could be any actor +} + +impl Handler for User { + async fn handle(&mut self, msg: SayToRoom, _ctx: &Context) { + // Must Box the message and hope the room can handle it + let _ = self.room.send_any(Box::new(Say { + from: self.name.clone(), + text: msg.text, + })); + } +} +``` +
+ +
+main.rs + +```rust +let room = ChatRoom::new().start(); +let alice = User { name: "Alice".into(), room: room.any_ref() }.start(); + +// Joining — also type-erased +room.send(Join { name: "Alice".into(), inbox: alice.any_ref() }).unwrap(); + +// Requesting members — must downcast the reply +let reply: Box = room.request_any(Box::new(Members)).await?; +let members: Vec = *reply.downcast::>().expect("wrong reply type"); +``` +
+ +### Analysis + +| Dimension | Assessment | +|-----------|-----------| +| **Readability** | The actor code is cluttered with `Box::new()`, `send_any()`, and `downcast()`. The type information that was available at compile time is now lost, making the code harder to reason about. | +| **API at a glance** | `AnyActorRef` tells you nothing about what messages an actor can receive. You must read the `Handler` impls to know, and even then the caller has no compile-time enforcement. | +| **Boilerplate** | Low for cross-boundary wiring (just `AnyActorRef` everywhere). But higher for callers who must box/downcast. | +| **main.rs expressivity** | Poor. `room.request_any(Box::new(Members))` followed by `.downcast::>()` is verbose and error-prone. Compare to Approach A's `room.request(Members).await` → `Vec`. | +| **Safety** | Sending the wrong message type is a **runtime** error (or silently ignored). This defeats Rust's core value proposition. | + +**Key insight:** AnyActorRef is essentially what you get in dynamically-typed languages. It solves #145 by erasing all type information, but in doing so also erases the compile-time safety that Rust provides. Wrong message types become runtime panics instead of compile errors. + +--- + +## Approach F: PID Addressing (Erlang-style) + +**Branch:** Not implemented. Documented in [`docs/ALTERNATIVE_APPROACHES.md`](../../blob/feat/145-protocol-trait/docs/ALTERNATIVE_APPROACHES.md). + +Every actor gets a `Pid(u64)`. A global registry maps `(Pid, TypeId)` → message sender. Messages are sent by PID with explicit registration per message type. + +### What the chat room would look like + +
+room.rs + +```rust +pub struct ChatRoom { + members: Vec<(String, Pid)>, // lightweight copyable identifier +} + +impl Handler for ChatRoom { + async fn handle(&mut self, msg: Say, _ctx: &Context) { + for (name, pid) in &self.members { + if *name != msg.from { + // Typed send — but resolved at runtime via global registry + let _ = spawned::send(*pid, Deliver { + from: msg.from.clone(), + text: msg.text.clone(), + }); + } + } + } +} + +impl Handler for ChatRoom { + async fn handle(&mut self, msg: Join, _ctx: &Context) { + self.members.push((msg.name, msg.pid)); + } +} +``` +
+ +
+user.rs + +```rust +pub struct User { + pub name: String, + pub room_pid: Pid, // just a u64 +} + +impl Handler for User { + async fn handle(&mut self, msg: SayToRoom, _ctx: &Context) { + let _ = spawned::send(self.room_pid, Say { + from: self.name.clone(), + text: msg.text, + }); + } +} +``` +
+ +
+main.rs — requires explicit registration + +```rust +let room = ChatRoom::new().start(); +let alice = User { name: "Alice".into(), room_pid: room.pid() }.start(); + +// Must register each message type the actor can receive via PID +room.register::(); +room.register::(); +room.register::(); +alice.register::(); +alice.register::(); + +room.send(Join { name: "Alice".into(), pid: alice.pid() }).unwrap(); + +// Typed request — but only works if Members was registered +let members: Vec = spawned::request(room.pid(), Members).await?; +``` +
+ +### Analysis + +| Dimension | Assessment | +|-----------|-----------| +| **Readability** | Actor code is clean — `spawned::send(pid, msg)` is simple and Erlang-familiar. But the registration boilerplate in `main.rs` is noisy and easy to forget. | +| **API at a glance** | `Pid` tells you nothing about what messages an actor accepts. You know less than with `ActorRef` (which at least tells you the actor type) or `Recipient` (which tells you the message type). | +| **Boilerplate** | Per-actor registration of every message type: `room.register::()`, `room.register::()`, etc. Forgetting a registration → runtime error. | +| **main.rs expressivity** | `spawned::send(pid, msg)` is concise. But registration lines are pure ceremony with no business logic value. | +| **Safety** | Sending to a dead PID or unregistered message type → **runtime** error. The compile-time guarantee "this actor handles this message" is lost. | +| **Clustering** | Best positioned for distributed systems — `Pid` is a location-transparent identifier that naturally extends to remote nodes. | + +**Key insight:** PID addressing is the most Erlang-faithful approach, and shines for clustering/distribution. But it trades Rust's compile-time type safety for runtime resolution, which is a cultural mismatch. Erlang's runtime was designed around "let it crash" — Rust's philosophy is "don't let it compile if it's wrong." + +--- + +## Comparison Matrix + +### Functional Dimensions + +| Dimension | A: Recipient | B: Protocol Traits | C: Typed Wrappers | D: Derive Macro | E: AnyActorRef | F: PID | +|-----------|-------------|-------------------|-------------------|-----------------|---------------|--------| +| **Status** | Implemented | WIP | Design only | Design only | Design only | Design only | +| **Breaking** | Yes | Yes | No | No | Yes | Yes | +| **#144 type safety** | Full | Full | Hidden `unreachable!` | Hidden `unreachable!` | Full | Full | +| **#145 type safety** | Compile-time | Compile-time | Compile-time | Compile-time | Runtime only | Runtime only | +| **Macro support** | `#[actor]` + message macros | `#[actor]` (no bridge macro) | N/A (enum-based) | Derive macro | `#[actor]` | `#[actor]` | +| **Dual-mode (async+threads)** | Works | Works | Complex (dual channel) | Complex | Works | Works | + +### Code Quality Dimensions + +| Dimension | A: Recipient | B: Protocol Traits | C: Typed Wrappers | D: Derive Macro | E: AnyActorRef | F: PID | +|-----------|-------------|-------------------|-------------------|-----------------|---------------|--------| +| **Handler readability** | Clear: one `impl Handler` or `#[send_handler]` per message | Same as A + bridge impls | Noisy: enum `match` arms + wrapper fns | Opaque: generated from enum annotations | Same as A, but callers use `Box::new` | Same as A, but callers use global `send(pid, msg)` | +| **API at a glance** | Extension traits or scan Handler impls | Protocol traits (best) | Typed wrapper functions | Annotated enum (good summary) | Nothing — `AnyActorRef` is opaque | Nothing — `Pid` is opaque | +| **main.rs expressivity** | `alice.say("Hi")` with ext traits; `alice.send(SayToRoom{...})` without | `room.say("Alice", "Hi")` via protocol | `ChatRoom::say(&room, ...)` assoc fn | Generated methods: `room.say(...)` | `room.send_any(Box::new(...))` | `spawned::send(pid, ...)` + registration | +| **Boilerplate per message** | Struct + optional macro | Struct + trait method + bridge impl | Enum variant + wrapper + match arm | Enum variant + annotation | Struct | Struct + registration | +| **Debugging** | Standard Rust — all code visible | Standard Rust — bridge impls visible | Standard Rust | Requires `cargo expand` | Runtime errors (downcast failures) | Runtime errors (unregistered types) | +| **Testability** | Good (mock via Recipient) | Best (mock protocol trait) | Good | Good | Fair (Any-based) | Hard (global state) | + +### Strategic Dimensions + +| Dimension | A: Recipient | B: Protocol Traits | C: Typed Wrappers | D: Derive Macro | E: AnyActorRef | F: PID | +|-----------|-------------|-------------------|-------------------|-----------------|---------------|--------| +| **Framework complexity** | Medium | None (user-space) | High (dual channel) | Very high (proc macro) | High (dispatch) | Medium (registry) | +| **Maintenance burden** | Low — proven Actix pattern | Low — user-maintained | High — two dispatch systems | High — complex macro | Medium | Medium | +| **Clustering readiness** | Needs `RemoteRecipient` | Needs remote bridge impls | Hard | Hard | Possible (serialize Any) | Excellent (Pid is location-transparent) | +| **Learning curve** | Moderate (Handler pattern) | Moderate + bridge pattern | Low (old API preserved) | Low (write enum, macro does rest) | Low concept, high debugging | Low concept, high registration overhead | +| **Erlang alignment** | Actix-like | Least Erlang | Actix-like | Actix-like | Erlang-ish | Most Erlang | + +--- + +## Recommendation + +**Approach A (Handler\ + Recipient\)** is the most mature and balanced option: +- Fully implemented with 34 passing tests, multiple examples, proc macro, registry, and dual-mode support +- Compile-time type safety for both #144 and #145 +- The `#[actor]` macro + extension traits provide good expressivity without hiding too much +- Proven pattern (Actix uses the same architecture) +- Non-macro version is already clean — the macro is additive, not essential + +**Approach B (Protocol Traits)** is valuable as a **complementary** pattern: +- Can coexist with Recipient\ — use protocol traits where you want explicit contracts and testability, Recipient\ where you want less boilerplate +- No framework changes needed — it's purely a user-space convention +- Best option for high-testability boundaries, but the bridge boilerplate cost is real + +**Approaches C and D** try to preserve the old enum-based API but introduce significant complexity (dual-channel, or heavy code generation) to work around its limitations. + +**Approaches E and F** sacrifice Rust's compile-time type safety for runtime flexibility. F (PID) may become relevant later for clustering, but is premature as the default API today. + +--- + +## Branch Reference + +| Branch | Base | Description | +|--------|------|-------------| +| `main` | — | Old enum-based API (baseline) | +| [`feat/critical-api-issues`](../../tree/feat/critical-api-issues) | main | Design doc for Handler\ + Recipient\ ([`docs/API_REDESIGN.md`](../../blob/feat/critical-api-issues/docs/API_REDESIGN.md)) | +| [`feat/handler-api-v0.5`](../../tree/feat/handler-api-v0.5) | main | Handler\ + Recipient\ implementation | +| [`feat/actor-macro-registry`](../../tree/feat/actor-macro-registry) | main | Adds `#[actor]` macro + named registry on top of Handler\ | +| [`feat/145-protocol-trait`](../../tree/feat/145-protocol-trait) | main | Protocol traits approach + [`docs/ALTERNATIVE_APPROACHES.md`](../../blob/feat/145-protocol-trait/docs/ALTERNATIVE_APPROACHES.md) | +| [`docs/add-project-roadmap`](../../tree/docs/add-project-roadmap) | main | Framework comparison with Actix and Ractor | + +--- + +## Detailed Design Documents + +- **[`docs/API_REDESIGN.md`](../../blob/feat/critical-api-issues/docs/API_REDESIGN.md)** (on `feat/critical-api-issues`) — Full design rationale for Handler\, Receiver\, Envelope pattern, RPITIT decision, and planned supervision traits. +- **[`docs/ALTERNATIVE_APPROACHES.md`](../../blob/feat/145-protocol-trait/docs/ALTERNATIVE_APPROACHES.md)** (on `feat/145-protocol-trait`) — Original comparison of all 5 alternative branches with execution order plan. From 36d03b4ae043be5fcc51665a5531a6e1eec15906 Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Fri, 13 Feb 2026 18:35:42 -0300 Subject: [PATCH 05/20] docs: add registry analysis, actor_api! macro, and macro improvement potential --- docs/API_ALTERNATIVES_SUMMARY.md | 223 +++++++++++++++++++++++++------ 1 file changed, 181 insertions(+), 42 deletions(-) diff --git a/docs/API_ALTERNATIVES_SUMMARY.md b/docs/API_ALTERNATIVES_SUMMARY.md index 9c109eb..af39a45 100644 --- a/docs/API_ALTERNATIVES_SUMMARY.md +++ b/docs/API_ALTERNATIVES_SUMMARY.md @@ -13,6 +13,8 @@ This document summarizes the different approaches explored for solving two criti - [Approach D: Derive Macro](#approach-d-derive-macro) - [Approach E: AnyActorRef](#approach-e-anyactorref-fully-type-erased) - [Approach F: PID Addressing](#approach-f-pid-addressing-erlang-style) +- [Registry & Service Discovery](#registry--service-discovery) +- [Macro Improvement Potential](#macro-improvement-potential) - [Comparison Matrix](#comparison-matrix) - [Recommendation](#recommendation) - [Branch Reference](#branch-reference) @@ -219,18 +221,20 @@ alice.send_request(SayToRoom { text: "Hello everyone!".into() }).await?; ``` -### With `#[actor]` macro + extension traits +### With `#[actor]` macro + `actor_api!`
-room.rs — macro eliminates Handler boilerplate +room.rs — macros eliminate both Handler and extension trait boilerplate ```rust -use spawned_concurrency::error::ActorError; +use spawned_concurrency::actor_api; use spawned_concurrency::send_messages; use spawned_concurrency::request_messages; use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler, Recipient}; use spawned_macros::actor; +// -- Messages -- + send_messages! { Say { from: String, text: String }; Deliver { from: String, text: String }; @@ -241,26 +245,30 @@ request_messages! { Members -> Vec } -pub struct ChatRoom { - members: Vec<(String, Recipient)>, -} +// -- API -- -pub trait ChatRoomApi { - fn say(&self, from: String, text: String) -> Result<(), ActorError>; - fn add_member(&self, name: String, inbox: Recipient) -> Result<(), ActorError>; - async fn members(&self) -> Result, ActorError>; +actor_api! { + pub ChatRoomApi for ActorRef { + send fn say(from: String, text: String) => Say; + send fn add_member(name: String, inbox: Recipient) => Join; + request async fn members() -> Vec => Members; + } } -impl ChatRoomApi for ActorRef { - fn say(&self, from: String, text: String) -> Result<(), ActorError> { self.send(Say { from, text }) } - fn add_member(&self, name: String, inbox: Recipient) -> Result<(), ActorError> { self.send(Join { name, inbox }) } - async fn members(&self) -> Result, ActorError> { self.request(Members).await } +// -- Actor -- + +pub struct ChatRoom { + members: Vec<(String, Recipient)>, } impl Actor for ChatRoom {} #[actor] impl ChatRoom { + pub fn new() -> Self { + Self { members: Vec::new() } + } + #[send_handler] async fn handle_say(&mut self, msg: Say, _ctx: &Context) { for (name, inbox) in &self.members { @@ -287,36 +295,43 @@ impl ChatRoom { user.rs — macro version ```rust -use spawned_concurrency::error::ActorError; +use spawned_concurrency::actor_api; use spawned_concurrency::send_messages; use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; use spawned_macros::actor; use crate::room::{ChatRoom, ChatRoomApi, Deliver}; +// -- Messages -- + send_messages! { SayToRoom { text: String }; JoinRoom { room: ActorRef } } -pub struct User { - pub name: String, - room: Option>, -} +// -- API -- -pub trait UserApi { - fn say(&self, text: String) -> Result<(), ActorError>; - fn join_room(&self, room: &ActorRef) -> Result<(), ActorError>; +actor_api! { + pub UserApi for ActorRef { + send fn say(text: String) => SayToRoom; + send fn join_room(room: ActorRef) => JoinRoom; + } } -impl UserApi for ActorRef { - fn say(&self, text: String) -> Result<(), ActorError> { self.send(SayToRoom { text }) } - fn join_room(&self, room: &ActorRef) -> Result<(), ActorError> { self.send(JoinRoom { room: room.clone() }) } +// -- Actor -- + +pub struct User { + pub name: String, + room: Option>, } impl Actor for User {} #[actor] impl User { + pub fn new(name: String) -> Self { + Self { name, room: None } + } + #[send_handler] async fn handle_say_to_room(&mut self, msg: SayToRoom, _ctx: &Context) { if let Some(ref room) = self.room { @@ -346,8 +361,8 @@ let room = ChatRoom::new().start(); let alice = User::new("Alice".into()).start(); let bob = User::new("Bob".into()).start(); -alice.join_room(&room).unwrap(); -bob.join_room(&room).unwrap(); +alice.join_room(room.clone()).unwrap(); +bob.join_room(room.clone()).unwrap(); let members = room.members().await.unwrap(); @@ -358,16 +373,16 @@ bob.say("Hi Alice!".into()).unwrap(); ### Analysis -| Dimension | Non-macro | With `#[actor]` macro | -|-----------|-----------|----------------------| -| **Readability** | Each `impl Handler` block is self-contained. You see the message type and return type in the trait bound. But many small impl blocks can feel scattered. | `#[send_handler]`/`#[request_handler]` attributes inside a single `#[actor] impl` block group all handlers together. Reads top-to-bottom like a class. | -| **API at a glance** | Must scan all `impl Handler` blocks to know what messages an actor handles. | Same — the `#[actor]` block groups them, but the extension trait (`ChatRoomApi`) is the real "at-a-glance" API surface. | -| **Boilerplate** | One `impl Handler` block per message × per actor. Message structs need manual `impl Message`. | `send_messages!`/`request_messages!` macros eliminate `Message` impls. `#[actor]` eliminates `Handler` impls. Extension traits add some code but improve caller ergonomics. | -| **main.rs expressivity** | Raw message structs: `room.send_request(Join { ... })` — explicit but verbose. | Extension traits: `alice.join_room(&room)` — reads like natural API calls. | -| **Circular dep solution** | `Recipient` — room stores `Recipient`, user stores `Recipient`. Neither knows the other's concrete type. | Same mechanism. The macro doesn't change how type erasure works. | -| **Discoverability** | Standard Rust patterns. Any Rust developer can read `impl Handler`. | `#[actor]` is custom — new developers need to learn what the attribute does, but the pattern is common (Actix uses the same). | +| Dimension | Non-macro | With `#[actor]` macro + `actor_api!` | +|-----------|-----------|--------------------------------------| +| **Readability** | Each `impl Handler` block is self-contained. You see the message type and return type in the trait bound. But many small impl blocks can feel scattered. | `#[send_handler]`/`#[request_handler]` attributes inside a single `#[actor] impl` block group all handlers together. `actor_api!` declares the caller-facing API in a compact block. Files read top-to-bottom: Messages → API → Actor. | +| **API at a glance** | Must scan all `impl Handler` blocks to know what messages an actor handles. | The `actor_api!` block is the "at-a-glance" API surface — each line declares a method, its params, and the underlying message. | +| **Boilerplate** | One `impl Handler` block per message × per actor. Message structs need manual `impl Message`. | `send_messages!`/`request_messages!` macros eliminate `Message` impls. `#[actor]` eliminates `Handler` impls. `actor_api!` reduces the extension trait + impl (~15 lines) to ~5 lines. | +| **main.rs expressivity** | Raw message structs: `room.send_request(Join { ... })` — explicit but verbose. | Extension traits: `alice.join_room(room.clone())` — reads like natural API calls. | +| **Circular dep solution** | `Recipient` — room stores `Recipient`, user stores `Recipient`. Neither knows the other's concrete type. | Same mechanism. The macros don't change how type erasure works. | +| **Discoverability** | Standard Rust patterns. Any Rust developer can read `impl Handler`. | `#[actor]` and `actor_api!` are custom — new developers need to learn what they do, but the patterns are common (Actix uses the same approach). | -**Key insight:** The non-macro version is already concise for handler code. The macro's main value is eliminating the `impl Handler for X { fn handle(...) { self.method_name(msg, ctx) } }` delegation wrapper — a small but real reduction per handler. The extension traits (optional, orthogonal to the macro) are what transform `main.rs` from "send raw structs" to "call named methods." +**Key insight:** The non-macro version is already concise for handler code. The `#[actor]` macro eliminates the `impl Handler` delegation wrapper per handler. The `actor_api!` macro eliminates the extension trait boilerplate (trait definition + impl block) that provides ergonomic method-call syntax on `ActorRef`. Together, they reduce an actor definition to three declarative blocks: messages, API, and handlers. --- @@ -886,6 +901,126 @@ let members: Vec = spawned::request(room.pid(), Members).await?; --- +## Registry & Service Discovery + +The current registry is a global `Any`-based name store (Approach A): + +```rust +// Register: store a Recipient by name +registry::register("service_registry", svc.recipient::()).unwrap(); + +// Discover: retrieve without knowing the concrete actor type +let recipient: Recipient = registry::whereis("service_registry").unwrap(); + +// Use: typed request through the recipient +let addr = request(&*recipient, Lookup { name: "web".into() }, timeout).await?; +``` + +The registry API (`register`, `whereis`, `unregister`, `registered`) stays the same across approaches — it's just `HashMap>` with `RwLock`. What changes is **what you store and what you get back**. + +### How it differs per approach + +| Approach | Stored value | Retrieved as | Type safety | Discovery granularity | +|----------|-------------|-------------|-------------|----------------------| +| **Baseline** | `ActorRef` | `ActorRef` | Compile-time, but requires knowing actor type | Per actor — defeats the point of discovery | +| **A: Recipient** | `Recipient` | `Recipient` | Compile-time per message type | Per message type — fine-grained | +| **B: Protocol Traits** | `Arc` | `Arc` | Compile-time per protocol | Per protocol — coarser-grained | +| **C: Typed Wrappers** | `ActorRef` or `Recipient` | Mixed | Depends on channel | Unclear — dual-channel split | +| **D: Derive Macro** | `Recipient` | `Recipient` | Same as A | Same as A | +| **E: AnyActorRef** | `AnyActorRef` | `AnyActorRef` | None — runtime only | Per actor, but no type info | +| **F: PID** | `Pid` | `Pid` | None — runtime only | Per actor (Erlang-style `whereis`) | + +**Key differences:** + +- **A and D** register per message type: `registry::register("room_lookup", room.recipient::())`. A consumer discovers a `Recipient` — it can only send `Lookup` messages, nothing else. If the room handles 5 message types, you can register it under 5 names (or one name per message type you want to expose). This is the most granular. + +- **B** registers per protocol: `registry::register("room", Arc::new(room.clone()) as Arc)`. A consumer discovers an `Arc` — it can call any method on the protocol (`say`, `add_member`). This is coarser but more natural: one registration covers all the methods in the protocol. + +- **E** is trivially simple but useless: `registry::register("room", room.any_ref())`. You get back an `AnyActorRef` that accepts `Box`. No compile-time knowledge of what messages the actor handles. + +- **F** is the most natural fit for a registry. The registry maps `name → Pid`, and PID-based dispatch handles the rest. This mirrors Erlang exactly: `register(room, Pid)`, `whereis(room) → Pid`. The registry is simple; the complexity moves to the PID dispatch table. But the same runtime safety concern applies — sending to a Pid that doesn't handle the message type fails at runtime. + +--- + +## Macro Improvement Potential + +Approach A's `actor_api!` macro eliminates extension trait boilerplate by generating a trait + impl from a compact declaration. Could similar macros reduce boilerplate in the other approaches? + +### Approach B: Protocol Traits — YES, significant potential + +The bridge impls are structurally identical to what `actor_api!` already generates. Each bridge method just wraps `self.send(Msg { fields })`: + +```rust +// Current bridge boilerplate (~10 lines per actor) +impl ChatBroadcaster for ActorRef { + fn say(&self, from: String, text: String) -> Result<(), ActorError> { + self.send(Say { from, text }) + } + fn add_member(&self, name: String, inbox: Arc) -> Result<(), ActorError> { + self.send(Join { name, inbox }) + } +} +``` + +A variant of `actor_api!` could generate bridge impls for an existing trait: + +```rust +// Potential: impl-only mode for existing protocol traits +actor_api! { + impl ChatBroadcaster for ActorRef { + send fn say(from: String, text: String) => Say; + send fn add_member(name: String, inbox: Arc) => Join; + } +} +``` + +This would use the same syntax but `impl Trait for Type` (no `pub`, no new trait) signals that we're implementing an existing trait. The protocol trait itself remains user-defined — it IS the contract, so it should stay hand-written. + +**Impact:** Bridge boilerplate per actor drops from ~10 lines to ~4 lines. The protocol trait definition stays manual (by design). Combined with `#[actor]`, the total code for a protocol-based actor would be competitive with Approach A. + +### Approach C: Typed Wrappers — NO + +The fundamental problem is the dual-channel architecture, not boilerplate. The `Clone` bound incompatibility between enum messages and `Recipient` creates a structural split that macros can't paper over. Typed wrappers still hide `unreachable!()` branches internally. + +### Approach D: Derive Macro — N/A + +This approach IS a macro. The `#[derive(ActorMessages)]` would generate message structs, `Message` impls, API wrappers, and `Handler` delegation — subsuming what `actor_api!`, `send_messages!`, and `#[actor]` do separately. Adding `actor_api!` on top would be redundant. + +### Approach E: AnyActorRef — NO + +You could wrap `send_any(Box::new(...))` in typed helper methods, but this provides false safety — the runtime dispatch can still fail. The whole point of AnyActorRef is erasing types; adding typed wrappers on top contradicts that. + +### Approach F: PID — PARTIAL + +The registration boilerplate could be automated: + +```rust +// Current: manual registration per message type +room.register::(); +room.register::(); +room.register::(); + +// Potential: derive-style auto-registration +#[actor(register(Say, Join, Members))] +impl ChatRoom { ... } +``` + +And `spawned::send(pid, Msg { ... })` could get ergonomic wrappers similar to `actor_api!`. But since `Pid` carries no type information, these wrappers can only provide ergonomics, not safety — a wrong Pid still causes a runtime error. + +### Summary + +| Approach | Macro potential | What it would eliminate | Worth implementing? | +|----------|----------------|----------------------|---------------------| +| **B: Protocol Traits** | High | Bridge impl boilerplate | Yes — `actor_api!` impl-only mode | +| **C: Typed Wrappers** | None | N/A — structural problem | No | +| **D: Derive Macro** | N/A | Already a macro | N/A | +| **E: AnyActorRef** | None | Would add false safety | No | +| **F: PID** | Low-Medium | Registration ceremony | Maybe — ergonomics only | + +**Takeaway:** Approach B is the only unimplemented approach that would meaningfully benefit from `actor_api!`-style macros. And the required change is small — adding an impl-only mode to the existing macro. This would make Approach B more competitive with Approach A on boilerplate, while retaining its testability advantage. + +--- + ## Comparison Matrix ### Functional Dimensions @@ -896,17 +1031,19 @@ let members: Vec = spawned::request(room.pid(), Members).await?; | **Breaking** | Yes | Yes | No | No | Yes | Yes | | **#144 type safety** | Full | Full | Hidden `unreachable!` | Hidden `unreachable!` | Full | Full | | **#145 type safety** | Compile-time | Compile-time | Compile-time | Compile-time | Runtime only | Runtime only | -| **Macro support** | `#[actor]` + message macros | `#[actor]` (no bridge macro) | N/A (enum-based) | Derive macro | `#[actor]` | `#[actor]` | +| **Macro support** | `#[actor]` + `actor_api!` + message macros | `#[actor]` (no bridge macro) | N/A (enum-based) | Derive macro | `#[actor]` | `#[actor]` | | **Dual-mode (async+threads)** | Works | Works | Complex (dual channel) | Complex | Works | Works | +| **Registry stores** | `Recipient` | `Arc` | Mixed | `Recipient` | `AnyActorRef` | `Pid` | +| **Registry type safety** | Compile-time | Compile-time | Depends | Compile-time | Runtime | Runtime | ### Code Quality Dimensions | Dimension | A: Recipient | B: Protocol Traits | C: Typed Wrappers | D: Derive Macro | E: AnyActorRef | F: PID | |-----------|-------------|-------------------|-------------------|-----------------|---------------|--------| | **Handler readability** | Clear: one `impl Handler` or `#[send_handler]` per message | Same as A + bridge impls | Noisy: enum `match` arms + wrapper fns | Opaque: generated from enum annotations | Same as A, but callers use `Box::new` | Same as A, but callers use global `send(pid, msg)` | -| **API at a glance** | Extension traits or scan Handler impls | Protocol traits (best) | Typed wrapper functions | Annotated enum (good summary) | Nothing — `AnyActorRef` is opaque | Nothing — `Pid` is opaque | -| **main.rs expressivity** | `alice.say("Hi")` with ext traits; `alice.send(SayToRoom{...})` without | `room.say("Alice", "Hi")` via protocol | `ChatRoom::say(&room, ...)` assoc fn | Generated methods: `room.say(...)` | `room.send_any(Box::new(...))` | `spawned::send(pid, ...)` + registration | -| **Boilerplate per message** | Struct + optional macro | Struct + trait method + bridge impl | Enum variant + wrapper + match arm | Enum variant + annotation | Struct | Struct + registration | +| **API at a glance** | `actor_api!` block or scan Handler impls | Protocol traits (best) | Typed wrapper functions | Annotated enum (good summary) | Nothing — `AnyActorRef` is opaque | Nothing — `Pid` is opaque | +| **main.rs expressivity** | `alice.say("Hi")` with `actor_api!`; `alice.send(SayToRoom{...})` without | `room.say("Alice", "Hi")` via protocol | `ChatRoom::say(&room, ...)` assoc fn | Generated methods: `room.say(...)` | `room.send_any(Box::new(...))` | `spawned::send(pid, ...)` + registration | +| **Boilerplate per message** | Struct + `actor_api!` line | Struct + trait method + bridge impl | Enum variant + wrapper + match arm | Enum variant + annotation | Struct | Struct + registration | | **Debugging** | Standard Rust — all code visible | Standard Rust — bridge impls visible | Standard Rust | Requires `cargo expand` | Runtime errors (downcast failures) | Runtime errors (unregistered types) | | **Testability** | Good (mock via Recipient) | Best (mock protocol trait) | Good | Good | Fair (Any-based) | Hard (global state) | @@ -919,6 +1056,7 @@ let members: Vec = spawned::request(room.pid(), Members).await?; | **Clustering readiness** | Needs `RemoteRecipient` | Needs remote bridge impls | Hard | Hard | Possible (serialize Any) | Excellent (Pid is location-transparent) | | **Learning curve** | Moderate (Handler pattern) | Moderate + bridge pattern | Low (old API preserved) | Low (write enum, macro does rest) | Low concept, high debugging | Low concept, high registration overhead | | **Erlang alignment** | Actix-like | Least Erlang | Actix-like | Actix-like | Erlang-ish | Most Erlang | +| **Macro improvement potential** | Already done (`actor_api!`) | High (bridge impls) | None (structural) | N/A (is a macro) | None (false safety) | Low (ergonomics only) | --- @@ -927,9 +1065,10 @@ let members: Vec = spawned::request(room.pid(), Members).await?; **Approach A (Handler\ + Recipient\)** is the most mature and balanced option: - Fully implemented with 34 passing tests, multiple examples, proc macro, registry, and dual-mode support - Compile-time type safety for both #144 and #145 -- The `#[actor]` macro + extension traits provide good expressivity without hiding too much +- The `#[actor]` macro + `actor_api!` macro provide good expressivity without hiding too much +- `actor_api!` reduces extension trait boilerplate from ~15 lines to ~5 lines per actor - Proven pattern (Actix uses the same architecture) -- Non-macro version is already clean — the macro is additive, not essential +- Non-macro version is already clean — the macros are additive, not essential **Approach B (Protocol Traits)** is valuable as a **complementary** pattern: - Can coexist with Recipient\ — use protocol traits where you want explicit contracts and testability, Recipient\ where you want less boilerplate From 7844f54e18226f83f02e644235bae04d47ddc367 Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Fri, 13 Feb 2026 18:50:42 -0300 Subject: [PATCH 06/20] docs: use permanent commit links instead of branch references --- docs/API_ALTERNATIVES_SUMMARY.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/API_ALTERNATIVES_SUMMARY.md b/docs/API_ALTERNATIVES_SUMMARY.md index af39a45..b23d444 100644 --- a/docs/API_ALTERNATIVES_SUMMARY.md +++ b/docs/API_ALTERNATIVES_SUMMARY.md @@ -117,7 +117,7 @@ match room.request(RoomRequest::Members).await? { ## Approach A: Handler\ + Recipient\ (Actix-style) -**Branches:** [`feat/handler-api-v0.5`](../../tree/feat/handler-api-v0.5) (implementation), [`feat/critical-api-issues`](../../tree/feat/critical-api-issues) (design doc), [`feat/actor-macro-registry`](../../tree/feat/actor-macro-registry) (adds macro + registry) +**Branches:** [`feat/handler-api-v0.5`](../../tree/34bf9a7) (implementation), [`feat/critical-api-issues`](../../tree/1ef33bf) (design doc), [`feat/actor-macro-registry`](../../tree/de651ad) (adds macro + registry) **Status:** Fully implemented and working. 34 tests passing. Multiple examples ported. @@ -388,7 +388,7 @@ bob.say("Hi Alice!".into()).unwrap(); ## Approach B: Protocol Traits (user-defined contracts) -**Branch:** [`feat/145-protocol-trait`](../../tree/feat/145-protocol-trait) (WIP, committed) +**Branch:** [`feat/145-protocol-trait`](../../tree/b0e5afb) (WIP, committed) **Status:** WIP. Full implementation + migrated examples committed. @@ -551,7 +551,7 @@ alice.send_request(SayToRoom { text: "Hello everyone!".into() }).await?; ## Approach C: Typed Wrappers (non-breaking) -**Branch:** Not implemented. Documented in [`docs/ALTERNATIVE_APPROACHES.md`](../../blob/feat/145-protocol-trait/docs/ALTERNATIVE_APPROACHES.md). +**Branch:** Not implemented. Documented in [`docs/ALTERNATIVE_APPROACHES.md`](../../blob/b0e5afb/docs/ALTERNATIVE_APPROACHES.md). Keeps the old enum-based `Actor` trait unchanged. Adds typed convenience methods that hide the enum matching. For #145, adds a second envelope-based channel to `ActorRef` alongside the existing enum channel. @@ -648,7 +648,7 @@ impl Handler for ChatRoom { /* ... */ } ## Approach D: Derive Macro -**Branch:** Not implemented. Documented in [`docs/ALTERNATIVE_APPROACHES.md`](../../blob/feat/145-protocol-trait/docs/ALTERNATIVE_APPROACHES.md). +**Branch:** Not implemented. Documented in [`docs/ALTERNATIVE_APPROACHES.md`](../../blob/b0e5afb/docs/ALTERNATIVE_APPROACHES.md). A proc macro `#[derive(ActorMessages)]` auto-generates per-variant message structs, `Message` impls, typed wrappers, and `Handler` delegation from an annotated enum. @@ -720,7 +720,7 @@ let members = room.members().await.unwrap(); ## Approach E: AnyActorRef (fully type-erased) -**Branch:** Not implemented. Documented in [`docs/ALTERNATIVE_APPROACHES.md`](../../blob/feat/145-protocol-trait/docs/ALTERNATIVE_APPROACHES.md). +**Branch:** Not implemented. Documented in [`docs/ALTERNATIVE_APPROACHES.md`](../../blob/b0e5afb/docs/ALTERNATIVE_APPROACHES.md). Replaces `Recipient` with a single fully type-erased handle `AnyActorRef = Arc` using `Box`. @@ -809,7 +809,7 @@ let members: Vec = *reply.downcast::>().expect("wrong reply ## Approach F: PID Addressing (Erlang-style) -**Branch:** Not implemented. Documented in [`docs/ALTERNATIVE_APPROACHES.md`](../../blob/feat/145-protocol-trait/docs/ALTERNATIVE_APPROACHES.md). +**Branch:** Not implemented. Documented in [`docs/ALTERNATIVE_APPROACHES.md`](../../blob/b0e5afb/docs/ALTERNATIVE_APPROACHES.md). Every actor gets a `Pid(u64)`. A global registry maps `(Pid, TypeId)` → message sender. Messages are sent by PID with explicit registration per message type. @@ -1086,15 +1086,15 @@ And `spawned::send(pid, Msg { ... })` could get ergonomic wrappers similar to `a | Branch | Base | Description | |--------|------|-------------| | `main` | — | Old enum-based API (baseline) | -| [`feat/critical-api-issues`](../../tree/feat/critical-api-issues) | main | Design doc for Handler\ + Recipient\ ([`docs/API_REDESIGN.md`](../../blob/feat/critical-api-issues/docs/API_REDESIGN.md)) | -| [`feat/handler-api-v0.5`](../../tree/feat/handler-api-v0.5) | main | Handler\ + Recipient\ implementation | -| [`feat/actor-macro-registry`](../../tree/feat/actor-macro-registry) | main | Adds `#[actor]` macro + named registry on top of Handler\ | -| [`feat/145-protocol-trait`](../../tree/feat/145-protocol-trait) | main | Protocol traits approach + [`docs/ALTERNATIVE_APPROACHES.md`](../../blob/feat/145-protocol-trait/docs/ALTERNATIVE_APPROACHES.md) | -| [`docs/add-project-roadmap`](../../tree/docs/add-project-roadmap) | main | Framework comparison with Actix and Ractor | +| [`feat/critical-api-issues`](../../tree/1ef33bf) | main | Design doc for Handler\ + Recipient\ ([`docs/API_REDESIGN.md`](../../blob/1ef33bf/docs/API_REDESIGN.md)) | +| [`feat/handler-api-v0.5`](../../tree/34bf9a7) | main | Handler\ + Recipient\ implementation | +| [`feat/actor-macro-registry`](../../tree/de651ad) | main | Adds `#[actor]` macro + named registry on top of Handler\ | +| [`feat/145-protocol-trait`](../../tree/b0e5afb) | main | Protocol traits approach + [`docs/ALTERNATIVE_APPROACHES.md`](../../blob/b0e5afb/docs/ALTERNATIVE_APPROACHES.md) | +| [`docs/add-project-roadmap`](../../tree/426c1a9) | main | Framework comparison with Actix and Ractor | --- ## Detailed Design Documents -- **[`docs/API_REDESIGN.md`](../../blob/feat/critical-api-issues/docs/API_REDESIGN.md)** (on `feat/critical-api-issues`) — Full design rationale for Handler\, Receiver\, Envelope pattern, RPITIT decision, and planned supervision traits. -- **[`docs/ALTERNATIVE_APPROACHES.md`](../../blob/feat/145-protocol-trait/docs/ALTERNATIVE_APPROACHES.md)** (on `feat/145-protocol-trait`) — Original comparison of all 5 alternative branches with execution order plan. +- **[`docs/API_REDESIGN.md`](../../blob/1ef33bf/docs/API_REDESIGN.md)** (on `feat/critical-api-issues`) — Full design rationale for Handler\, Receiver\, Envelope pattern, RPITIT decision, and planned supervision traits. +- **[`docs/ALTERNATIVE_APPROACHES.md`](../../blob/b0e5afb/docs/ALTERNATIVE_APPROACHES.md)** (on `feat/145-protocol-trait`) — Original comparison of all 5 alternative branches with execution order plan. From 0ae9989b0486471daaf60c181896be5980407c38 Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Fri, 13 Feb 2026 18:54:39 -0300 Subject: [PATCH 07/20] docs: use full commit SHAs for permanent links --- docs/API_ALTERNATIVES_SUMMARY.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/API_ALTERNATIVES_SUMMARY.md b/docs/API_ALTERNATIVES_SUMMARY.md index b23d444..a91eafd 100644 --- a/docs/API_ALTERNATIVES_SUMMARY.md +++ b/docs/API_ALTERNATIVES_SUMMARY.md @@ -117,7 +117,7 @@ match room.request(RoomRequest::Members).await? { ## Approach A: Handler\ + Recipient\ (Actix-style) -**Branches:** [`feat/handler-api-v0.5`](../../tree/34bf9a7) (implementation), [`feat/critical-api-issues`](../../tree/1ef33bf) (design doc), [`feat/actor-macro-registry`](../../tree/de651ad) (adds macro + registry) +**Branches:** [`feat/handler-api-v0.5`](../../tree/34bf9a759cda72e5311efda8f1fc8a5ae515129a) (implementation), [`feat/critical-api-issues`](../../tree/1ef33bf0c463543dca379463c554ccc5914c86ff) (design doc), [`feat/actor-macro-registry`](../../tree/de651ad21e2dd39babf534cb74174ae0fe3b399c) (adds macro + registry) **Status:** Fully implemented and working. 34 tests passing. Multiple examples ported. @@ -388,7 +388,7 @@ bob.say("Hi Alice!".into()).unwrap(); ## Approach B: Protocol Traits (user-defined contracts) -**Branch:** [`feat/145-protocol-trait`](../../tree/b0e5afb) (WIP, committed) +**Branch:** [`feat/145-protocol-trait`](../../tree/b0e5afb2c69e1f5b6ab8ee82b59582348877c819) (WIP, committed) **Status:** WIP. Full implementation + migrated examples committed. @@ -551,7 +551,7 @@ alice.send_request(SayToRoom { text: "Hello everyone!".into() }).await?; ## Approach C: Typed Wrappers (non-breaking) -**Branch:** Not implemented. Documented in [`docs/ALTERNATIVE_APPROACHES.md`](../../blob/b0e5afb/docs/ALTERNATIVE_APPROACHES.md). +**Branch:** Not implemented. Documented in [`docs/ALTERNATIVE_APPROACHES.md`](../../blob/b0e5afb2c69e1f5b6ab8ee82b59582348877c819/docs/ALTERNATIVE_APPROACHES.md). Keeps the old enum-based `Actor` trait unchanged. Adds typed convenience methods that hide the enum matching. For #145, adds a second envelope-based channel to `ActorRef` alongside the existing enum channel. @@ -648,7 +648,7 @@ impl Handler for ChatRoom { /* ... */ } ## Approach D: Derive Macro -**Branch:** Not implemented. Documented in [`docs/ALTERNATIVE_APPROACHES.md`](../../blob/b0e5afb/docs/ALTERNATIVE_APPROACHES.md). +**Branch:** Not implemented. Documented in [`docs/ALTERNATIVE_APPROACHES.md`](../../blob/b0e5afb2c69e1f5b6ab8ee82b59582348877c819/docs/ALTERNATIVE_APPROACHES.md). A proc macro `#[derive(ActorMessages)]` auto-generates per-variant message structs, `Message` impls, typed wrappers, and `Handler` delegation from an annotated enum. @@ -720,7 +720,7 @@ let members = room.members().await.unwrap(); ## Approach E: AnyActorRef (fully type-erased) -**Branch:** Not implemented. Documented in [`docs/ALTERNATIVE_APPROACHES.md`](../../blob/b0e5afb/docs/ALTERNATIVE_APPROACHES.md). +**Branch:** Not implemented. Documented in [`docs/ALTERNATIVE_APPROACHES.md`](../../blob/b0e5afb2c69e1f5b6ab8ee82b59582348877c819/docs/ALTERNATIVE_APPROACHES.md). Replaces `Recipient` with a single fully type-erased handle `AnyActorRef = Arc` using `Box`. @@ -809,7 +809,7 @@ let members: Vec = *reply.downcast::>().expect("wrong reply ## Approach F: PID Addressing (Erlang-style) -**Branch:** Not implemented. Documented in [`docs/ALTERNATIVE_APPROACHES.md`](../../blob/b0e5afb/docs/ALTERNATIVE_APPROACHES.md). +**Branch:** Not implemented. Documented in [`docs/ALTERNATIVE_APPROACHES.md`](../../blob/b0e5afb2c69e1f5b6ab8ee82b59582348877c819/docs/ALTERNATIVE_APPROACHES.md). Every actor gets a `Pid(u64)`. A global registry maps `(Pid, TypeId)` → message sender. Messages are sent by PID with explicit registration per message type. @@ -1086,15 +1086,15 @@ And `spawned::send(pid, Msg { ... })` could get ergonomic wrappers similar to `a | Branch | Base | Description | |--------|------|-------------| | `main` | — | Old enum-based API (baseline) | -| [`feat/critical-api-issues`](../../tree/1ef33bf) | main | Design doc for Handler\ + Recipient\ ([`docs/API_REDESIGN.md`](../../blob/1ef33bf/docs/API_REDESIGN.md)) | -| [`feat/handler-api-v0.5`](../../tree/34bf9a7) | main | Handler\ + Recipient\ implementation | -| [`feat/actor-macro-registry`](../../tree/de651ad) | main | Adds `#[actor]` macro + named registry on top of Handler\ | -| [`feat/145-protocol-trait`](../../tree/b0e5afb) | main | Protocol traits approach + [`docs/ALTERNATIVE_APPROACHES.md`](../../blob/b0e5afb/docs/ALTERNATIVE_APPROACHES.md) | -| [`docs/add-project-roadmap`](../../tree/426c1a9) | main | Framework comparison with Actix and Ractor | +| [`feat/critical-api-issues`](../../tree/1ef33bf0c463543dca379463c554ccc5914c86ff) | main | Design doc for Handler\ + Recipient\ ([`docs/API_REDESIGN.md`](../../blob/1ef33bf0c463543dca379463c554ccc5914c86ff/docs/API_REDESIGN.md)) | +| [`feat/handler-api-v0.5`](../../tree/34bf9a759cda72e5311efda8f1fc8a5ae515129a) | main | Handler\ + Recipient\ implementation | +| [`feat/actor-macro-registry`](../../tree/de651ad21e2dd39babf534cb74174ae0fe3b399c) | main | Adds `#[actor]` macro + named registry on top of Handler\ | +| [`feat/145-protocol-trait`](../../tree/b0e5afb2c69e1f5b6ab8ee82b59582348877c819) | main | Protocol traits approach + [`docs/ALTERNATIVE_APPROACHES.md`](../../blob/b0e5afb2c69e1f5b6ab8ee82b59582348877c819/docs/ALTERNATIVE_APPROACHES.md) | +| [`docs/add-project-roadmap`](../../tree/426c1a9952b3ad440686c318882d570f2032666f) | main | Framework comparison with Actix and Ractor | --- ## Detailed Design Documents -- **[`docs/API_REDESIGN.md`](../../blob/1ef33bf/docs/API_REDESIGN.md)** (on `feat/critical-api-issues`) — Full design rationale for Handler\, Receiver\, Envelope pattern, RPITIT decision, and planned supervision traits. -- **[`docs/ALTERNATIVE_APPROACHES.md`](../../blob/b0e5afb/docs/ALTERNATIVE_APPROACHES.md)** (on `feat/145-protocol-trait`) — Original comparison of all 5 alternative branches with execution order plan. +- **[`docs/API_REDESIGN.md`](../../blob/1ef33bf0c463543dca379463c554ccc5914c86ff/docs/API_REDESIGN.md)** (on `feat/critical-api-issues`) — Full design rationale for Handler\, Receiver\, Envelope pattern, RPITIT decision, and planned supervision traits. +- **[`docs/ALTERNATIVE_APPROACHES.md`](../../blob/b0e5afb2c69e1f5b6ab8ee82b59582348877c819/docs/ALTERNATIVE_APPROACHES.md)** (on `feat/145-protocol-trait`) — Original comparison of all 5 alternative branches with execution order plan. From 51f1961786e68bf48cc089e827376fd1510af77b Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Fri, 13 Feb 2026 18:56:05 -0300 Subject: [PATCH 08/20] docs: use absolute GitHub URLs for cross-branch links --- docs/API_ALTERNATIVES_SUMMARY.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/API_ALTERNATIVES_SUMMARY.md b/docs/API_ALTERNATIVES_SUMMARY.md index a91eafd..5e01c9a 100644 --- a/docs/API_ALTERNATIVES_SUMMARY.md +++ b/docs/API_ALTERNATIVES_SUMMARY.md @@ -117,7 +117,7 @@ match room.request(RoomRequest::Members).await? { ## Approach A: Handler\ + Recipient\ (Actix-style) -**Branches:** [`feat/handler-api-v0.5`](../../tree/34bf9a759cda72e5311efda8f1fc8a5ae515129a) (implementation), [`feat/critical-api-issues`](../../tree/1ef33bf0c463543dca379463c554ccc5914c86ff) (design doc), [`feat/actor-macro-registry`](../../tree/de651ad21e2dd39babf534cb74174ae0fe3b399c) (adds macro + registry) +**Branches:** [`feat/handler-api-v0.5`](https://github.com/lambdaclass/spawned/tree/34bf9a759cda72e5311efda8f1fc8a5ae515129a) (implementation), [`feat/critical-api-issues`](https://github.com/lambdaclass/spawned/tree/1ef33bf0c463543dca379463c554ccc5914c86ff) (design doc), [`feat/actor-macro-registry`](https://github.com/lambdaclass/spawned/tree/de651ad21e2dd39babf534cb74174ae0fe3b399c) (adds macro + registry) **Status:** Fully implemented and working. 34 tests passing. Multiple examples ported. @@ -388,7 +388,7 @@ bob.say("Hi Alice!".into()).unwrap(); ## Approach B: Protocol Traits (user-defined contracts) -**Branch:** [`feat/145-protocol-trait`](../../tree/b0e5afb2c69e1f5b6ab8ee82b59582348877c819) (WIP, committed) +**Branch:** [`feat/145-protocol-trait`](https://github.com/lambdaclass/spawned/tree/b0e5afb2c69e1f5b6ab8ee82b59582348877c819) (WIP, committed) **Status:** WIP. Full implementation + migrated examples committed. @@ -551,7 +551,7 @@ alice.send_request(SayToRoom { text: "Hello everyone!".into() }).await?; ## Approach C: Typed Wrappers (non-breaking) -**Branch:** Not implemented. Documented in [`docs/ALTERNATIVE_APPROACHES.md`](../../blob/b0e5afb2c69e1f5b6ab8ee82b59582348877c819/docs/ALTERNATIVE_APPROACHES.md). +**Branch:** Not implemented. Documented in [`docs/ALTERNATIVE_APPROACHES.md`](https://github.com/lambdaclass/spawned/blob/b0e5afb2c69e1f5b6ab8ee82b59582348877c819/docs/ALTERNATIVE_APPROACHES.md). Keeps the old enum-based `Actor` trait unchanged. Adds typed convenience methods that hide the enum matching. For #145, adds a second envelope-based channel to `ActorRef` alongside the existing enum channel. @@ -648,7 +648,7 @@ impl Handler for ChatRoom { /* ... */ } ## Approach D: Derive Macro -**Branch:** Not implemented. Documented in [`docs/ALTERNATIVE_APPROACHES.md`](../../blob/b0e5afb2c69e1f5b6ab8ee82b59582348877c819/docs/ALTERNATIVE_APPROACHES.md). +**Branch:** Not implemented. Documented in [`docs/ALTERNATIVE_APPROACHES.md`](https://github.com/lambdaclass/spawned/blob/b0e5afb2c69e1f5b6ab8ee82b59582348877c819/docs/ALTERNATIVE_APPROACHES.md). A proc macro `#[derive(ActorMessages)]` auto-generates per-variant message structs, `Message` impls, typed wrappers, and `Handler` delegation from an annotated enum. @@ -720,7 +720,7 @@ let members = room.members().await.unwrap(); ## Approach E: AnyActorRef (fully type-erased) -**Branch:** Not implemented. Documented in [`docs/ALTERNATIVE_APPROACHES.md`](../../blob/b0e5afb2c69e1f5b6ab8ee82b59582348877c819/docs/ALTERNATIVE_APPROACHES.md). +**Branch:** Not implemented. Documented in [`docs/ALTERNATIVE_APPROACHES.md`](https://github.com/lambdaclass/spawned/blob/b0e5afb2c69e1f5b6ab8ee82b59582348877c819/docs/ALTERNATIVE_APPROACHES.md). Replaces `Recipient` with a single fully type-erased handle `AnyActorRef = Arc` using `Box`. @@ -809,7 +809,7 @@ let members: Vec = *reply.downcast::>().expect("wrong reply ## Approach F: PID Addressing (Erlang-style) -**Branch:** Not implemented. Documented in [`docs/ALTERNATIVE_APPROACHES.md`](../../blob/b0e5afb2c69e1f5b6ab8ee82b59582348877c819/docs/ALTERNATIVE_APPROACHES.md). +**Branch:** Not implemented. Documented in [`docs/ALTERNATIVE_APPROACHES.md`](https://github.com/lambdaclass/spawned/blob/b0e5afb2c69e1f5b6ab8ee82b59582348877c819/docs/ALTERNATIVE_APPROACHES.md). Every actor gets a `Pid(u64)`. A global registry maps `(Pid, TypeId)` → message sender. Messages are sent by PID with explicit registration per message type. @@ -1086,15 +1086,15 @@ And `spawned::send(pid, Msg { ... })` could get ergonomic wrappers similar to `a | Branch | Base | Description | |--------|------|-------------| | `main` | — | Old enum-based API (baseline) | -| [`feat/critical-api-issues`](../../tree/1ef33bf0c463543dca379463c554ccc5914c86ff) | main | Design doc for Handler\ + Recipient\ ([`docs/API_REDESIGN.md`](../../blob/1ef33bf0c463543dca379463c554ccc5914c86ff/docs/API_REDESIGN.md)) | -| [`feat/handler-api-v0.5`](../../tree/34bf9a759cda72e5311efda8f1fc8a5ae515129a) | main | Handler\ + Recipient\ implementation | -| [`feat/actor-macro-registry`](../../tree/de651ad21e2dd39babf534cb74174ae0fe3b399c) | main | Adds `#[actor]` macro + named registry on top of Handler\ | -| [`feat/145-protocol-trait`](../../tree/b0e5afb2c69e1f5b6ab8ee82b59582348877c819) | main | Protocol traits approach + [`docs/ALTERNATIVE_APPROACHES.md`](../../blob/b0e5afb2c69e1f5b6ab8ee82b59582348877c819/docs/ALTERNATIVE_APPROACHES.md) | -| [`docs/add-project-roadmap`](../../tree/426c1a9952b3ad440686c318882d570f2032666f) | main | Framework comparison with Actix and Ractor | +| [`feat/critical-api-issues`](https://github.com/lambdaclass/spawned/tree/1ef33bf0c463543dca379463c554ccc5914c86ff) | main | Design doc for Handler\ + Recipient\ ([`docs/API_REDESIGN.md`](https://github.com/lambdaclass/spawned/blob/1ef33bf0c463543dca379463c554ccc5914c86ff/docs/API_REDESIGN.md)) | +| [`feat/handler-api-v0.5`](https://github.com/lambdaclass/spawned/tree/34bf9a759cda72e5311efda8f1fc8a5ae515129a) | main | Handler\ + Recipient\ implementation | +| [`feat/actor-macro-registry`](https://github.com/lambdaclass/spawned/tree/de651ad21e2dd39babf534cb74174ae0fe3b399c) | main | Adds `#[actor]` macro + named registry on top of Handler\ | +| [`feat/145-protocol-trait`](https://github.com/lambdaclass/spawned/tree/b0e5afb2c69e1f5b6ab8ee82b59582348877c819) | main | Protocol traits approach + [`docs/ALTERNATIVE_APPROACHES.md`](https://github.com/lambdaclass/spawned/blob/b0e5afb2c69e1f5b6ab8ee82b59582348877c819/docs/ALTERNATIVE_APPROACHES.md) | +| [`docs/add-project-roadmap`](https://github.com/lambdaclass/spawned/tree/426c1a9952b3ad440686c318882d570f2032666f) | main | Framework comparison with Actix and Ractor | --- ## Detailed Design Documents -- **[`docs/API_REDESIGN.md`](../../blob/1ef33bf0c463543dca379463c554ccc5914c86ff/docs/API_REDESIGN.md)** (on `feat/critical-api-issues`) — Full design rationale for Handler\, Receiver\, Envelope pattern, RPITIT decision, and planned supervision traits. -- **[`docs/ALTERNATIVE_APPROACHES.md`](../../blob/b0e5afb2c69e1f5b6ab8ee82b59582348877c819/docs/ALTERNATIVE_APPROACHES.md)** (on `feat/145-protocol-trait`) — Original comparison of all 5 alternative branches with execution order plan. +- **[`docs/API_REDESIGN.md`](https://github.com/lambdaclass/spawned/blob/1ef33bf0c463543dca379463c554ccc5914c86ff/docs/API_REDESIGN.md)** (on `feat/critical-api-issues`) — Full design rationale for Handler\, Receiver\, Envelope pattern, RPITIT decision, and planned supervision traits. +- **[`docs/ALTERNATIVE_APPROACHES.md`](https://github.com/lambdaclass/spawned/blob/b0e5afb2c69e1f5b6ab8ee82b59582348877c819/docs/ALTERNATIVE_APPROACHES.md)** (on `feat/145-protocol-trait`) — Original comparison of all 5 alternative branches with execution order plan. From 5f2437a33f9ef1a65d4d5764de6fde468a4af09a Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Wed, 18 Feb 2026 18:45:23 -0300 Subject: [PATCH 09/20] docs: refresh Approach B with Response, type aliases, and conversion helpers --- docs/API_ALTERNATIVES_SUMMARY.md | 269 +++++++++++++++++++++---------- 1 file changed, 187 insertions(+), 82 deletions(-) diff --git a/docs/API_ALTERNATIVES_SUMMARY.md b/docs/API_ALTERNATIVES_SUMMARY.md index 5e01c9a..555d522 100644 --- a/docs/API_ALTERNATIVES_SUMMARY.md +++ b/docs/API_ALTERNATIVES_SUMMARY.md @@ -394,140 +394,225 @@ bob.say("Hi Alice!".into()).unwrap(); Uses the same `Handler` and `#[actor]` macro as Approach A for #144. Solves #145 differently: instead of `Recipient`, actors communicate across boundaries via explicit user-defined trait objects. +**Key improvements over the initial WIP:** Type aliases (`BroadcasterRef`, `ParticipantRef`) replace raw `Arc`, conversion helpers (`.as_broadcaster()`) replace `Arc::new(x.clone())`, and `Response` enables async request-response on protocol traits without breaking object safety. + +### Response\: Envelope's counterpart on the receive side + +The existing codebase uses the **Envelope pattern** to type-erase messages on the send side: `Box>` wraps a message + a oneshot sender, allowing the actor's mailbox to hold heterogeneous messages. `Response` is the structural mirror on the receive side — it wraps a oneshot receiver and implements `Future>`: + +```rust +// Envelope (existing): type-erases on the SEND side +// Box> holds msg + response sender + +// Response (new): concrete awaitable on the RECEIVE side +// wraps oneshot::Receiver, implements Future +pub struct Response(oneshot::Receiver); + +impl Future for Response { + type Output = Result; + fn poll(self: Pin<&mut Self>, cx: &mut std::task::Context) -> Poll { + // delegates to inner receiver + } +} +``` + +This keeps protocol traits **object-safe** — `fn members(&self) -> Response>` returns a concrete type, not `impl Future` (which would require RPITIT and break `dyn Trait`). No `BoxFuture` boxing needed either. + ### Full chat room code
-protocols.rs — shared contracts, neither actor type mentioned +protocols.rs — shared contracts with type aliases + Response<T> ```rust use spawned_concurrency::error::ActorError; +use spawned_concurrency::Response; use std::sync::Arc; +pub type ParticipantRef = Arc; +pub type BroadcasterRef = Arc; + pub trait ChatParticipant: Send + Sync { fn deliver(&self, from: String, text: String) -> Result<(), ActorError>; } pub trait ChatBroadcaster: Send + Sync { fn say(&self, from: String, text: String) -> Result<(), ActorError>; - fn add_member(&self, name: String, inbox: Arc) -> Result<(), ActorError>; + fn add_member(&self, name: String, inbox: ParticipantRef) -> Result<(), ActorError>; + fn members(&self) -> Response>; } ```
-messages.rs — internal message structs +room.rs — Messages → Bridge → Conversion → Actor ```rust +use spawned_concurrency::actor_api; use spawned_concurrency::messages; +use spawned_concurrency::error::ActorError; +use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; +use spawned_concurrency::Response; +use spawned_macros::actor; +use std::sync::Arc; +use crate::protocols::{BroadcasterRef, ChatBroadcaster, ChatParticipant, ParticipantRef}; + +// -- Messages -- messages! { Say { from: String, text: String } -> (); - SayToRoom { text: String } -> (); - Deliver { from: String, text: String } -> (); + Members -> Vec; } -``` -
- -
-room.rs — actor + bridge impl -```rust -use spawned_concurrency::error::ActorError; -use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; -use std::sync::Arc; -use crate::messages::Say; -use crate::protocols::{ChatBroadcaster, ChatParticipant}; - -// Join carries an Arc — manually defined (can't use message macro) pub struct Join { pub name: String, - pub inbox: Arc, + pub inbox: ParticipantRef, } impl Message for Join { type Result = (); } +// -- Protocol bridge -- + +impl ChatBroadcaster for ActorRef { + fn say(&self, from: String, text: String) -> Result<(), ActorError> { + self.send(Say { from, text }) + } + fn add_member(&self, name: String, inbox: ParticipantRef) -> Result<(), ActorError> { + self.send(Join { name, inbox }) + } + fn members(&self) -> Response> { + Response::from(self.request_raw(Members)) + } +} + +// -- Conversion helper -- + +impl ActorRef { + pub fn as_broadcaster(&self) -> BroadcasterRef { + Arc::new(self.clone()) + } +} + +// -- Actor -- + pub struct ChatRoom { - members: Vec<(String, Arc)>, + members: Vec<(String, ParticipantRef)>, } impl Actor for ChatRoom {} -impl Handler for ChatRoom { - async fn handle(&mut self, msg: Join, _ctx: &Context) { +#[actor] +impl ChatRoom { + pub fn new() -> Self { + Self { members: Vec::new() } + } + + #[send_handler] + async fn handle_join(&mut self, msg: Join, _ctx: &Context) { self.members.push((msg.name, msg.inbox)); } -} -impl Handler for ChatRoom { - async fn handle(&mut self, msg: Say, _ctx: &Context) { + #[send_handler] + async fn handle_say(&mut self, msg: Say, _ctx: &Context) { for (name, inbox) in &self.members { if *name != msg.from { let _ = inbox.deliver(msg.from.clone(), msg.text.clone()); } } } -} -// Bridge: ActorRef implements the protocol trait -impl ChatBroadcaster for ActorRef { - fn say(&self, from: String, text: String) -> Result<(), ActorError> { - self.send(Say { from, text }) - } - fn add_member(&self, name: String, inbox: Arc) -> Result<(), ActorError> { - self.send(Join { name, inbox }) + #[request_handler] + async fn handle_members(&mut self, _msg: Members, _ctx: &Context) -> Vec { + self.members.iter().map(|(name, _)| name.clone()).collect() } } ```
-user.rs — actor + bridge impl +user.rs — bridge + conversion + actor_api! for direct caller API ```rust +use spawned_concurrency::actor_api; +use spawned_concurrency::messages; +use spawned_concurrency::error::ActorError; use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; +use spawned_macros::actor; use std::sync::Arc; -use crate::messages::{Deliver, SayToRoom}; -use crate::protocols::{ChatBroadcaster, ChatParticipant}; +use crate::protocols::{BroadcasterRef, ChatParticipant, ParticipantRef}; + +// -- Messages -- + +messages! { + SayToRoom { text: String } -> (); + Deliver { from: String, text: String } -> (); +} + +// -- Protocol bridge -- + +impl ChatParticipant for ActorRef { + fn deliver(&self, from: String, text: String) -> Result<(), ActorError> { + self.send(Deliver { from, text }) + } +} + +// -- Conversion helper -- + +impl ActorRef { + pub fn as_participant(&self) -> ParticipantRef { + Arc::new(self.clone()) + } +} + +// -- Direct caller API (for main.rs) -- + +actor_api! { + pub UserApi for ActorRef { + send fn say(text: String) => SayToRoom; + } +} + +// -- Actor -- pub struct User { pub name: String, - pub room: Arc, // protocol trait, not ActorRef + pub room: BroadcasterRef, } impl Actor for User {} -impl Handler for User { - async fn handle(&mut self, msg: SayToRoom, _ctx: &Context) { - let _ = self.room.say(self.name.clone(), msg.text); +#[actor] +impl User { + pub fn new(name: String, room: BroadcasterRef) -> Self { + Self { name, room } } -} -impl Handler for User { - async fn handle(&mut self, msg: Deliver, _ctx: &Context) { - tracing::info!("[{}] got: {} says '{}'", self.name, msg.from, msg.text); + #[send_handler] + async fn handle_say_to_room(&mut self, msg: SayToRoom, _ctx: &Context) { + let _ = self.room.say(self.name.clone(), msg.text); } -} -// Bridge: ActorRef implements the protocol trait -impl ChatParticipant for ActorRef { - fn deliver(&self, from: String, text: String) -> Result<(), ActorError> { - self.send(Deliver { from, text }) + #[send_handler] + async fn handle_deliver(&mut self, msg: Deliver, _ctx: &Context) { + tracing::info!("[{}] got: {} says '{}'", self.name, msg.from, msg.text); } } ```
-main.rs +main.rs — clean, comparable to Approach A ```rust let room = ChatRoom::new().start(); -let alice = User { name: "Alice".into(), room: Arc::new(room.clone()) }.start(); -let bob = User { name: "Bob".into(), room: Arc::new(room.clone()) }.start(); +let alice = User::new("Alice".into(), room.as_broadcaster()).start(); +let bob = User::new("Bob".into(), room.as_broadcaster()).start(); -room.add_member("Alice".into(), Arc::new(alice.clone())).unwrap(); -room.add_member("Bob".into(), Arc::new(bob.clone())).unwrap(); +room.add_member("Alice".into(), alice.as_participant()).unwrap(); +room.add_member("Bob".into(), bob.as_participant()).unwrap(); -alice.send_request(SayToRoom { text: "Hello everyone!".into() }).await?; +let members = room.members().await?; + +alice.say("Hello everyone!".into()).unwrap(); +bob.say("Hi Alice!".into()).unwrap(); ```
@@ -535,17 +620,18 @@ alice.send_request(SayToRoom { text: "Hello everyone!".into() }).await?; | Dimension | Assessment | |-----------|-----------| -| **Readability** | `protocols.rs` is an excellent summary of what crosses the actor boundary. But the bridge impls (`impl ChatBroadcaster for ActorRef`) are pure boilerplate — each method just wraps `self.send(MessageStruct { ... })`. | -| **API at a glance** | The protocol traits serve as a natural API contract. Looking at `ChatBroadcaster` tells you exactly what a room can do, with named methods and their signatures. This is the strongest "at-a-glance" surface of all approaches. | -| **Boilerplate** | Higher than Approach A. For each cross-actor boundary you need: (1) a protocol trait, (2) a bridge impl, (3) the message structs, and (4) the Handler impls. That's 4 layers of code. With Approach A's `Recipient`, the bridge layer disappears entirely. | -| **main.rs expressivity** | Protocol methods are directly callable: `room.say(...)`, `room.add_member(...)`. But wiring requires `Arc::new()` wrapping: `Arc::new(room.clone())`, `Arc::new(alice.clone())`. | -| **Circular dep solution** | Actors hold `Arc` instead of `ActorRef`. Clean in concept but each new message type crossing the boundary requires adding a method to the protocol trait + updating the bridge impl. | -| **Macro compatibility** | The `#[actor]` macro works for the Handler impls, but bridge impls must still be written manually. The protocol trait itself has no macro support. | +| **Readability** | `protocols.rs` is an excellent summary of what crosses the actor boundary. Type aliases (`BroadcasterRef`, `ParticipantRef`) eliminate raw `Arc` noise. Files read top-to-bottom: Messages → Bridge → Conversion → Actor. | +| **API at a glance** | Protocol traits serve as the natural API contract for cross-actor boundaries. Looking at `ChatBroadcaster` tells you exactly what a room can do, with named methods and signatures — the strongest "at-a-glance" surface of all approaches. `actor_api!` adds a direct caller API for `main.rs` where protocol traits aren't needed. | +| **Boilerplate** | Higher than Approach A per cross-actor boundary: protocol trait + bridge impl + message structs + Handler impls. Mitigated by type aliases, conversion helpers, and potential macro bridge (see [Macro Improvement Potential](#macro-improvement-potential)). | +| **main.rs expressivity** | Now comparable to A: `room.as_broadcaster()` instead of `Arc::new(room.clone())`, `room.members().await?` via `Response`, `alice.say(...)` via `actor_api!`. | +| **Request-response** | `Response` keeps protocol traits object-safe while supporting async request-response. Structural mirror of the Envelope pattern — no RPITIT, no `BoxFuture` boxing. | +| **Circular dep solution** | Actors hold `BroadcasterRef` / `ParticipantRef` instead of `ActorRef`. Each new cross-boundary message requires adding a method to the protocol trait + updating the bridge impl. | +| **Macro compatibility** | `#[actor]` for Handler impls, `actor_api!` for direct caller API. Bridge impls are manual but structurally regular (macro bridge is feasible — see below). | | **Testability** | Best of all approaches — you can mock `ChatBroadcaster` or `ChatParticipant` directly in unit tests without running an actor system. | -**Key insight:** Protocol traits excel as documentation and testing contracts. But they duplicate information: the protocol trait method `fn say(&self, from: String, text: String)` mirrors the message struct `Say { from: String, text: String }` and the bridge impl just connects them. In Approach A, `Recipient` removes this duplication — the message struct *is* the contract. +**Key insight:** Protocol traits define contracts at the actor level (like Erlang behaviours) rather than the message level (like Actix's `Recipient`). The duplication cost (protocol method mirrors message struct) is real but buys three things: (1) testability via trait mocking, (2) a natural "API at a glance" surface, and (3) actor-level granularity for registry and discovery. With `Response`, type aliases, and conversion helpers, B's expressivity now matches A's macro version. -**Scaling concern:** In a system with N actor types and M message types crossing boundaries, Approach A needs M message structs. Approach B needs M message structs + P protocol traits + P bridge impls, where P grows with the number of distinct actor-to-actor interaction patterns. +**Scaling trade-off:** In a system with N actor types and M cross-boundary message types, Approach A needs M message structs. Approach B needs M message structs + P protocol traits + P bridge impls, where P grows with distinct actor-to-actor interaction patterns. The extra cost scales with *interaction patterns*, not messages — and each protocol trait is a natural documentation + testing boundary. --- @@ -934,7 +1020,7 @@ The registry API (`register`, `whereis`, `unregister`, `registered`) stays the s - **A and D** register per message type: `registry::register("room_lookup", room.recipient::())`. A consumer discovers a `Recipient` — it can only send `Lookup` messages, nothing else. If the room handles 5 message types, you can register it under 5 names (or one name per message type you want to expose). This is the most granular. -- **B** registers per protocol: `registry::register("room", Arc::new(room.clone()) as Arc)`. A consumer discovers an `Arc` — it can call any method on the protocol (`say`, `add_member`). This is coarser but more natural: one registration covers all the methods in the protocol. +- **B** registers per protocol: `registry::register("room", room.as_broadcaster())`. A consumer discovers a `BroadcasterRef` (`Arc`) — it can call any method on the protocol (`say`, `add_member`, `members`). This is coarser but more natural: one registration covers all the methods in the protocol. - **E** is trivially simple but useless: `registry::register("room", room.any_ref())`. You get back an `AnyActorRef` that accepts `Box`. No compile-time knowledge of what messages the actor handles. @@ -948,17 +1034,20 @@ Approach A's `actor_api!` macro eliminates extension trait boilerplate by genera ### Approach B: Protocol Traits — YES, significant potential -The bridge impls are structurally identical to what `actor_api!` already generates. Each bridge method just wraps `self.send(Msg { fields })`: +B already uses `actor_api!` for direct caller APIs (e.g., `UserApi` in `user.rs`). The remaining boilerplate is bridge impls — structurally identical to what `actor_api!` generates. Each bridge method just wraps `self.send(Msg { fields })` or `Response::from(self.request_raw(Msg))`: ```rust -// Current bridge boilerplate (~10 lines per actor) +// Current bridge boilerplate (~12 lines per actor) impl ChatBroadcaster for ActorRef { fn say(&self, from: String, text: String) -> Result<(), ActorError> { self.send(Say { from, text }) } - fn add_member(&self, name: String, inbox: Arc) -> Result<(), ActorError> { + fn add_member(&self, name: String, inbox: ParticipantRef) -> Result<(), ActorError> { self.send(Join { name, inbox }) } + fn members(&self) -> Response> { + Response::from(self.request_raw(Members)) + } } ``` @@ -969,14 +1058,27 @@ A variant of `actor_api!` could generate bridge impls for an existing trait: actor_api! { impl ChatBroadcaster for ActorRef { send fn say(from: String, text: String) => Say; - send fn add_member(name: String, inbox: Arc) => Join; + send fn add_member(name: String, inbox: ParticipantRef) => Join; + request fn members() -> Vec => Members; } } ``` This would use the same syntax but `impl Trait for Type` (no `pub`, no new trait) signals that we're implementing an existing trait. The protocol trait itself remains user-defined — it IS the contract, so it should stay hand-written. -**Impact:** Bridge boilerplate per actor drops from ~10 lines to ~4 lines. The protocol trait definition stays manual (by design). Combined with `#[actor]`, the total code for a protocol-based actor would be competitive with Approach A. +Conversion helpers (`as_broadcaster()`, `as_participant()`) could also be generated: + +```rust +// Potential: auto-generate conversion helpers +actor_api! { + impl ChatBroadcaster for ActorRef { + convert fn as_broadcaster() -> BroadcasterRef; + // ... + } +} +``` + +**Impact:** Bridge boilerplate per actor drops from ~12 lines to ~5 lines. The protocol trait definition stays manual (by design). Combined with `#[actor]` and `actor_api!` for direct caller APIs, the total code for a protocol-based actor is competitive with Approach A. ### Approach C: Typed Wrappers — NO @@ -1011,13 +1113,13 @@ And `spawned::send(pid, Msg { ... })` could get ergonomic wrappers similar to `a | Approach | Macro potential | What it would eliminate | Worth implementing? | |----------|----------------|----------------------|---------------------| -| **B: Protocol Traits** | High | Bridge impl boilerplate | Yes — `actor_api!` impl-only mode | +| **B: Protocol Traits** | High | Bridge impls + conversion helpers | Yes — `actor_api!` impl-only mode | | **C: Typed Wrappers** | None | N/A — structural problem | No | | **D: Derive Macro** | N/A | Already a macro | N/A | | **E: AnyActorRef** | None | Would add false safety | No | | **F: PID** | Low-Medium | Registration ceremony | Maybe — ergonomics only | -**Takeaway:** Approach B is the only unimplemented approach that would meaningfully benefit from `actor_api!`-style macros. And the required change is small — adding an impl-only mode to the existing macro. This would make Approach B more competitive with Approach A on boilerplate, while retaining its testability advantage. +**Takeaway:** Approach B already uses `actor_api!` for direct caller APIs and would benefit further from an impl-only mode for bridge impls. The required change is small — reusing the existing macro syntax. With `Response`, type aliases, conversion helpers, and macro bridge impls, Approach B's total code is competitive with Approach A while retaining its testability and Erlang-like actor-level granularity advantages. --- @@ -1031,19 +1133,19 @@ And `spawned::send(pid, Msg { ... })` could get ergonomic wrappers similar to `a | **Breaking** | Yes | Yes | No | No | Yes | Yes | | **#144 type safety** | Full | Full | Hidden `unreachable!` | Hidden `unreachable!` | Full | Full | | **#145 type safety** | Compile-time | Compile-time | Compile-time | Compile-time | Runtime only | Runtime only | -| **Macro support** | `#[actor]` + `actor_api!` + message macros | `#[actor]` (no bridge macro) | N/A (enum-based) | Derive macro | `#[actor]` | `#[actor]` | +| **Macro support** | `#[actor]` + `actor_api!` + message macros | `#[actor]` + `actor_api!` (direct API) + bridge impls | N/A (enum-based) | Derive macro | `#[actor]` | `#[actor]` | | **Dual-mode (async+threads)** | Works | Works | Complex (dual channel) | Complex | Works | Works | -| **Registry stores** | `Recipient` | `Arc` | Mixed | `Recipient` | `AnyActorRef` | `Pid` | +| **Registry stores** | `Recipient` | `BroadcasterRef` / `Arc` | Mixed | `Recipient` | `AnyActorRef` | `Pid` | | **Registry type safety** | Compile-time | Compile-time | Depends | Compile-time | Runtime | Runtime | ### Code Quality Dimensions | Dimension | A: Recipient | B: Protocol Traits | C: Typed Wrappers | D: Derive Macro | E: AnyActorRef | F: PID | |-----------|-------------|-------------------|-------------------|-----------------|---------------|--------| -| **Handler readability** | Clear: one `impl Handler` or `#[send_handler]` per message | Same as A + bridge impls | Noisy: enum `match` arms + wrapper fns | Opaque: generated from enum annotations | Same as A, but callers use `Box::new` | Same as A, but callers use global `send(pid, msg)` | -| **API at a glance** | `actor_api!` block or scan Handler impls | Protocol traits (best) | Typed wrapper functions | Annotated enum (good summary) | Nothing — `AnyActorRef` is opaque | Nothing — `Pid` is opaque | -| **main.rs expressivity** | `alice.say("Hi")` with `actor_api!`; `alice.send(SayToRoom{...})` without | `room.say("Alice", "Hi")` via protocol | `ChatRoom::say(&room, ...)` assoc fn | Generated methods: `room.say(...)` | `room.send_any(Box::new(...))` | `spawned::send(pid, ...)` + registration | -| **Boilerplate per message** | Struct + `actor_api!` line | Struct + trait method + bridge impl | Enum variant + wrapper + match arm | Enum variant + annotation | Struct | Struct + registration | +| **Handler readability** | Clear: one `impl Handler` or `#[send_handler]` per message | Same as A for handlers. Files read Messages → Bridge → Conversion → Actor. Type aliases reduce noise. | Noisy: enum `match` arms + wrapper fns | Opaque: generated from enum annotations | Same as A, but callers use `Box::new` | Same as A, but callers use global `send(pid, msg)` | +| **API at a glance** | `actor_api!` block or scan Handler impls | Protocol traits (best) + `actor_api!` for direct caller API | Typed wrapper functions | Annotated enum (good summary) | Nothing — `AnyActorRef` is opaque | Nothing — `Pid` is opaque | +| **main.rs expressivity** | `alice.say("Hi")` with `actor_api!`; `alice.send(SayToRoom{...})` without | `room.say(...)`, `room.members().await?` via protocol; `alice.say(...)` via `actor_api!` | `ChatRoom::say(&room, ...)` assoc fn | Generated methods: `room.say(...)` | `room.send_any(Box::new(...))` | `spawned::send(pid, ...)` + registration | +| **Boilerplate per message** | Struct + `actor_api!` line | Struct + protocol method + bridge impl (or macro bridge) | Enum variant + wrapper + match arm | Enum variant + annotation | Struct | Struct + registration | | **Debugging** | Standard Rust — all code visible | Standard Rust — bridge impls visible | Standard Rust | Requires `cargo expand` | Runtime errors (downcast failures) | Runtime errors (unregistered types) | | **Testability** | Good (mock via Recipient) | Best (mock protocol trait) | Good | Good | Fair (Any-based) | Hard (global state) | @@ -1055,8 +1157,8 @@ And `spawned::send(pid, Msg { ... })` could get ergonomic wrappers similar to `a | **Maintenance burden** | Low — proven Actix pattern | Low — user-maintained | High — two dispatch systems | High — complex macro | Medium | Medium | | **Clustering readiness** | Needs `RemoteRecipient` | Needs remote bridge impls | Hard | Hard | Possible (serialize Any) | Excellent (Pid is location-transparent) | | **Learning curve** | Moderate (Handler pattern) | Moderate + bridge pattern | Low (old API preserved) | Low (write enum, macro does rest) | Low concept, high debugging | Low concept, high registration overhead | -| **Erlang alignment** | Actix-like | Least Erlang | Actix-like | Actix-like | Erlang-ish | Most Erlang | -| **Macro improvement potential** | Already done (`actor_api!`) | High (bridge impls) | None (structural) | N/A (is a macro) | None (false safety) | Low (ergonomics only) | +| **Erlang alignment** | Actix-like | Actor-level granularity (Erlang behaviours) | Actix-like | Actix-like | Erlang-ish | Most Erlang | +| **Macro improvement potential** | Already done (`actor_api!`) | Medium (bridge impls, conversion helpers) | None (structural) | N/A (is a macro) | None (false safety) | Low (ergonomics only) | --- @@ -1070,10 +1172,13 @@ And `spawned::send(pid, Msg { ... })` could get ergonomic wrappers similar to `a - Proven pattern (Actix uses the same architecture) - Non-macro version is already clean — the macros are additive, not essential -**Approach B (Protocol Traits)** is valuable as a **complementary** pattern: +**Approach B (Protocol Traits)** is a strong alternative, especially with `Response`: +- With type aliases, conversion helpers, and `Response`, main.rs expressivity now matches Approach A +- `Response` keeps protocol traits object-safe while supporting async request-response — structural mirror of the Envelope pattern (no RPITIT, no `BoxFuture`) +- Protocol traits define contracts at the actor level (like Erlang behaviours), giving actor-level granularity for registry and discovery +- Best testability — protocol traits can be mocked directly without running an actor system - Can coexist with Recipient\ — use protocol traits where you want explicit contracts and testability, Recipient\ where you want less boilerplate -- No framework changes needed — it's purely a user-space convention -- Best option for high-testability boundaries, but the bridge boilerplate cost is real +- Only requires `Response` from the framework; protocol traits and bridge impls are purely user-space **Approaches C and D** try to preserve the old enum-based API but introduce significant complexity (dual-channel, or heavy code generation) to work around its limitations. From 5d39d73c2016b3643b7c26d1e6b70008e927f8f9 Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Wed, 18 Feb 2026 19:32:03 -0300 Subject: [PATCH 10/20] docs: use messages! macro for Join, remove stale import in B example --- docs/API_ALTERNATIVES_SUMMARY.md | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/docs/API_ALTERNATIVES_SUMMARY.md b/docs/API_ALTERNATIVES_SUMMARY.md index 555d522..83cd74e 100644 --- a/docs/API_ALTERNATIVES_SUMMARY.md +++ b/docs/API_ALTERNATIVES_SUMMARY.md @@ -447,28 +447,22 @@ pub trait ChatBroadcaster: Send + Sync { room.rs — Messages → Bridge → Conversion → Actor ```rust -use spawned_concurrency::actor_api; use spawned_concurrency::messages; use spawned_concurrency::error::ActorError; use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; use spawned_concurrency::Response; use spawned_macros::actor; use std::sync::Arc; -use crate::protocols::{BroadcasterRef, ChatBroadcaster, ChatParticipant, ParticipantRef}; +use crate::protocols::{BroadcasterRef, ChatBroadcaster, ParticipantRef}; // -- Messages -- messages! { Say { from: String, text: String } -> (); + Join { name: String, inbox: ParticipantRef } -> (); Members -> Vec; } -pub struct Join { - pub name: String, - pub inbox: ParticipantRef, -} -impl Message for Join { type Result = (); } - // -- Protocol bridge -- impl ChatBroadcaster for ActorRef { From cd0f05b78688e5c3ed5e9965b551c5e580cf1908 Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Thu, 19 Feb 2026 15:54:19 -0300 Subject: [PATCH 11/20] feat: add macro infrastructure, Receiver/Recipient, and registry --- Cargo.lock | 30 +++ Cargo.toml | 4 + concurrency/Cargo.toml | 1 + concurrency/src/lib.rs | 1 + concurrency/src/message.rs | 247 +++++++++++++++++++- concurrency/src/registry.rs | 91 ++++++++ concurrency/src/tasks/actor.rs | 162 ++++++++++--- concurrency/src/tasks/mod.rs | 3 +- concurrency/src/tasks/stream_tests.rs | 14 +- concurrency/src/tasks/timer_tests.rs | 12 +- concurrency/src/threads/actor.rs | 102 ++++++-- concurrency/src/threads/mod.rs | 2 +- concurrency/src/threads/timer_tests.rs | 12 +- examples/bank/src/main.rs | 18 +- examples/bank_threads/src/main.rs | 18 +- examples/blocking_genserver/main.rs | 4 +- examples/chat_room/src/main.rs | 4 +- examples/chat_room_threads/Cargo.toml | 11 + examples/chat_room_threads/src/main.rs | 39 ++++ examples/chat_room_threads/src/room.rs | 67 ++++++ examples/chat_room_threads/src/user.rs | 56 +++++ examples/name_server/src/main.rs | 6 +- examples/ping_pong_threads/src/consumer.rs | 31 +-- examples/ping_pong_threads/src/main.rs | 59 ++--- examples/ping_pong_threads/src/messages.rs | 9 +- examples/ping_pong_threads/src/producer.rs | 46 ++-- examples/ping_pong_threads/src/protocols.rs | 40 ++++ examples/service_discovery/Cargo.toml | 11 + examples/service_discovery/src/main.rs | 102 ++++++++ macros/Cargo.toml | 14 ++ macros/src/lib.rs | 112 +++++++++ 31 files changed, 1143 insertions(+), 185 deletions(-) create mode 100644 concurrency/src/registry.rs create mode 100644 examples/chat_room_threads/Cargo.toml create mode 100644 examples/chat_room_threads/src/main.rs create mode 100644 examples/chat_room_threads/src/room.rs create mode 100644 examples/chat_room_threads/src/user.rs create mode 100644 examples/ping_pong_threads/src/protocols.rs create mode 100644 examples/service_discovery/Cargo.toml create mode 100644 examples/service_discovery/src/main.rs create mode 100644 macros/Cargo.toml create mode 100644 macros/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 978e604..42c156e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -129,6 +129,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "chat_room_threads" +version = "0.4.5" +dependencies = [ + "spawned-concurrency", + "spawned-macros", + "spawned-rt", + "tracing", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1138,6 +1148,16 @@ dependencies = [ "serde", ] +[[package]] +name = "service_discovery" +version = "0.4.5" +dependencies = [ + "spawned-concurrency", + "spawned-macros", + "spawned-rt", + "tracing", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1219,6 +1239,7 @@ version = "0.4.5" dependencies = [ "futures", "pin-project-lite", + "spawned-macros", "spawned-rt", "thiserror", "tokio", @@ -1226,6 +1247,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "spawned-macros" +version = "0.4.5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "spawned-rt" version = "0.4.5" diff --git a/Cargo.toml b/Cargo.toml index 7bbb458..abd1256 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ resolver = "3" members = [ "rt", "concurrency", + "macros", "examples/bank", "examples/bank_threads", "examples/name_server", @@ -16,11 +17,14 @@ members = [ "examples/signal_test", "examples/signal_test_threads", "examples/chat_room", + "examples/chat_room_threads", + "examples/service_discovery", ] [workspace.dependencies] spawned-rt = { path = "rt", version = "0.4.5" } spawned-concurrency = { path = "concurrency", version = "0.4.5" } +spawned-macros = { path = "macros", version = "0.4.5" } tracing = { version = "0.1.41", features = ["log"] } tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } diff --git a/concurrency/Cargo.toml b/concurrency/Cargo.toml index c2845a9..04ba8b8 100644 --- a/concurrency/Cargo.toml +++ b/concurrency/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true [dependencies] spawned-rt = { workspace = true } +spawned-macros = { workspace = true } tracing = { workspace = true } futures = "0.3.1" thiserror = "2.0.12" diff --git a/concurrency/src/lib.rs b/concurrency/src/lib.rs index 9d71f12..c470d0b 100644 --- a/concurrency/src/lib.rs +++ b/concurrency/src/lib.rs @@ -1,4 +1,5 @@ pub mod error; pub mod message; +pub mod registry; pub mod tasks; pub mod threads; diff --git a/concurrency/src/message.rs b/concurrency/src/message.rs index a97b310..c340dd6 100644 --- a/concurrency/src/message.rs +++ b/concurrency/src/message.rs @@ -21,7 +21,6 @@ macro_rules! messages { // Base: unit message ($(#[$meta:meta])* $name:ident -> $result:ty) => { $(#[$meta])* - #[derive(Debug)] pub struct $name; impl $crate::message::Message for $name { type Result = $result; @@ -31,7 +30,6 @@ macro_rules! messages { // Base: struct message ($(#[$meta:meta])* $name:ident { $($field:ident : $ftype:ty),* $(,)? } -> $result:ty) => { $(#[$meta])* - #[derive(Debug)] pub struct $name { $(pub $field: $ftype,)* } impl $crate::message::Message for $name { type Result = $result; @@ -50,3 +48,248 @@ macro_rules! messages { $crate::messages!($($rest)*); }; } + +/// Fire-and-forget messages (Result type is always `()`). +/// +/// ```ignore +/// send_messages! { +/// Increment; +/// Deposit { who: String, amount: i32 } +/// } +/// ``` +#[macro_export] +macro_rules! send_messages { + () => {}; + + // Base: unit message + ($(#[$meta:meta])* $name:ident) => { + $(#[$meta])* + pub struct $name; + impl $crate::message::Message for $name { + type Result = (); + } + }; + + // Base: struct message + ($(#[$meta:meta])* $name:ident { $($field:ident : $ftype:ty),* $(,)? }) => { + $(#[$meta])* + pub struct $name { $(pub $field: $ftype,)* } + impl $crate::message::Message for $name { + type Result = (); + } + }; + + // Recursive: unit message followed by more + ($(#[$meta:meta])* $name:ident; $($rest:tt)*) => { + $crate::send_messages!($(#[$meta])* $name); + $crate::send_messages!($($rest)*); + }; + + // Recursive: struct message followed by more + ($(#[$meta:meta])* $name:ident { $($field:ident : $ftype:ty),* $(,)? }; $($rest:tt)*) => { + $crate::send_messages!($(#[$meta])* $name { $($field : $ftype),* }); + $crate::send_messages!($($rest)*); + }; +} + +/// Request-response messages (Result type is explicitly specified). +/// +/// ```ignore +/// request_messages! { +/// GetCount -> u64; +/// Lookup { key: String } -> Option +/// } +/// ``` +#[macro_export] +macro_rules! request_messages { + () => {}; + + // Base: unit message + ($(#[$meta:meta])* $name:ident -> $result:ty) => { + $(#[$meta])* + pub struct $name; + impl $crate::message::Message for $name { + type Result = $result; + } + }; + + // Base: struct message + ($(#[$meta:meta])* $name:ident { $($field:ident : $ftype:ty),* $(,)? } -> $result:ty) => { + $(#[$meta])* + pub struct $name { $(pub $field: $ftype,)* } + impl $crate::message::Message for $name { + type Result = $result; + } + }; + + // Recursive: unit message followed by more + ($(#[$meta:meta])* $name:ident -> $result:ty; $($rest:tt)*) => { + $crate::request_messages!($(#[$meta])* $name -> $result); + $crate::request_messages!($($rest)*); + }; + + // Recursive: struct message followed by more + ($(#[$meta:meta])* $name:ident { $($field:ident : $ftype:ty),* $(,)? } -> $result:ty; $($rest:tt)*) => { + $crate::request_messages!($(#[$meta])* $name { $($field : $ftype),* } -> $result); + $crate::request_messages!($($rest)*); + }; +} + +/// Generates an extension trait + impl on `ActorRef
` for ergonomic method-call syntax. +/// +/// ```ignore +/// actor_api! { +/// pub ChatRoomApi for ActorRef { +/// send fn say(from: String, text: String) => Say; +/// send fn add_member(name: String, inbox: Recipient) => Join; +/// request async fn members() -> Vec => Members; +/// } +/// } +/// ``` +/// +/// For threads (sync), use `request fn` instead of `request async fn`. +#[macro_export] +macro_rules! actor_api { + // Entry: pub trait + (pub $trait_name:ident for $actor_ref:ty { $($body:tt)* }) => { + $crate::actor_api!(@parse [pub] $trait_name $actor_ref [] [] $($body)*); + }; + + // Entry: private trait + ($trait_name:ident for $actor_ref:ty { $($body:tt)* }) => { + $crate::actor_api!(@parse [] $trait_name $actor_ref [] [] $($body)*); + }; + + // Terminal: generate trait + impl + (@parse [$($vis:tt)*] $trait_name:ident $actor_ref:ty + [$($trait_items:tt)*] + [$($impl_items:tt)*] + ) => { + $($vis)* trait $trait_name { + $($trait_items)* + } + impl $trait_name for $actor_ref { + $($impl_items)* + } + }; + + // send fn with params + (@parse [$($vis:tt)*] $trait_name:ident $actor_ref:ty + [$($trait_items:tt)*] + [$($impl_items:tt)*] + send fn $method:ident($($param:ident : $ptype:ty),+ $(,)?) => $msg:ident; + $($rest:tt)* + ) => { + $crate::actor_api!(@parse [$($vis)*] $trait_name $actor_ref + [$($trait_items)* + fn $method(&self, $($param : $ptype),+) -> Result<(), $crate::error::ActorError>; + ] + [$($impl_items)* + fn $method(&self, $($param : $ptype),+) -> Result<(), $crate::error::ActorError> { + self.send($msg { $($param),+ }) + } + ] + $($rest)* + ); + }; + + // send fn without params (unit message) + (@parse [$($vis:tt)*] $trait_name:ident $actor_ref:ty + [$($trait_items:tt)*] + [$($impl_items:tt)*] + send fn $method:ident() => $msg:ident; + $($rest:tt)* + ) => { + $crate::actor_api!(@parse [$($vis)*] $trait_name $actor_ref + [$($trait_items)* + fn $method(&self) -> Result<(), $crate::error::ActorError>; + ] + [$($impl_items)* + fn $method(&self) -> Result<(), $crate::error::ActorError> { + self.send($msg) + } + ] + $($rest)* + ); + }; + + // request async fn with params + (@parse [$($vis:tt)*] $trait_name:ident $actor_ref:ty + [$($trait_items:tt)*] + [$($impl_items:tt)*] + request async fn $method:ident($($param:ident : $ptype:ty),+ $(,)?) -> $ret:ty => $msg:ident; + $($rest:tt)* + ) => { + $crate::actor_api!(@parse [$($vis)*] $trait_name $actor_ref + [$($trait_items)* + async fn $method(&self, $($param : $ptype),+) -> Result<$ret, $crate::error::ActorError>; + ] + [$($impl_items)* + async fn $method(&self, $($param : $ptype),+) -> Result<$ret, $crate::error::ActorError> { + self.request($msg { $($param),+ }).await + } + ] + $($rest)* + ); + }; + + // request async fn without params (unit message) + (@parse [$($vis:tt)*] $trait_name:ident $actor_ref:ty + [$($trait_items:tt)*] + [$($impl_items:tt)*] + request async fn $method:ident() -> $ret:ty => $msg:ident; + $($rest:tt)* + ) => { + $crate::actor_api!(@parse [$($vis)*] $trait_name $actor_ref + [$($trait_items)* + async fn $method(&self) -> Result<$ret, $crate::error::ActorError>; + ] + [$($impl_items)* + async fn $method(&self) -> Result<$ret, $crate::error::ActorError> { + self.request($msg).await + } + ] + $($rest)* + ); + }; + + // request fn with params (sync/threads) + (@parse [$($vis:tt)*] $trait_name:ident $actor_ref:ty + [$($trait_items:tt)*] + [$($impl_items:tt)*] + request fn $method:ident($($param:ident : $ptype:ty),+ $(,)?) -> $ret:ty => $msg:ident; + $($rest:tt)* + ) => { + $crate::actor_api!(@parse [$($vis)*] $trait_name $actor_ref + [$($trait_items)* + fn $method(&self, $($param : $ptype),+) -> Result<$ret, $crate::error::ActorError>; + ] + [$($impl_items)* + fn $method(&self, $($param : $ptype),+) -> Result<$ret, $crate::error::ActorError> { + self.request($msg { $($param),+ }) + } + ] + $($rest)* + ); + }; + + // request fn without params (sync/threads, unit message) + (@parse [$($vis:tt)*] $trait_name:ident $actor_ref:ty + [$($trait_items:tt)*] + [$($impl_items:tt)*] + request fn $method:ident() -> $ret:ty => $msg:ident; + $($rest:tt)* + ) => { + $crate::actor_api!(@parse [$($vis)*] $trait_name $actor_ref + [$($trait_items)* + fn $method(&self) -> Result<$ret, $crate::error::ActorError>; + ] + [$($impl_items)* + fn $method(&self) -> Result<$ret, $crate::error::ActorError> { + self.request($msg) + } + ] + $($rest)* + ); + }; +} diff --git a/concurrency/src/registry.rs b/concurrency/src/registry.rs new file mode 100644 index 0000000..f37a5ba --- /dev/null +++ b/concurrency/src/registry.rs @@ -0,0 +1,91 @@ +use std::any::Any; +use std::collections::HashMap; +use std::sync::{OnceLock, RwLock}; + +type Store = RwLock>>; + +fn global_store() -> &'static Store { + static STORE: OnceLock = OnceLock::new(); + STORE.get_or_init(|| RwLock::new(HashMap::new())) +} + +#[derive(Debug, thiserror::Error)] +pub enum RegistryError { + #[error("name '{0}' is already registered")] + AlreadyRegistered(String), +} + +pub fn register(name: &str, value: T) -> Result<(), RegistryError> { + let mut store = global_store().write().unwrap_or_else(|p| p.into_inner()); + if store.contains_key(name) { + return Err(RegistryError::AlreadyRegistered(name.to_string())); + } + store.insert(name.to_string(), Box::new(value)); + Ok(()) +} + +pub fn whereis(name: &str) -> Option { + let store = global_store().read().unwrap_or_else(|p| p.into_inner()); + store.get(name)?.downcast_ref::().cloned() +} + +pub fn unregister(name: &str) { + let mut store = global_store().write().unwrap_or_else(|p| p.into_inner()); + store.remove(name); +} + +pub fn registered() -> Vec { + let store = global_store().read().unwrap_or_else(|p| p.into_inner()); + store.keys().cloned().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + // Use unique names per test to avoid cross-test interference with global state. + + #[test] + fn register_and_whereis() { + register("test_rw_1", 42u64).unwrap(); + let val: Option = whereis("test_rw_1"); + assert_eq!(val, Some(42)); + } + + #[test] + fn whereis_wrong_type_returns_none() { + register("test_wt_1", 42u64).unwrap(); + let val: Option = whereis("test_wt_1"); + assert_eq!(val, None); + } + + #[test] + fn whereis_missing_returns_none() { + let val: Option = whereis("nonexistent_key"); + assert_eq!(val, None); + } + + #[test] + fn duplicate_register_fails() { + register("test_dup_1", 1u32).unwrap(); + let result = register("test_dup_1", 2u32); + assert!(result.is_err()); + } + + #[test] + fn unregister_removes_entry() { + register("test_unreg_1", "hello".to_string()).unwrap(); + unregister("test_unreg_1"); + let val: Option = whereis("test_unreg_1"); + assert_eq!(val, None); + } + + #[test] + fn registered_lists_names() { + register("test_list_a", 1u32).unwrap(); + register("test_list_b", 2u32).unwrap(); + let names = registered(); + assert!(names.contains(&"test_list_a".to_string())); + assert!(names.contains(&"test_list_b".to_string())); + } +} diff --git a/concurrency/src/tasks/actor.rs b/concurrency/src/tasks/actor.rs index 71f8357..c0a90a9 100644 --- a/concurrency/src/tasks/actor.rs +++ b/concurrency/src/tasks/actor.rs @@ -6,7 +6,7 @@ use spawned_rt::{ tasks::{self as rt, mpsc, oneshot, timeout, watch, CancellationToken, JoinHandle}, threads, }; -use std::{fmt::Debug, future::Future, panic::AssertUnwindSafe, pin::Pin, time::Duration}; +use std::{fmt::Debug, future::Future, panic::AssertUnwindSafe, pin::Pin, sync::Arc, time::Duration}; const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); @@ -131,7 +131,7 @@ impl Context { .map_err(|_| ActorError::ActorStopped) } - pub fn request(&self, msg: M) -> Result, ActorError> + pub fn request_raw(&self, msg: M) -> Result, ActorError> where A: Handler, M: Message, @@ -147,12 +147,12 @@ impl Context { Ok(rx) } - pub async fn send_request(&self, msg: M) -> Result + pub async fn request(&self, msg: M) -> Result where A: Handler, M: Message, { - let rx = self.request(msg)?; + let rx = self.request_raw(msg)?; match timeout(DEFAULT_REQUEST_TIMEOUT, rx).await { Ok(Ok(result)) => Ok(result), Ok(Err(_)) => Err(ActorError::ActorStopped), @@ -160,11 +160,58 @@ impl Context { } } + pub fn recipient(&self) -> Recipient + where + A: Handler, + M: Message, + { + Arc::new(self.clone()) + } + pub(crate) fn cancellation_token(&self) -> CancellationToken { self.cancellation_token.clone() } } +// Bridge: Context implements Receiver for any M that A handles +impl Receiver for Context +where + A: Actor + Handler, + M: Message, +{ + fn send(&self, msg: M) -> Result<(), ActorError> { + Context::send(self, msg) + } + + fn request_raw(&self, msg: M) -> Result, ActorError> { + Context::request_raw(self, msg) + } +} + +// --------------------------------------------------------------------------- +// Receiver trait (object-safe) + Recipient alias +// --------------------------------------------------------------------------- + +pub trait Receiver: Send + Sync { + fn send(&self, msg: M) -> Result<(), ActorError>; + fn request_raw(&self, msg: M) -> Result, ActorError>; +} + +pub type Recipient = Arc>; + +pub async fn request( + recipient: &dyn Receiver, + msg: M, + timeout_duration: Duration, +) -> Result { + let rx = recipient.request_raw(msg)?; + match timeout(timeout_duration, rx).await { + Ok(Ok(result)) => Ok(result), + Ok(Err(_)) => Err(ActorError::ActorStopped), + Err(_) => Err(ActorError::RequestTimeout), + } +} + // --------------------------------------------------------------------------- // ActorRef // --------------------------------------------------------------------------- @@ -203,7 +250,7 @@ impl ActorRef { .map_err(|_| ActorError::ActorStopped) } - pub fn request(&self, msg: M) -> Result, ActorError> + pub fn request_raw(&self, msg: M) -> Result, ActorError> where A: Handler, M: Message, @@ -219,15 +266,15 @@ impl ActorRef { Ok(rx) } - pub async fn send_request(&self, msg: M) -> Result + pub async fn request(&self, msg: M) -> Result where A: Handler, M: Message, { - self.send_request_with_timeout(msg, DEFAULT_REQUEST_TIMEOUT).await + self.request_with_timeout(msg, DEFAULT_REQUEST_TIMEOUT).await } - pub async fn send_request_with_timeout( + pub async fn request_with_timeout( &self, msg: M, duration: Duration, @@ -236,7 +283,7 @@ impl ActorRef { A: Handler, M: Message, { - let rx = self.request(msg)?; + let rx = self.request_raw(msg)?; match timeout(duration, rx).await { Ok(Ok(result)) => Ok(result), Ok(Err(_)) => Err(ActorError::ActorStopped), @@ -244,6 +291,14 @@ impl ActorRef { } } + pub fn recipient(&self) -> Recipient + where + A: Handler, + M: Message, + { + Arc::new(self.clone()) + } + pub fn context(&self) -> Context { Context::from_ref(self) } @@ -258,6 +313,21 @@ impl ActorRef { } } +// Bridge: ActorRef implements Receiver for any M that A handles +impl Receiver for ActorRef +where + A: Actor + Handler, + M: Message, +{ + fn send(&self, msg: M) -> Result<(), ActorError> { + ActorRef::send(self, msg) + } + + fn request_raw(&self, msg: M) -> Result, ActorError> { + ActorRef::request_raw(self, msg) + } +} + // --------------------------------------------------------------------------- // Actor startup + main loop // --------------------------------------------------------------------------- @@ -515,19 +585,20 @@ mod tests { runtime.block_on(async move { let counter = Counter { count: 0 }.start(); - let result = counter.send_request(GetCount).await.unwrap(); + let result = counter.request(GetCount).await.unwrap(); assert_eq!(result, 0); - let result = counter.send_request(Increment).await.unwrap(); + let result = counter.request(Increment).await.unwrap(); assert_eq!(result, 1); + // fire-and-forget send counter.send(Increment).unwrap(); rt::sleep(Duration::from_millis(10)).await; - let result = counter.send_request(GetCount).await.unwrap(); + let result = counter.request(GetCount).await.unwrap(); assert_eq!(result, 2); - let final_count = counter.send_request(StopCounter).await.unwrap(); + let final_count = counter.request(StopCounter).await.unwrap(); assert_eq!(final_count, 2); }); } @@ -538,19 +609,19 @@ mod tests { runtime.block_on(async move { let counter = Counter { count: 0 }.start_with_backend(Backend::Blocking); - let result = counter.send_request(GetCount).await.unwrap(); + let result = counter.request(GetCount).await.unwrap(); assert_eq!(result, 0); - let result = counter.send_request(Increment).await.unwrap(); + let result = counter.request(Increment).await.unwrap(); assert_eq!(result, 1); counter.send(Increment).unwrap(); rt::sleep(Duration::from_millis(50)).await; - let result = counter.send_request(GetCount).await.unwrap(); + let result = counter.request(GetCount).await.unwrap(); assert_eq!(result, 2); - let final_count = counter.send_request(StopCounter).await.unwrap(); + let final_count = counter.request(StopCounter).await.unwrap(); assert_eq!(final_count, 2); }); } @@ -561,19 +632,19 @@ mod tests { runtime.block_on(async move { let counter = Counter { count: 0 }.start_with_backend(Backend::Thread); - let result = counter.send_request(GetCount).await.unwrap(); + let result = counter.request(GetCount).await.unwrap(); assert_eq!(result, 0); - let result = counter.send_request(Increment).await.unwrap(); + let result = counter.request(Increment).await.unwrap(); assert_eq!(result, 1); counter.send(Increment).unwrap(); rt::sleep(Duration::from_millis(50)).await; - let result = counter.send_request(GetCount).await.unwrap(); + let result = counter.request(GetCount).await.unwrap(); assert_eq!(result, 2); - let final_count = counter.send_request(StopCounter).await.unwrap(); + let final_count = counter.request(StopCounter).await.unwrap(); assert_eq!(final_count, 2); }); } @@ -586,21 +657,21 @@ mod tests { let blocking_counter = Counter { count: 100 }.start_with_backend(Backend::Blocking); let thread_counter = Counter { count: 200 }.start_with_backend(Backend::Thread); - async_counter.send_request(Increment).await.unwrap(); - blocking_counter.send_request(Increment).await.unwrap(); - thread_counter.send_request(Increment).await.unwrap(); + async_counter.request(Increment).await.unwrap(); + blocking_counter.request(Increment).await.unwrap(); + thread_counter.request(Increment).await.unwrap(); - let async_val = async_counter.send_request(GetCount).await.unwrap(); - let blocking_val = blocking_counter.send_request(GetCount).await.unwrap(); - let thread_val = thread_counter.send_request(GetCount).await.unwrap(); + let async_val = async_counter.request(GetCount).await.unwrap(); + let blocking_val = blocking_counter.request(GetCount).await.unwrap(); + let thread_val = thread_counter.request(GetCount).await.unwrap(); assert_eq!(async_val, 1); assert_eq!(blocking_val, 101); assert_eq!(thread_val, 201); - async_counter.send_request(StopCounter).await.unwrap(); - blocking_counter.send_request(StopCounter).await.unwrap(); - thread_counter.send_request(StopCounter).await.unwrap(); + async_counter.request(StopCounter).await.unwrap(); + blocking_counter.request(StopCounter).await.unwrap(); + thread_counter.request(StopCounter).await.unwrap(); }); } @@ -619,12 +690,29 @@ mod tests { let actor = SlowActor.start(); let result = actor - .send_request_with_timeout(SlowOp, Duration::from_millis(50)) + .request_with_timeout(SlowOp, Duration::from_millis(50)) .await; assert!(matches!(result, Err(ActorError::RequestTimeout))); }); } + #[test] + pub fn recipient_type_erasure() { + let runtime = rt::Runtime::new().unwrap(); + runtime.block_on(async move { + let counter = Counter { count: 42 }.start(); + let recipient: Recipient = counter.recipient(); + + let rx = recipient.request_raw(GetCount).unwrap(); + let result = rx.await.unwrap(); + assert_eq!(result, 42); + + // Also test request helper + let result = request(&*recipient, GetCount, Duration::from_secs(5)).await.unwrap(); + assert_eq!(result, 42); + }); + } + // --- SlowShutdownActor for join tests --- struct SlowShutdownActor; @@ -760,9 +848,9 @@ mod tests { let goodboy = WellBehavedTask { count: 0 }.start(); goodboy.send(IncrementWell).unwrap(); rt::sleep(Duration::from_secs(1)).await; - let count = goodboy.send_request(GetCount).await.unwrap(); + let count = goodboy.request(GetCount).await.unwrap(); assert_ne!(count, 10); - goodboy.send_request(StopCounter).await.unwrap(); + goodboy.request(StopCounter).await.unwrap(); }); } @@ -775,9 +863,9 @@ mod tests { let goodboy = WellBehavedTask { count: 0 }.start(); goodboy.send(IncrementWell).unwrap(); rt::sleep(Duration::from_secs(1)).await; - let count = goodboy.send_request(GetCount).await.unwrap(); + let count = goodboy.request(GetCount).await.unwrap(); assert_eq!(count, 10); - goodboy.send_request(StopCounter).await.unwrap(); + goodboy.request(StopCounter).await.unwrap(); }); } @@ -790,9 +878,9 @@ mod tests { let goodboy = WellBehavedTask { count: 0 }.start(); goodboy.send(IncrementWell).unwrap(); rt::sleep(Duration::from_secs(1)).await; - let count = goodboy.send_request(GetCount).await.unwrap(); + let count = goodboy.request(GetCount).await.unwrap(); assert_eq!(count, 10); - goodboy.send_request(StopCounter).await.unwrap(); + goodboy.request(StopCounter).await.unwrap(); }); } } diff --git a/concurrency/src/tasks/mod.rs b/concurrency/src/tasks/mod.rs index abfa512..2dc766f 100644 --- a/concurrency/src/tasks/mod.rs +++ b/concurrency/src/tasks/mod.rs @@ -9,7 +9,8 @@ mod stream_tests; mod timer_tests; pub use actor::{ - send_message_on, Actor, ActorRef, ActorStart, Backend, Context, Handler, + request, send_message_on, Actor, ActorRef, ActorStart, Backend, Context, Handler, Receiver, + Recipient, }; pub use process::{send, Process, ProcessInfo}; pub use stream::spawn_listener; diff --git a/concurrency/src/tasks/stream_tests.rs b/concurrency/src/tasks/stream_tests.rs index 69c5a6f..75d1000 100644 --- a/concurrency/src/tasks/stream_tests.rs +++ b/concurrency/src/tasks/stream_tests.rs @@ -74,7 +74,7 @@ pub fn test_sum_numbers_from_stream() { rt::sleep(Duration::from_secs(1)).await; - let val = summatory.send_request(GetValue).await.unwrap(); + let val = summatory.request(GetValue).await.unwrap(); assert_eq!(val, 15); }) } @@ -101,7 +101,7 @@ pub fn test_sum_numbers_from_channel() { rt::sleep(Duration::from_secs(1)).await; - let val = summatory.send_request(GetValue).await.unwrap(); + let val = summatory.request(GetValue).await.unwrap(); assert_eq!(val, 15); }) } @@ -128,7 +128,7 @@ pub fn test_sum_numbers_from_broadcast_channel() { rt::sleep(Duration::from_secs(1)).await; - let val = summatory.send_request(GetValue).await.unwrap(); + let val = summatory.request(GetValue).await.unwrap(); assert_eq!(val, 15); }) } @@ -165,14 +165,14 @@ pub fn test_stream_cancellation() { ); rt::sleep(Duration::from_millis(READ_TIME)).await; - let val = summatory.send_request(GetValue).await.unwrap(); + let val = summatory.request(GetValue).await.unwrap(); assert!((1..=15).contains(&val)); assert!(listener_handle.await.is_ok()); rt::sleep(Duration::from_millis(10)).await; - assert!(summatory.send_request(GetValue).await.is_err()); + assert!(summatory.request(GetValue).await.is_err()); }) } @@ -194,7 +194,7 @@ pub fn test_halting_on_stream_error() { rt::sleep(Duration::from_secs(1)).await; - let result = summatory.send_request(GetValue).await; + let result = summatory.request(GetValue).await; assert!(result.is_err()); }) } @@ -217,7 +217,7 @@ pub fn test_skipping_on_stream_error() { rt::sleep(Duration::from_secs(1)).await; - let val = summatory.send_request(GetValue).await.unwrap(); + let val = summatory.request(GetValue).await.unwrap(); assert_eq!(val, 15); }) } diff --git a/concurrency/src/tasks/timer_tests.rs b/concurrency/src/tasks/timer_tests.rs index 205d5ba..a1ddff7 100644 --- a/concurrency/src/tasks/timer_tests.rs +++ b/concurrency/src/tasks/timer_tests.rs @@ -72,14 +72,14 @@ pub fn test_send_interval_and_cancellation() { rt::sleep(Duration::from_secs(1)).await; - let count = repeater.send_request(GetRepCount).await.unwrap(); + let count = repeater.request(GetRepCount).await.unwrap(); assert_eq!(9, count); repeater.send(StopTimer).unwrap(); rt::sleep(Duration::from_secs(1)).await; - let count2 = repeater.send_request(GetRepCount).await.unwrap(); + let count2 = repeater.request(GetRepCount).await.unwrap(); assert_eq!(9, count2); }); } @@ -140,7 +140,7 @@ pub fn test_send_after_and_cancellation() { rt::sleep(Duration::from_millis(200)).await; - let count = repeater.send_request(GetDelCount).await.unwrap(); + let count = repeater.request(GetDelCount).await.unwrap(); assert_eq!(1, count); let ctx = Context::from_ref(&repeater); @@ -154,7 +154,7 @@ pub fn test_send_after_and_cancellation() { rt::sleep(Duration::from_millis(200)).await; - let count2 = repeater.send_request(GetDelCount).await.unwrap(); + let count2 = repeater.request(GetDelCount).await.unwrap(); assert_eq!(1, count2); }); } @@ -174,7 +174,7 @@ pub fn test_send_after_gen_server_teardown() { rt::sleep(Duration::from_millis(200)).await; - let count = repeater.send_request(GetDelCount).await.unwrap(); + let count = repeater.request(GetDelCount).await.unwrap(); assert_eq!(1, count); let ctx = Context::from_ref(&repeater); @@ -184,7 +184,7 @@ pub fn test_send_after_gen_server_teardown() { Inc, ); - let count2 = repeater.send_request(StopDelayed).await.unwrap(); + let count2 = repeater.request(StopDelayed).await.unwrap(); rt::sleep(Duration::from_millis(200)).await; diff --git a/concurrency/src/threads/actor.rs b/concurrency/src/threads/actor.rs index a851d4e..f7e7a53 100644 --- a/concurrency/src/threads/actor.rs +++ b/concurrency/src/threads/actor.rs @@ -1,14 +1,16 @@ -use crate::error::ActorError; -use crate::message::Message; use spawned_rt::threads::{ self as rt, mpsc, oneshot, oneshot::RecvTimeoutError, CancellationToken, }; use std::{ + fmt::Debug, panic::{catch_unwind, AssertUnwindSafe}, sync::{Arc, Condvar, Mutex}, time::Duration, }; +use crate::error::ActorError; +use crate::message::Message; + const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); // --------------------------------------------------------------------------- @@ -29,7 +31,7 @@ pub trait Handler: Actor { } // --------------------------------------------------------------------------- -// Envelope (type-erasure on the actor side) +// Envelope (type-erasure) // --------------------------------------------------------------------------- trait Envelope: Send { @@ -72,7 +74,7 @@ impl Clone for Context { } } -impl std::fmt::Debug for Context { +impl Debug for Context { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Context").finish_non_exhaustive() } @@ -101,7 +103,7 @@ impl Context { .map_err(|_| ActorError::ActorStopped) } - pub fn request(&self, msg: M) -> Result, ActorError> + pub fn request_raw(&self, msg: M) -> Result, ActorError> where A: Handler, M: Message, @@ -117,15 +119,15 @@ impl Context { Ok(rx) } - pub fn send_request(&self, msg: M) -> Result + pub fn request(&self, msg: M) -> Result where A: Handler, M: Message, { - self.send_request_with_timeout(msg, DEFAULT_REQUEST_TIMEOUT) + self.request_with_timeout(msg, DEFAULT_REQUEST_TIMEOUT) } - pub fn send_request_with_timeout( + pub fn request_with_timeout( &self, msg: M, duration: Duration, @@ -134,7 +136,7 @@ impl Context { A: Handler, M: Message, { - let rx = self.request(msg)?; + let rx = self.request_raw(msg)?; match rx.recv_timeout(duration) { Ok(result) => Ok(result), Err(RecvTimeoutError::Timeout) => Err(ActorError::RequestTimeout), @@ -142,11 +144,58 @@ impl Context { } } + pub fn recipient(&self) -> Recipient + where + A: Handler, + M: Message, + { + Arc::new(self.clone()) + } + pub(crate) fn cancellation_token(&self) -> CancellationToken { self.cancellation_token.clone() } } +// Bridge: Context implements Receiver for any M that A handles +impl Receiver for Context +where + A: Actor + Handler, + M: Message, +{ + fn send(&self, msg: M) -> Result<(), ActorError> { + Context::send(self, msg) + } + + fn request_raw(&self, msg: M) -> Result, ActorError> { + Context::request_raw(self, msg) + } +} + +// --------------------------------------------------------------------------- +// Receiver trait (object-safe) + Recipient alias +// --------------------------------------------------------------------------- + +pub trait Receiver: Send + Sync { + fn send(&self, msg: M) -> Result<(), ActorError>; + fn request_raw(&self, msg: M) -> Result, ActorError>; +} + +pub type Recipient = Arc>; + +pub fn request( + recipient: &dyn Receiver, + msg: M, + timeout: Duration, +) -> Result { + let rx = recipient.request_raw(msg)?; + match rx.recv_timeout(timeout) { + Ok(result) => Ok(result), + Err(RecvTimeoutError::Timeout) => Err(ActorError::RequestTimeout), + Err(RecvTimeoutError::Disconnected) => Err(ActorError::ActorStopped), + } +} + // --------------------------------------------------------------------------- // ActorRef // --------------------------------------------------------------------------- @@ -168,7 +217,7 @@ pub struct ActorRef { completion: Arc<(Mutex, Condvar)>, } -impl std::fmt::Debug for ActorRef { +impl Debug for ActorRef { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ActorRef").finish_non_exhaustive() } @@ -196,7 +245,7 @@ impl ActorRef { .map_err(|_| ActorError::ActorStopped) } - pub fn request(&self, msg: M) -> Result, ActorError> + pub fn request_raw(&self, msg: M) -> Result, ActorError> where A: Handler, M: Message, @@ -212,15 +261,15 @@ impl ActorRef { Ok(rx) } - pub fn send_request(&self, msg: M) -> Result + pub fn request(&self, msg: M) -> Result where A: Handler, M: Message, { - self.send_request_with_timeout(msg, DEFAULT_REQUEST_TIMEOUT) + self.request_with_timeout(msg, DEFAULT_REQUEST_TIMEOUT) } - pub fn send_request_with_timeout( + pub fn request_with_timeout( &self, msg: M, duration: Duration, @@ -229,7 +278,7 @@ impl ActorRef { A: Handler, M: Message, { - let rx = self.request(msg)?; + let rx = self.request_raw(msg)?; match rx.recv_timeout(duration) { Ok(result) => Ok(result), Err(RecvTimeoutError::Timeout) => Err(ActorError::RequestTimeout), @@ -237,6 +286,14 @@ impl ActorRef { } } + pub fn recipient(&self) -> Recipient + where + A: Handler, + M: Message, + { + Arc::new(self.clone()) + } + pub fn context(&self) -> Context { Context::from_ref(self) } @@ -250,6 +307,21 @@ impl ActorRef { } } +// Bridge: ActorRef implements Receiver for any M that A handles +impl Receiver for ActorRef +where + A: Actor + Handler, + M: Message, +{ + fn send(&self, msg: M) -> Result<(), ActorError> { + ActorRef::send(self, msg) + } + + fn request_raw(&self, msg: M) -> Result, ActorError> { + ActorRef::request_raw(self, msg) + } +} + // --------------------------------------------------------------------------- // Actor startup + main loop // --------------------------------------------------------------------------- diff --git a/concurrency/src/threads/mod.rs b/concurrency/src/threads/mod.rs index d1750ca..b9c96de 100644 --- a/concurrency/src/threads/mod.rs +++ b/concurrency/src/threads/mod.rs @@ -7,7 +7,7 @@ mod time; mod timer_tests; pub use actor::{ - send_message_on, Actor, ActorRef, ActorStart, Context, Handler, + request, send_message_on, Actor, ActorRef, ActorStart, Context, Handler, Receiver, Recipient, }; pub use process::{send, Process, ProcessInfo}; pub use stream::spawn_listener; diff --git a/concurrency/src/threads/timer_tests.rs b/concurrency/src/threads/timer_tests.rs index 6ba13c9..0339d02 100644 --- a/concurrency/src/threads/timer_tests.rs +++ b/concurrency/src/threads/timer_tests.rs @@ -70,14 +70,14 @@ pub fn test_send_interval_and_cancellation() { rt::sleep(Duration::from_secs(1)); - let count = repeater.send_request(GetRepCount).unwrap(); + let count = repeater.request(GetRepCount).unwrap(); assert_eq!(9, count); repeater.send(StopTimer).unwrap(); rt::sleep(Duration::from_secs(1)); - let count2 = repeater.send_request(GetRepCount).unwrap(); + let count2 = repeater.request(GetRepCount).unwrap(); assert_eq!(9, count2); } @@ -131,7 +131,7 @@ pub fn test_send_after_and_cancellation() { rt::sleep(Duration::from_millis(200)); - let count = actor.send_request(GetDelCount).unwrap(); + let count = actor.request(GetDelCount).unwrap(); assert_eq!(1, count); let ctx = Context::from_ref(&actor); @@ -141,7 +141,7 @@ pub fn test_send_after_and_cancellation() { rt::sleep(Duration::from_millis(200)); - let count2 = actor.send_request(GetDelCount).unwrap(); + let count2 = actor.request(GetDelCount).unwrap(); assert_eq!(1, count2); } @@ -154,13 +154,13 @@ pub fn test_send_after_actor_shutdown() { rt::sleep(Duration::from_millis(200)); - let count = actor.send_request(GetDelCount).unwrap(); + let count = actor.request(GetDelCount).unwrap(); assert_eq!(1, count); let ctx = Context::from_ref(&actor); let _ = send_after(Duration::from_millis(100), ctx, Inc); - let count2 = actor.send_request(StopDelayed).unwrap(); + let count2 = actor.request(StopDelayed).unwrap(); rt::sleep(Duration::from_millis(200)); diff --git a/examples/bank/src/main.rs b/examples/bank/src/main.rs index 5ff3958..7572645 100644 --- a/examples/bank/src/main.rs +++ b/examples/bank/src/main.rs @@ -16,7 +16,7 @@ fn main() { let bank = Bank::new().start(); // Testing initial balance for "main" account - let result = bank.send_request(Withdraw { who: "main".into(), amount: 15 }).await.unwrap(); + let result = bank.request(Withdraw { who: "main".into(), amount: 15 }).await.unwrap(); tracing::info!("Withdraw result {result:?}"); assert_eq!( result, @@ -29,17 +29,17 @@ fn main() { let joe = "Joe".to_string(); // Error on deposit for a non-existent account - let result = bank.send_request(Deposit { who: joe.clone(), amount: 10 }).await.unwrap(); + let result = bank.request(Deposit { who: joe.clone(), amount: 10 }).await.unwrap(); tracing::info!("Deposit result {result:?}"); assert_eq!(result, Err(BankError::NotACustomer { who: joe.clone() })); // Account creation - let result = bank.send_request(NewAccount { who: joe.clone() }).await.unwrap(); + let result = bank.request(NewAccount { who: joe.clone() }).await.unwrap(); tracing::info!("New account result {result:?}"); assert_eq!(result, Ok(BankOutMessage::Welcome { who: joe.clone() })); // Deposit - let result = bank.send_request(Deposit { who: joe.clone(), amount: 10 }).await.unwrap(); + let result = bank.request(Deposit { who: joe.clone(), amount: 10 }).await.unwrap(); tracing::info!("Deposit result {result:?}"); assert_eq!( result, @@ -47,7 +47,7 @@ fn main() { ); // Deposit - let result = bank.send_request(Deposit { who: joe.clone(), amount: 30 }).await.unwrap(); + let result = bank.request(Deposit { who: joe.clone(), amount: 30 }).await.unwrap(); tracing::info!("Deposit result {result:?}"); assert_eq!( result, @@ -55,7 +55,7 @@ fn main() { ); // Withdrawal - let result = bank.send_request(Withdraw { who: joe.clone(), amount: 15 }).await.unwrap(); + let result = bank.request(Withdraw { who: joe.clone(), amount: 15 }).await.unwrap(); tracing::info!("Withdraw result {result:?}"); assert_eq!( result, @@ -63,7 +63,7 @@ fn main() { ); // Withdrawal with not enough balance - let result = bank.send_request(Withdraw { who: joe.clone(), amount: 45 }).await.unwrap(); + let result = bank.request(Withdraw { who: joe.clone(), amount: 45 }).await.unwrap(); tracing::info!("Withdraw result {result:?}"); assert_eq!( result, @@ -71,7 +71,7 @@ fn main() { ); // Full withdrawal - let result = bank.send_request(Withdraw { who: joe.clone(), amount: 25 }).await.unwrap(); + let result = bank.request(Withdraw { who: joe.clone(), amount: 25 }).await.unwrap(); tracing::info!("Withdraw result {result:?}"); assert_eq!( result, @@ -79,7 +79,7 @@ fn main() { ); // Stopping the bank - let result = bank.send_request(Stop).await.unwrap(); + let result = bank.request(Stop).await.unwrap(); tracing::info!("Stop result {result:?}"); assert_eq!(result, Ok(BankOutMessage::Stopped)); }) diff --git a/examples/bank_threads/src/main.rs b/examples/bank_threads/src/main.rs index 5878516..4f65316 100644 --- a/examples/bank_threads/src/main.rs +++ b/examples/bank_threads/src/main.rs @@ -16,7 +16,7 @@ fn main() { let bank = Bank::new().start(); // Testing initial balance for "main" account - let result = bank.send_request(Withdraw { who: "main".into(), amount: 15 }).unwrap(); + let result = bank.request(Withdraw { who: "main".into(), amount: 15 }).unwrap(); tracing::info!("Withdraw result {result:?}"); assert_eq!( result, @@ -29,17 +29,17 @@ fn main() { let joe = "Joe".to_string(); // Error on deposit for a non-existent account - let result = bank.send_request(Deposit { who: joe.clone(), amount: 10 }).unwrap(); + let result = bank.request(Deposit { who: joe.clone(), amount: 10 }).unwrap(); tracing::info!("Deposit result {result:?}"); assert_eq!(result, Err(BankError::NotACustomer { who: joe.clone() })); // Account creation - let result = bank.send_request(NewAccount { who: joe.clone() }).unwrap(); + let result = bank.request(NewAccount { who: joe.clone() }).unwrap(); tracing::info!("New account result {result:?}"); assert_eq!(result, Ok(BankOutMessage::Welcome { who: joe.clone() })); // Deposit - let result = bank.send_request(Deposit { who: joe.clone(), amount: 10 }).unwrap(); + let result = bank.request(Deposit { who: joe.clone(), amount: 10 }).unwrap(); tracing::info!("Deposit result {result:?}"); assert_eq!( result, @@ -47,7 +47,7 @@ fn main() { ); // Deposit - let result = bank.send_request(Deposit { who: joe.clone(), amount: 30 }).unwrap(); + let result = bank.request(Deposit { who: joe.clone(), amount: 30 }).unwrap(); tracing::info!("Deposit result {result:?}"); assert_eq!( result, @@ -55,7 +55,7 @@ fn main() { ); // Withdrawal - let result = bank.send_request(Withdraw { who: joe.clone(), amount: 15 }).unwrap(); + let result = bank.request(Withdraw { who: joe.clone(), amount: 15 }).unwrap(); tracing::info!("Withdraw result {result:?}"); assert_eq!( result, @@ -63,7 +63,7 @@ fn main() { ); // Withdrawal with not enough balance - let result = bank.send_request(Withdraw { who: joe.clone(), amount: 45 }).unwrap(); + let result = bank.request(Withdraw { who: joe.clone(), amount: 45 }).unwrap(); tracing::info!("Withdraw result {result:?}"); assert_eq!( result, @@ -71,7 +71,7 @@ fn main() { ); // Full withdrawal - let result = bank.send_request(Withdraw { who: joe.clone(), amount: 25 }).unwrap(); + let result = bank.request(Withdraw { who: joe.clone(), amount: 25 }).unwrap(); tracing::info!("Withdraw result {result:?}"); assert_eq!( result, @@ -79,7 +79,7 @@ fn main() { ); // Stopping the bank - let result = bank.send_request(Stop).unwrap(); + let result = bank.request(Stop).unwrap(); tracing::info!("Stop result {result:?}"); assert_eq!(result, Ok(BankOutMessage::Stopped)); }) diff --git a/examples/blocking_genserver/main.rs b/examples/blocking_genserver/main.rs index 99694d4..8d2e755 100644 --- a/examples/blocking_genserver/main.rs +++ b/examples/blocking_genserver/main.rs @@ -83,11 +83,11 @@ pub fn main() { let goodboy = WellBehavedTask { count: 0 }.start(); let _ = goodboy.send(Tick); rt::sleep(Duration::from_secs(1)).await; - let count = goodboy.send_request(GetCount).await.unwrap(); + let count = goodboy.request(GetCount).await.unwrap(); assert!(count == 10); - goodboy.send_request(StopActor).await.unwrap(); + goodboy.request(StopActor).await.unwrap(); exit(0); }) } diff --git a/examples/chat_room/src/main.rs b/examples/chat_room/src/main.rs index 7b565f4..41a6637 100644 --- a/examples/chat_room/src/main.rs +++ b/examples/chat_room/src/main.rs @@ -52,14 +52,14 @@ fn main() { // Alice speaks: main -> alice (SayToRoom) -> room (Say) -> bob (Deliver) alice - .send_request(SayToRoom { + .request(SayToRoom { text: "Hello everyone!".into(), }) .await .unwrap(); // Bob replies: main -> bob (SayToRoom) -> room (Say) -> alice (Deliver) - bob.send_request(SayToRoom { + bob.request(SayToRoom { text: "Hey Alice!".into(), }) .await diff --git a/examples/chat_room_threads/Cargo.toml b/examples/chat_room_threads/Cargo.toml new file mode 100644 index 0000000..b04a3b5 --- /dev/null +++ b/examples/chat_room_threads/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "chat_room_threads" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +spawned-rt = { workspace = true } +spawned-concurrency = { workspace = true } +spawned-macros = { workspace = true } +tracing = { workspace = true } diff --git a/examples/chat_room_threads/src/main.rs b/examples/chat_room_threads/src/main.rs new file mode 100644 index 0000000..b408c8e --- /dev/null +++ b/examples/chat_room_threads/src/main.rs @@ -0,0 +1,39 @@ +mod room; +mod user; + +use std::thread; +use std::time::Duration; + +use room::{ChatRoom, ChatRoomApi}; +use spawned_concurrency::threads::ActorStart; +use spawned_rt::threads as rt; +use user::{User, UserApi}; + +fn main() { + rt::run(|| { + let room = ChatRoom::new().start(); + + let alice = User::new("Alice".into()).start(); + let bob = User::new("Bob".into()).start(); + + // Register users in the room (send — fire-and-forget) + alice.join_room(room.clone()).unwrap(); + bob.join_room(room.clone()).unwrap(); + + // Let join messages propagate (user -> room) + thread::sleep(Duration::from_millis(10)); + + // Query members (request — blocking) + let members = room.members().unwrap(); + tracing::info!("Members in room: {:?}", members); + + // Chat (send — fire-and-forget) + alice.say("Hello everyone!".into()).unwrap(); + bob.say("Hi Alice!".into()).unwrap(); + + // Give time for messages to propagate + thread::sleep(Duration::from_millis(100)); + + tracing::info!("Chat room demo complete"); + }); +} diff --git a/examples/chat_room_threads/src/room.rs b/examples/chat_room_threads/src/room.rs new file mode 100644 index 0000000..7108abf --- /dev/null +++ b/examples/chat_room_threads/src/room.rs @@ -0,0 +1,67 @@ +use spawned_concurrency::actor_api; +use spawned_concurrency::request_messages; +use spawned_concurrency::send_messages; +use spawned_concurrency::threads::{Actor, ActorRef, Context, Handler, Recipient}; +use spawned_macros::actor; + +// -- Messages -- + +send_messages! { + Say { from: String, text: String }; + Deliver { from: String, text: String }; + Join { name: String, inbox: Recipient } +} + +request_messages! { + Members -> Vec +} + +// -- API -- + +actor_api! { + pub ChatRoomApi for ActorRef { + send fn say(from: String, text: String) => Say; + send fn add_member(name: String, inbox: Recipient) => Join; + request fn members() -> Vec => Members; + } +} + +// -- Actor -- + +pub struct ChatRoom { + members: Vec<(String, Recipient)>, +} + +impl Actor for ChatRoom {} + +#[actor] +impl ChatRoom { + pub fn new() -> Self { + Self { + members: Vec::new(), + } + } + + #[send_handler] + fn handle_say(&mut self, msg: Say, _ctx: &Context) { + for (name, inbox) in &self.members { + if *name != msg.from { + let _ = inbox.send(Deliver { + from: msg.from.clone(), + text: msg.text.clone(), + }); + } + } + } + + #[send_handler] + fn handle_join(&mut self, msg: Join, _ctx: &Context) { + tracing::info!("[room] {} joined", msg.name); + self.members.push((msg.name, msg.inbox)); + } + + #[request_handler] + fn handle_members(&mut self, _msg: Members, _ctx: &Context) -> Vec { + self.members.iter().map(|(name, _)| name.clone()).collect() + } +} diff --git a/examples/chat_room_threads/src/user.rs b/examples/chat_room_threads/src/user.rs new file mode 100644 index 0000000..4dfe6a5 --- /dev/null +++ b/examples/chat_room_threads/src/user.rs @@ -0,0 +1,56 @@ +use spawned_concurrency::actor_api; +use spawned_concurrency::send_messages; +use spawned_concurrency::threads::{Actor, ActorRef, Context, Handler}; +use spawned_macros::actor; + +use crate::room::{ChatRoom, ChatRoomApi, Deliver}; + +// -- Messages -- + +send_messages! { + SayToRoom { text: String }; + JoinRoom { room: ActorRef } +} + +// -- API -- + +actor_api! { + pub UserApi for ActorRef { + send fn say(text: String) => SayToRoom; + send fn join_room(room: ActorRef) => JoinRoom; + } +} + +// -- Actor -- + +pub struct User { + pub name: String, + room: Option>, +} + +impl Actor for User {} + +#[actor] +impl User { + pub fn new(name: String) -> Self { + Self { name, room: None } + } + + #[send_handler] + fn handle_say_to_room(&mut self, msg: SayToRoom, _ctx: &Context) { + if let Some(ref room) = self.room { + let _ = room.say(self.name.clone(), msg.text); + } + } + + #[send_handler] + fn handle_join_room(&mut self, msg: JoinRoom, ctx: &Context) { + let _ = msg.room.add_member(self.name.clone(), ctx.recipient::()); + self.room = Some(msg.room); + } + + #[send_handler] + fn handle_deliver(&mut self, msg: Deliver, _ctx: &Context) { + tracing::info!("[{}] got: {} says '{}'", self.name, msg.from, msg.text); + } +} diff --git a/examples/name_server/src/main.rs b/examples/name_server/src/main.rs index 4daca79..7da4ab8 100644 --- a/examples/name_server/src/main.rs +++ b/examples/name_server/src/main.rs @@ -15,16 +15,16 @@ fn main() { rt::run(async { let ns = NameServer::new().start(); - ns.send_request(Add { key: "Joe".into(), value: "At Home".into() }).await.unwrap(); + ns.request(Add { key: "Joe".into(), value: "At Home".into() }).await.unwrap(); - let result = ns.send_request(Find { key: "Joe".into() }).await.unwrap(); + let result = ns.request(Find { key: "Joe".into() }).await.unwrap(); tracing::info!("Retrieving value result: {result:?}"); assert_eq!( result, FindResult::Found { value: "At Home".to_string() } ); - let result = ns.send_request(Find { key: "Bob".into() }).await.unwrap(); + let result = ns.request(Find { key: "Bob".into() }).await.unwrap(); tracing::info!("Retrieving value result: {result:?}"); assert_eq!(result, FindResult::NotFound); }) diff --git a/examples/ping_pong_threads/src/consumer.rs b/examples/ping_pong_threads/src/consumer.rs index 44777c4..3f24494 100644 --- a/examples/ping_pong_threads/src/consumer.rs +++ b/examples/ping_pong_threads/src/consumer.rs @@ -1,26 +1,17 @@ -use spawned_concurrency::threads::{self as concurrency, Process, ProcessInfo}; -use spawned_rt::threads::mpsc::Sender; +use spawned_concurrency::threads::{Actor, Context, Handler}; -use crate::messages::Message; +use crate::messages::Ping; +use crate::protocols::PongInbox; -pub struct Consumer {} - -impl Consumer { - pub fn spawn_new() -> ProcessInfo { - Self {}.spawn() - } +pub struct Consumer { + pub producer: PongInbox, } -impl Process for Consumer { - fn handle(&mut self, message: Message, _tx: &Sender) -> Message { - tracing::info!("Consumer received {message:?}"); - match message.clone() { - Message::Ping { from } => { - tracing::info!("Consumer sent Pong"); - concurrency::send(&from, Message::Pong); - } - Message::Pong => (), - }; - message +impl Actor for Consumer {} + +impl Handler for Consumer { + fn handle(&mut self, _msg: Ping, _ctx: &Context) { + tracing::info!("Consumer received Ping, sending Pong"); + let _ = self.producer.pong(); } } diff --git a/examples/ping_pong_threads/src/main.rs b/examples/ping_pong_threads/src/main.rs index 73fc4d6..8afc8ee 100644 --- a/examples/ping_pong_threads/src/main.rs +++ b/examples/ping_pong_threads/src/main.rs @@ -1,55 +1,36 @@ -//! Simple example to test concurrency/Process abstraction -//! -//! Based on an Erlang example: -//! -module(ping). -//! -//! -export([ping/1, pong/0, spawn_consumer/0, spawn_producer/1, start/0]). -//! -//! ping(Pid) -> -//! Pid ! {ping, self()}, -//! receive -//! pong -> -//! io:format("Received pong!!!~n"), -//! ping(Pid) -//! end. -//! -//! pong() -> -//! receive -//! {ping, Pid} -> -//! io:format("Received ping!!~n"), -//! Pid ! pong, -//! pong(); -//! die -> -//! ok -//! end. -//! -//! spawn_consumer() -> -//! spawn(ping, pong, []). -//! -//! spawn_producer(Pid) -> -//! spawn(ping, ping, [Pid]). -//! -//! start() -> -//! Pid = spawn_consumer(), -//! spawn_producer(Pid). - mod consumer; mod messages; mod producer; +mod protocols; use std::{thread, time::Duration}; use consumer::Consumer; -use producer::Producer; +use producer::{Producer, SetConsumer}; +use spawned_concurrency::threads::ActorStart as _; use spawned_rt::threads as rt; +use std::sync::Arc; fn main() { rt::run(|| { - let consumer = Consumer::spawn_new(); + // Start the producer first + let producer = Producer { consumer: None }.start(); + + // Start the consumer with an Arc pointing to the producer + let consumer = Consumer { + producer: Arc::new(producer.clone()), + } + .start(); + + // Wire up the producer with the consumer's Arc + producer + .send(SetConsumer(Arc::new(consumer.clone()))) + .unwrap(); - Producer::spawn_new(consumer.sender()); + // Kick off the ping-pong loop + consumer.send(messages::Ping).unwrap(); - // giving it some time before ending + // Let them ping-pong for a bit thread::sleep(Duration::from_millis(1)); }) } diff --git a/examples/ping_pong_threads/src/messages.rs b/examples/ping_pong_threads/src/messages.rs index e8a07ef..2740ff9 100644 --- a/examples/ping_pong_threads/src/messages.rs +++ b/examples/ping_pong_threads/src/messages.rs @@ -1,7 +1,6 @@ -use spawned_rt::threads::mpsc::Sender; +use spawned_concurrency::send_messages; -#[derive(Debug, Clone)] -pub enum Message { - Ping { from: Sender }, - Pong, +send_messages! { + Ping; + Pong } diff --git a/examples/ping_pong_threads/src/producer.rs b/examples/ping_pong_threads/src/producer.rs index 01dd564..b5190f9 100644 --- a/examples/ping_pong_threads/src/producer.rs +++ b/examples/ping_pong_threads/src/producer.rs @@ -1,32 +1,36 @@ -use spawned_concurrency::threads::{self as concurrency, Process, ProcessInfo}; -use spawned_rt::threads::mpsc::Sender; +use spawned_concurrency::message::Message; +use spawned_concurrency::threads::{Actor, Context, Handler}; -use crate::messages::Message; +use crate::messages::Pong; +use crate::protocols::PingInbox; -pub struct Producer { - consumer: Sender, +pub struct SetConsumer(pub PingInbox); +impl Message for SetConsumer { + type Result = (); } - -impl Producer { - pub fn spawn_new(consumer: Sender) -> ProcessInfo { - Self { consumer }.spawn() +impl std::fmt::Debug for SetConsumer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("SetConsumer").finish() } +} - fn send_ping(&self, tx: &Sender, consumer: &Sender) { - let message = Message::Ping { from: tx.clone() }; - tracing::info!("Producer sent Ping"); - concurrency::send(consumer, message); - } +pub struct Producer { + pub consumer: Option, } -impl Process for Producer { - fn init(&mut self, tx: &Sender) { - self.send_ping(tx, &self.consumer); +impl Actor for Producer {} + +impl Handler for Producer { + fn handle(&mut self, msg: SetConsumer, _ctx: &Context) { + self.consumer = Some(msg.0); } +} - fn handle(&mut self, message: Message, tx: &Sender) -> Message { - tracing::info!("Producer received {message:?}"); - self.send_ping(tx, &self.consumer); - message +impl Handler for Producer { + fn handle(&mut self, _msg: Pong, _ctx: &Context) { + tracing::info!("Producer received Pong, sending Ping"); + if let Some(consumer) = &self.consumer { + let _ = consumer.ping(); + } } } diff --git a/examples/ping_pong_threads/src/protocols.rs b/examples/ping_pong_threads/src/protocols.rs new file mode 100644 index 0000000..99c5275 --- /dev/null +++ b/examples/ping_pong_threads/src/protocols.rs @@ -0,0 +1,40 @@ +use spawned_concurrency::error::ActorError; +use spawned_concurrency::threads::{ActorRef, Handler}; +use std::sync::Arc; + +use crate::consumer::Consumer; +use crate::messages::{Ping, Pong}; +use crate::producer::Producer; + +// --- Protocol traits: cross-actor contracts --- + +pub trait PingReceiver: Send + Sync { + fn ping(&self) -> Result<(), ActorError>; +} + +pub trait PongReceiver: Send + Sync { + fn pong(&self) -> Result<(), ActorError>; +} + +// --- Bridge impls --- + +impl PingReceiver for ActorRef +where + Consumer: Handler, +{ + fn ping(&self) -> Result<(), ActorError> { + self.send(Ping) + } +} + +impl PongReceiver for ActorRef +where + Producer: Handler, +{ + fn pong(&self) -> Result<(), ActorError> { + self.send(Pong) + } +} + +pub type PingInbox = Arc; +pub type PongInbox = Arc; diff --git a/examples/service_discovery/Cargo.toml b/examples/service_discovery/Cargo.toml new file mode 100644 index 0000000..39860fe --- /dev/null +++ b/examples/service_discovery/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "service_discovery" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +spawned-rt = { workspace = true } +spawned-concurrency = { workspace = true } +spawned-macros = { workspace = true } +tracing = { workspace = true } diff --git a/examples/service_discovery/src/main.rs b/examples/service_discovery/src/main.rs new file mode 100644 index 0000000..7b282d4 --- /dev/null +++ b/examples/service_discovery/src/main.rs @@ -0,0 +1,102 @@ +use std::collections::HashMap; +use std::time::Duration; + +use spawned_concurrency::messages; +use spawned_concurrency::registry; +use spawned_concurrency::tasks::{Actor, ActorStart, Context, Handler, Recipient, request}; +use spawned_macros::actor; +use spawned_rt::tasks as rt; + +// --- Messages --- + +messages! { + Register { name: String, address: String } -> (); + Lookup { name: String } -> Option; + ListAll -> Vec<(String, String)> +} + +// --- ServiceRegistry actor --- + +struct ServiceRegistry { + services: HashMap, +} + +impl ServiceRegistry { + fn new() -> Self { + Self { + services: HashMap::new(), + } + } +} + +impl Actor for ServiceRegistry {} + +#[actor] +impl ServiceRegistry { + #[handler] + async fn handle_register(&mut self, msg: Register, _ctx: &Context) { + tracing::info!("Registered service '{}' at {}", msg.name, msg.address); + self.services.insert(msg.name, msg.address); + } + + #[handler] + async fn handle_lookup(&mut self, msg: Lookup, _ctx: &Context) -> Option { + self.services.get(&msg.name).cloned() + } + + #[handler] + async fn handle_list_all(&mut self, _msg: ListAll, _ctx: &Context) -> Vec<(String, String)> { + self.services.iter().map(|(k, v)| (k.clone(), v.clone())).collect() + } +} + +fn main() { + rt::run(async { + // Start the service registry actor + let svc = ServiceRegistry::new().start(); + + // Register it by name — other actors can discover it + registry::register("service_registry", svc.recipient::()).unwrap(); + + // Register some services + svc.send(Register { + name: "web".into(), + address: "http://localhost:8080".into(), + }) + .unwrap(); + + svc.send(Register { + name: "db".into(), + address: "postgres://localhost:5432".into(), + }) + .unwrap(); + + // A consumer discovers the registry by name (doesn't need to know ServiceRegistry type) + let lookup_recipient: Recipient = registry::whereis("service_registry").unwrap(); + + // Look up a service + let addr = request( + &*lookup_recipient, + Lookup { + name: "web".into(), + }, + Duration::from_secs(5), + ) + .await + .unwrap(); + tracing::info!("Looked up 'web': {:?}", addr); + + // List all registered names in the registry + let names = registry::registered(); + tracing::info!("Registry contains: {:?}", names); + + // Direct request for all services + let all = svc.request(ListAll).await.unwrap(); + tracing::info!("All services: {:?}", all); + + // Clean up + registry::unregister("service_registry"); + + tracing::info!("Service discovery demo complete"); + }); +} diff --git a/macros/Cargo.toml b/macros/Cargo.toml new file mode 100644 index 0000000..c1e3e15 --- /dev/null +++ b/macros/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "spawned-macros" +description = "Proc macros for the Spawned actor framework" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +proc-macro = true + +[dependencies] +syn = { version = "2", features = ["full"] } +quote = "1" +proc-macro2 = "1" diff --git a/macros/src/lib.rs b/macros/src/lib.rs new file mode 100644 index 0000000..f8dda17 --- /dev/null +++ b/macros/src/lib.rs @@ -0,0 +1,112 @@ +use proc_macro::TokenStream; +use quote::quote; +use syn::{parse_macro_input, FnArg, ImplItem, ItemImpl, Pat, ReturnType, Type}; + +/// Attribute macro for actor impl blocks. +/// +/// Place `#[actor]` on an `impl MyActor` block containing methods annotated +/// with `#[send_handler]` or `#[request_handler]`. For each annotated method, +/// the macro generates a corresponding `impl Handler for MyActor` block. +/// +/// Use `#[send_handler]` for fire-and-forget messages (no return value): +/// +/// ```ignore +/// #[send_handler] +/// async fn on_deposit(&mut self, msg: Deposit, ctx: &Context) { ... } +/// ``` +/// +/// Use `#[request_handler]` for request-response messages (returns a value): +/// +/// ```ignore +/// #[request_handler] +/// async fn on_balance(&mut self, msg: GetBalance, ctx: &Context) -> u64 { ... } +/// ``` +/// +/// Sync handlers (for the `threads` module) omit `async`: +/// +/// ```ignore +/// #[send_handler] +/// fn on_deposit(&mut self, msg: Deposit, ctx: &Context) { ... } +/// ``` +/// +/// The generic `#[handler]` attribute is also supported for backwards +/// compatibility and works for both send and request handlers. +#[proc_macro_attribute] +pub fn actor(_attr: TokenStream, item: TokenStream) -> TokenStream { + let mut impl_block = parse_macro_input!(item as ItemImpl); + + let self_ty = &impl_block.self_ty; + let (impl_generics, _, where_clause) = impl_block.generics.split_for_impl(); + + let mut handler_impls = Vec::new(); + + for item in &mut impl_block.items { + if let ImplItem::Fn(method) = item { + let handler_idx = method.attrs.iter().position(|attr| { + attr.path().is_ident("handler") + || attr.path().is_ident("send_handler") + || attr.path().is_ident("request_handler") + }); + + if let Some(idx) = handler_idx { + method.attrs.remove(idx); + + let method_name = &method.sig.ident; + let is_async = method.sig.asyncness.is_some(); + + // Extract message type from 2nd parameter (index 1, after &mut self) + let msg_ty = match method.sig.inputs.iter().nth(1) { + Some(FnArg::Typed(pat_type)) => { + if let Pat::Ident(pat_ident) = &*pat_type.pat { + if pat_ident.ident == "_" || pat_ident.ident.to_string().starts_with('_') { + // Still use the type + } + } + &*pat_type.ty + } + _ => { + return syn::Error::new_spanned( + &method.sig, + "handler method must have signature: fn(&mut self, msg: M, ctx: &Context) -> R", + ) + .to_compile_error() + .into(); + } + }; + + // Extract return type (default to () if omitted) + let ret_ty: Box = match &method.sig.output { + ReturnType::Default => syn::parse_quote! { () }, + ReturnType::Type(_, ty) => ty.clone(), + }; + + let handler_impl = if is_async { + quote! { + impl #impl_generics Handler<#msg_ty> for #self_ty #where_clause { + async fn handle(&mut self, msg: #msg_ty, ctx: &Context) -> #ret_ty { + self.#method_name(msg, ctx).await + } + } + } + } else { + quote! { + impl #impl_generics Handler<#msg_ty> for #self_ty #where_clause { + fn handle(&mut self, msg: #msg_ty, ctx: &Context) -> #ret_ty { + self.#method_name(msg, ctx) + } + } + } + }; + + handler_impls.push(handler_impl); + } + } + } + + let output = quote! { + #impl_block + #(#handler_impls)* + }; + + output.into() +} From 04d86e13dde5d6dcce78e37ea7f1067b928f3ecd Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Thu, 19 Feb 2026 18:17:32 -0300 Subject: [PATCH 12/20] feat: add protocol_impl! macro, Context::actor_ref(), rewrite examples to Approach B --- Cargo.lock | 1 + concurrency/src/message.rs | 112 +++++++------------- concurrency/src/tasks/actor.rs | 65 ++++++++++++ concurrency/src/tasks/mod.rs | 2 +- concurrency/src/threads/actor.rs | 12 +++ examples/chat_room/Cargo.toml | 1 + examples/chat_room/src/main.rs | 63 +++-------- examples/chat_room/src/messages.rs | 7 -- examples/chat_room/src/protocols.rs | 24 +++-- examples/chat_room/src/room.rs | 80 +++++++------- examples/chat_room/src/user.rs | 84 +++++++++++---- examples/chat_room_threads/src/main.rs | 20 ++-- examples/chat_room_threads/src/protocols.rs | 25 +++++ examples/chat_room_threads/src/room.rs | 51 +++++---- examples/chat_room_threads/src/user.rs | 58 +++++++--- examples/ping_pong/src/consumer.rs | 11 +- examples/ping_pong/src/main.rs | 13 +-- examples/ping_pong/src/messages.rs | 15 +-- examples/ping_pong/src/producer.rs | 16 +-- examples/ping_pong/src/protocols.rs | 27 ----- examples/ping_pong_threads/src/consumer.rs | 11 +- examples/ping_pong_threads/src/main.rs | 5 - examples/ping_pong_threads/src/producer.rs | 16 +-- examples/ping_pong_threads/src/protocols.rs | 27 ----- 24 files changed, 407 insertions(+), 339 deletions(-) delete mode 100644 examples/chat_room/src/messages.rs create mode 100644 examples/chat_room_threads/src/protocols.rs diff --git a/Cargo.lock b/Cargo.lock index 42c156e..089bb36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -125,6 +125,7 @@ name = "chat_room" version = "0.1.0" dependencies = [ "spawned-concurrency", + "spawned-macros", "spawned-rt", "tracing", ] diff --git a/concurrency/src/message.rs b/concurrency/src/message.rs index c340dd6..70b3789 100644 --- a/concurrency/src/message.rs +++ b/concurrency/src/message.rs @@ -135,55 +135,45 @@ macro_rules! request_messages { }; } -/// Generates an extension trait + impl on `ActorRef` for ergonomic method-call syntax. +/// Implements an existing protocol trait on a concrete type by mapping methods to messages. /// /// ```ignore -/// actor_api! { -/// pub ChatRoomApi for ActorRef { +/// protocol_impl! { +/// ChatBroadcaster for ActorRef { /// send fn say(from: String, text: String) => Say; -/// send fn add_member(name: String, inbox: Recipient) => Join; -/// request async fn members() -> Vec => Members; +/// send fn add_member(name: String, participant: ParticipantRef) => Join; +/// request fn members() -> Vec => Members; /// } /// } /// ``` /// -/// For threads (sync), use `request fn` instead of `request async fn`. +/// Method kinds: +/// - `send fn` — fire-and-forget, returns `Result<(), ActorError>` +/// - `request fn` — async request via `Response` (for tasks) +/// - `request sync fn` — blocking request, returns `Result` (for threads) #[macro_export] -macro_rules! actor_api { - // Entry: pub trait - (pub $trait_name:ident for $actor_ref:ty { $($body:tt)* }) => { - $crate::actor_api!(@parse [pub] $trait_name $actor_ref [] [] $($body)*); +macro_rules! protocol_impl { + // Entry + ($trait_name:ident for $target:ty { $($body:tt)* }) => { + $crate::protocol_impl!(@parse $trait_name $target [] $($body)*); }; - // Entry: private trait - ($trait_name:ident for $actor_ref:ty { $($body:tt)* }) => { - $crate::actor_api!(@parse [] $trait_name $actor_ref [] [] $($body)*); - }; - - // Terminal: generate trait + impl - (@parse [$($vis:tt)*] $trait_name:ident $actor_ref:ty - [$($trait_items:tt)*] + // Terminal: emit impl block + (@parse $trait_name:ident $target:ty [$($impl_items:tt)*] ) => { - $($vis)* trait $trait_name { - $($trait_items)* - } - impl $trait_name for $actor_ref { + impl $trait_name for $target { $($impl_items)* } }; // send fn with params - (@parse [$($vis:tt)*] $trait_name:ident $actor_ref:ty - [$($trait_items:tt)*] + (@parse $trait_name:ident $target:ty [$($impl_items:tt)*] send fn $method:ident($($param:ident : $ptype:ty),+ $(,)?) => $msg:ident; $($rest:tt)* ) => { - $crate::actor_api!(@parse [$($vis)*] $trait_name $actor_ref - [$($trait_items)* - fn $method(&self, $($param : $ptype),+) -> Result<(), $crate::error::ActorError>; - ] + $crate::protocol_impl!(@parse $trait_name $target [$($impl_items)* fn $method(&self, $($param : $ptype),+) -> Result<(), $crate::error::ActorError> { self.send($msg { $($param),+ }) @@ -193,17 +183,13 @@ macro_rules! actor_api { ); }; - // send fn without params (unit message) - (@parse [$($vis:tt)*] $trait_name:ident $actor_ref:ty - [$($trait_items:tt)*] + // send fn without params + (@parse $trait_name:ident $target:ty [$($impl_items:tt)*] send fn $method:ident() => $msg:ident; $($rest:tt)* ) => { - $crate::actor_api!(@parse [$($vis)*] $trait_name $actor_ref - [$($trait_items)* - fn $method(&self) -> Result<(), $crate::error::ActorError>; - ] + $crate::protocol_impl!(@parse $trait_name $target [$($impl_items)* fn $method(&self) -> Result<(), $crate::error::ActorError> { self.send($msg) @@ -213,57 +199,45 @@ macro_rules! actor_api { ); }; - // request async fn with params - (@parse [$($vis:tt)*] $trait_name:ident $actor_ref:ty - [$($trait_items:tt)*] + // request fn with params (async — returns Response) + (@parse $trait_name:ident $target:ty [$($impl_items:tt)*] - request async fn $method:ident($($param:ident : $ptype:ty),+ $(,)?) -> $ret:ty => $msg:ident; + request fn $method:ident($($param:ident : $ptype:ty),+ $(,)?) -> $ret:ty => $msg:ident; $($rest:tt)* ) => { - $crate::actor_api!(@parse [$($vis)*] $trait_name $actor_ref - [$($trait_items)* - async fn $method(&self, $($param : $ptype),+) -> Result<$ret, $crate::error::ActorError>; - ] + $crate::protocol_impl!(@parse $trait_name $target [$($impl_items)* - async fn $method(&self, $($param : $ptype),+) -> Result<$ret, $crate::error::ActorError> { - self.request($msg { $($param),+ }).await + fn $method(&self, $($param : $ptype),+) -> $crate::tasks::Response<$ret> { + $crate::tasks::Response::from(self.request_raw($msg { $($param),+ })) } ] $($rest)* ); }; - // request async fn without params (unit message) - (@parse [$($vis:tt)*] $trait_name:ident $actor_ref:ty - [$($trait_items:tt)*] + // request fn without params (async — returns Response) + (@parse $trait_name:ident $target:ty [$($impl_items:tt)*] - request async fn $method:ident() -> $ret:ty => $msg:ident; + request fn $method:ident() -> $ret:ty => $msg:ident; $($rest:tt)* ) => { - $crate::actor_api!(@parse [$($vis)*] $trait_name $actor_ref - [$($trait_items)* - async fn $method(&self) -> Result<$ret, $crate::error::ActorError>; - ] + $crate::protocol_impl!(@parse $trait_name $target [$($impl_items)* - async fn $method(&self) -> Result<$ret, $crate::error::ActorError> { - self.request($msg).await + fn $method(&self) -> $crate::tasks::Response<$ret> { + $crate::tasks::Response::from(self.request_raw($msg)) } ] $($rest)* ); }; - // request fn with params (sync/threads) - (@parse [$($vis:tt)*] $trait_name:ident $actor_ref:ty - [$($trait_items:tt)*] + // request sync fn with params (threads — returns Result) + (@parse $trait_name:ident $target:ty [$($impl_items:tt)*] - request fn $method:ident($($param:ident : $ptype:ty),+ $(,)?) -> $ret:ty => $msg:ident; + request sync fn $method:ident($($param:ident : $ptype:ty),+ $(,)?) -> $ret:ty => $msg:ident; $($rest:tt)* ) => { - $crate::actor_api!(@parse [$($vis)*] $trait_name $actor_ref - [$($trait_items)* - fn $method(&self, $($param : $ptype),+) -> Result<$ret, $crate::error::ActorError>; - ] + $crate::protocol_impl!(@parse $trait_name $target [$($impl_items)* fn $method(&self, $($param : $ptype),+) -> Result<$ret, $crate::error::ActorError> { self.request($msg { $($param),+ }) @@ -273,17 +247,13 @@ macro_rules! actor_api { ); }; - // request fn without params (sync/threads, unit message) - (@parse [$($vis:tt)*] $trait_name:ident $actor_ref:ty - [$($trait_items:tt)*] + // request sync fn without params (threads — returns Result) + (@parse $trait_name:ident $target:ty [$($impl_items:tt)*] - request fn $method:ident() -> $ret:ty => $msg:ident; + request sync fn $method:ident() -> $ret:ty => $msg:ident; $($rest:tt)* ) => { - $crate::actor_api!(@parse [$($vis)*] $trait_name $actor_ref - [$($trait_items)* - fn $method(&self) -> Result<$ret, $crate::error::ActorError>; - ] + $crate::protocol_impl!(@parse $trait_name $target [$($impl_items)* fn $method(&self) -> Result<$ret, $crate::error::ActorError> { self.request($msg) diff --git a/concurrency/src/tasks/actor.rs b/concurrency/src/tasks/actor.rs index c0a90a9..7ee362a 100644 --- a/concurrency/src/tasks/actor.rs +++ b/concurrency/src/tasks/actor.rs @@ -91,6 +91,7 @@ where pub struct Context { sender: mpsc::Sender + Send>>, cancellation_token: CancellationToken, + completion_rx: watch::Receiver, } impl Clone for Context { @@ -98,6 +99,7 @@ impl Clone for Context { Self { sender: self.sender.clone(), cancellation_token: self.cancellation_token.clone(), + completion_rx: self.completion_rx.clone(), } } } @@ -113,6 +115,7 @@ impl Context { Self { sender: actor_ref.sender.clone(), cancellation_token: actor_ref.cancellation_token.clone(), + completion_rx: actor_ref.completion_rx.clone(), } } @@ -168,6 +171,14 @@ impl Context { Arc::new(self.clone()) } + pub fn actor_ref(&self) -> ActorRef { + ActorRef { + sender: self.sender.clone(), + cancellation_token: self.cancellation_token.clone(), + completion_rx: self.completion_rx.clone(), + } + } + pub(crate) fn cancellation_token(&self) -> CancellationToken { self.cancellation_token.clone() } @@ -212,6 +223,59 @@ pub async fn request( } } +// --------------------------------------------------------------------------- +// Response — awaitable wrapper for protocol trait request-response +// --------------------------------------------------------------------------- + +enum ResponseState { + Receiver(oneshot::Receiver), + Error(ActorError), + Done, +} + +/// Concrete `Future` that wraps a oneshot receiver. Keeps protocol traits object-safe: +/// `fn members(&self) -> Response>` returns a concrete type, not `impl Future`. +pub struct Response(ResponseState); + +impl Unpin for Response {} + +impl From, ActorError>> for Response { + fn from(result: Result, ActorError>) -> Self { + match result { + Ok(rx) => Self(ResponseState::Receiver(rx)), + Err(e) => Self(ResponseState::Error(e)), + } + } +} + +impl Future for Response { + type Output = Result; + + fn poll(self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> std::task::Poll { + let this = self.get_mut(); + match &mut this.0 { + ResponseState::Receiver(rx) => match Pin::new(rx).poll(cx) { + std::task::Poll::Ready(Ok(val)) => { + this.0 = ResponseState::Done; + std::task::Poll::Ready(Ok(val)) + } + std::task::Poll::Ready(Err(_)) => { + this.0 = ResponseState::Done; + std::task::Poll::Ready(Err(ActorError::ActorStopped)) + } + std::task::Poll::Pending => std::task::Poll::Pending, + }, + ResponseState::Error(_) => { + match std::mem::replace(&mut this.0, ResponseState::Done) { + ResponseState::Error(e) => std::task::Poll::Ready(Err(e)), + _ => unreachable!(), + } + } + ResponseState::Done => panic!("Response polled after completion"), + } + } +} + // --------------------------------------------------------------------------- // ActorRef // --------------------------------------------------------------------------- @@ -347,6 +411,7 @@ impl ActorRef { let ctx = Context { sender: tx, cancellation_token: cancellation_token.clone(), + completion_rx: actor_ref.completion_rx.clone(), }; let inner_future = async move { diff --git a/concurrency/src/tasks/mod.rs b/concurrency/src/tasks/mod.rs index 2dc766f..8725c97 100644 --- a/concurrency/src/tasks/mod.rs +++ b/concurrency/src/tasks/mod.rs @@ -10,7 +10,7 @@ mod timer_tests; pub use actor::{ request, send_message_on, Actor, ActorRef, ActorStart, Backend, Context, Handler, Receiver, - Recipient, + Recipient, Response, }; pub use process::{send, Process, ProcessInfo}; pub use stream::spawn_listener; diff --git a/concurrency/src/threads/actor.rs b/concurrency/src/threads/actor.rs index f7e7a53..99ce181 100644 --- a/concurrency/src/threads/actor.rs +++ b/concurrency/src/threads/actor.rs @@ -63,6 +63,7 @@ where pub struct Context { sender: mpsc::Sender + Send>>, cancellation_token: CancellationToken, + completion: Arc<(Mutex, Condvar)>, } impl Clone for Context { @@ -70,6 +71,7 @@ impl Clone for Context { Self { sender: self.sender.clone(), cancellation_token: self.cancellation_token.clone(), + completion: self.completion.clone(), } } } @@ -85,6 +87,7 @@ impl Context { Self { sender: actor_ref.sender.clone(), cancellation_token: actor_ref.cancellation_token.clone(), + completion: actor_ref.completion.clone(), } } @@ -152,6 +155,14 @@ impl Context { Arc::new(self.clone()) } + pub fn actor_ref(&self) -> ActorRef { + ActorRef { + sender: self.sender.clone(), + cancellation_token: self.cancellation_token.clone(), + completion: self.completion.clone(), + } + } + pub(crate) fn cancellation_token(&self) -> CancellationToken { self.cancellation_token.clone() } @@ -341,6 +352,7 @@ impl ActorRef { let ctx = Context { sender: tx, cancellation_token: cancellation_token.clone(), + completion: actor_ref.completion.clone(), }; let _thread_handle = rt::spawn(move || { diff --git a/examples/chat_room/Cargo.toml b/examples/chat_room/Cargo.toml index 4fb3882..935169e 100644 --- a/examples/chat_room/Cargo.toml +++ b/examples/chat_room/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] spawned-rt = { workspace = true } spawned-concurrency = { workspace = true } +spawned-macros = { workspace = true } tracing = { workspace = true } [[bin]] diff --git a/examples/chat_room/src/main.rs b/examples/chat_room/src/main.rs index 41a6637..3b35782 100644 --- a/examples/chat_room/src/main.rs +++ b/examples/chat_room/src/main.rs @@ -1,71 +1,36 @@ -//! Chat room example demonstrating how protocol traits solve circular dependencies. +//! Chat room example — Approach B (protocol traits + protocol_impl!). //! -//! The problem: -//! - `ChatRoom` needs to send `Deliver` to each `User` -//! - `User` needs to send `Say` to the `ChatRoom` -//! - With concrete types, `room.rs` would import `User` and -//! `user.rs` would import `ChatRoom` — circular module dependency. -//! -//! The solution: -//! - `ChatRoom` holds `Arc` — doesn't know about `User` -//! - `User` holds `Arc` — doesn't know about `ChatRoom` -//! - Both modules only depend on the shared `protocols` module. -//! -//! Message flow: -//! main -> SayToRoom -> User -> Say -> ChatRoom -> Deliver -> User +//! Room and User have ZERO direct dependencies on each other. +//! Both depend only on the protocol traits in `protocols.rs`. +//! Cross-boundary erasure is via `BroadcasterRef` and `ParticipantRef`. -mod messages; mod protocols; mod room; mod user; -use messages::SayToRoom; -use protocols::ChatBroadcaster; +use protocols::{AsBroadcaster, ChatBroadcaster}; use room::ChatRoom; use spawned_concurrency::tasks::ActorStart as _; use spawned_rt::tasks as rt; -use std::sync::Arc; use std::time::Duration; -use user::User; +use user::{User, UserActions}; fn main() { rt::run(async { let room = ChatRoom::new().start(); + let alice = User::new("Alice".into()).start(); + let bob = User::new("Bob".into()).start(); - let alice = User { - name: "Alice".into(), - room: Arc::new(room.clone()), - } - .start(); - - let bob = User { - name: "Bob".into(), - room: Arc::new(room.clone()), - } - .start(); - - // Register users via protocol trait — room stores Arc - room.add_member("Alice".into(), Arc::new(alice.clone())).unwrap(); - room.add_member("Bob".into(), Arc::new(bob.clone())).unwrap(); - // Small delay to let join messages be processed + alice.join_room(room.as_broadcaster()).unwrap(); + bob.join_room(room.as_broadcaster()).unwrap(); rt::sleep(Duration::from_millis(10)).await; - // Alice speaks: main -> alice (SayToRoom) -> room (Say) -> bob (Deliver) - alice - .request(SayToRoom { - text: "Hello everyone!".into(), - }) - .await - .unwrap(); + let members = room.members().await.unwrap(); + tracing::info!("Members: {:?}", members); - // Bob replies: main -> bob (SayToRoom) -> room (Say) -> alice (Deliver) - bob.request(SayToRoom { - text: "Hey Alice!".into(), - }) - .await - .unwrap(); + alice.say("Hello everyone!".into()).unwrap(); + bob.say("Hey Alice!".into()).unwrap(); - // Let deliveries propagate rt::sleep(Duration::from_millis(50)).await; }) } diff --git a/examples/chat_room/src/messages.rs b/examples/chat_room/src/messages.rs deleted file mode 100644 index 2dc23d3..0000000 --- a/examples/chat_room/src/messages.rs +++ /dev/null @@ -1,7 +0,0 @@ -use spawned_concurrency::messages; - -messages! { - Say { from: String, text: String } -> (); - SayToRoom { text: String } -> (); - Deliver { from: String, text: String } -> (); -} diff --git a/examples/chat_room/src/protocols.rs b/examples/chat_room/src/protocols.rs index 823058a..cf52ac5 100644 --- a/examples/chat_room/src/protocols.rs +++ b/examples/chat_room/src/protocols.rs @@ -1,17 +1,29 @@ //! Protocol traits — cross-actor contracts. //! -//! Neither `ChatRoom` nor `User` appears here. The circular dependency -//! is broken because each actor holds an `Arc` -//! instead of a concrete `ActorRef`. +//! Neither `ChatRoom` nor `User` appears here. Both actors depend only +//! on these traits, breaking circular dependencies completely. use spawned_concurrency::error::ActorError; +use spawned_concurrency::tasks::Response; use std::sync::Arc; +pub type BroadcasterRef = Arc; +pub type ParticipantRef = Arc; + +pub trait ChatBroadcaster: Send + Sync { + fn say(&self, from: String, text: String) -> Result<(), ActorError>; + fn add_member(&self, name: String, participant: ParticipantRef) -> Result<(), ActorError>; + fn members(&self) -> Response>; +} + pub trait ChatParticipant: Send + Sync { fn deliver(&self, from: String, text: String) -> Result<(), ActorError>; } -pub trait ChatBroadcaster: Send + Sync { - fn say(&self, from: String, text: String) -> Result<(), ActorError>; - fn add_member(&self, name: String, inbox: Arc) -> Result<(), ActorError>; +pub trait AsBroadcaster { + fn as_broadcaster(&self) -> BroadcasterRef; +} + +pub trait AsParticipant { + fn as_participant(&self) -> ParticipantRef; } diff --git a/examples/chat_room/src/room.rs b/examples/chat_room/src/room.rs index 93f405d..e2654be 100644 --- a/examples/chat_room/src/room.rs +++ b/examples/chat_room/src/room.rs @@ -1,71 +1,75 @@ -//! ChatRoom actor — knows about `Say`, `Join`, and `ChatParticipant` trait. -//! Does NOT know about the `User` type. +//! ChatRoom actor — knows about protocol traits, not User. -use spawned_concurrency::error::ActorError; -use spawned_concurrency::message::Message; +use spawned_concurrency::protocol_impl; +use spawned_concurrency::request_messages; +use spawned_concurrency::send_messages; use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; +use spawned_macros::actor; use std::sync::Arc; -use crate::messages::Say; -use crate::protocols::{ChatBroadcaster, ChatParticipant}; +use crate::protocols::{AsBroadcaster, BroadcasterRef, ChatBroadcaster, ParticipantRef}; -// Join carries an Arc, so we define it here (not via macro) -pub struct Join { - pub name: String, - pub inbox: Arc, +// -- Internal messages -- + +send_messages! { + Say { from: String, text: String }; + Join { name: String, participant: ParticipantRef } +} + +request_messages! { + Members -> Vec } -impl Message for Join { - type Result = (); +// -- Protocol bridge -- + +protocol_impl! { + ChatBroadcaster for ActorRef { + send fn say(from: String, text: String) => Say; + send fn add_member(name: String, participant: ParticipantRef) => Join; + request fn members() -> Vec => Members; + } } -impl std::fmt::Debug for Join { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Join").field("name", &self.name).finish() +impl AsBroadcaster for ActorRef { + fn as_broadcaster(&self) -> BroadcasterRef { + Arc::new(self.clone()) } } +// -- Actor -- + pub struct ChatRoom { - members: Vec<(String, Arc)>, + members: Vec<(String, ParticipantRef)>, } +impl Actor for ChatRoom {} + +#[actor] impl ChatRoom { pub fn new() -> Self { Self { members: Vec::new(), } } -} - -impl Actor for ChatRoom {} -impl Handler for ChatRoom { - async fn handle(&mut self, msg: Join, _ctx: &Context) { + #[send_handler] + async fn handle_join(&mut self, msg: Join, _ctx: &Context) { tracing::info!("[room] {} joined", msg.name); - self.members.push((msg.name, msg.inbox)); + self.members.push((msg.name, msg.participant)); } -} -impl Handler for ChatRoom { - async fn handle(&mut self, msg: Say, _ctx: &Context) { + #[send_handler] + async fn handle_say(&mut self, msg: Say, _ctx: &Context) { tracing::info!("[room] {} says: {}", msg.from, msg.text); - - // Broadcast to all members except the sender - for (name, inbox) in &self.members { + for (name, participant) in &self.members { if *name != msg.from { - let _ = inbox.deliver(msg.from.clone(), msg.text.clone()); + let _ = participant.deliver(msg.from.clone(), msg.text.clone()); } } } -} - -// Bridge: ActorRef implements ChatBroadcaster -impl ChatBroadcaster for ActorRef { - fn say(&self, from: String, text: String) -> Result<(), ActorError> { - self.send(Say { from, text }) - } - fn add_member(&self, name: String, inbox: Arc) -> Result<(), ActorError> { - self.send(Join { name, inbox }) + #[request_handler] + async fn handle_members(&mut self, _msg: Members, _ctx: &Context) -> Vec { + self.members.iter().map(|(name, _)| name.clone()).collect() } } diff --git a/examples/chat_room/src/user.rs b/examples/chat_room/src/user.rs index ab9a888..d3c1a3c 100644 --- a/examples/chat_room/src/user.rs +++ b/examples/chat_room/src/user.rs @@ -1,36 +1,82 @@ -//! User actor — knows about `SayToRoom`, `Deliver`, and `ChatBroadcaster` trait. -//! Does NOT know about the `ChatRoom` type. +//! User actor — knows about protocol traits, not ChatRoom. use spawned_concurrency::error::ActorError; +use spawned_concurrency::protocol_impl; +use spawned_concurrency::send_messages; use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; +use spawned_macros::actor; use std::sync::Arc; -use crate::messages::{Deliver, SayToRoom}; -use crate::protocols::{ChatBroadcaster, ChatParticipant}; +use crate::protocols::{AsParticipant, BroadcasterRef, ChatParticipant, ParticipantRef}; -pub struct User { - pub name: String, - pub room: Arc, +// -- Internal messages -- + +send_messages! { + Deliver { from: String, text: String }; + SayToRoom { text: String }; + JoinRoom { room: BroadcasterRef } } -impl Actor for User {} +// -- Protocol bridge (ChatParticipant) -- -impl Handler for User { - async fn handle(&mut self, msg: SayToRoom, _ctx: &Context) { - // Forward to the room via Arc — no ChatRoom type needed - let _ = self.room.say(self.name.clone(), msg.text); +protocol_impl! { + ChatParticipant for ActorRef { + send fn deliver(from: String, text: String) => Deliver; } } -impl Handler for User { - async fn handle(&mut self, msg: Deliver, _ctx: &Context) { - tracing::info!("[{}] got message from {}: {}", self.name, msg.from, msg.text); +impl AsParticipant for ActorRef { + fn as_participant(&self) -> ParticipantRef { + Arc::new(self.clone()) } } -// Bridge: ActorRef implements ChatParticipant -impl ChatParticipant for ActorRef { - fn deliver(&self, from: String, text: String) -> Result<(), ActorError> { - self.send(Deliver { from, text }) +// -- Caller API -- + +pub trait UserActions { + fn say(&self, text: String) -> Result<(), ActorError>; + fn join_room(&self, room: BroadcasterRef) -> Result<(), ActorError>; +} + +protocol_impl! { + UserActions for ActorRef { + send fn say(text: String) => SayToRoom; + send fn join_room(room: BroadcasterRef) => JoinRoom; + } +} + +// -- Actor -- + +pub struct User { + name: String, + room: Option, +} + +impl Actor for User {} + +#[actor] +impl User { + pub fn new(name: String) -> Self { + Self { name, room: None } + } + + #[send_handler] + async fn handle_join_room(&mut self, msg: JoinRoom, ctx: &Context) { + let _ = msg + .room + .add_member(self.name.clone(), ctx.actor_ref().as_participant()); + self.room = Some(msg.room); + } + + #[send_handler] + async fn handle_say_to_room(&mut self, msg: SayToRoom, _ctx: &Context) { + if let Some(ref room) = self.room { + let _ = room.say(self.name.clone(), msg.text); + } + } + + #[send_handler] + async fn handle_deliver(&mut self, msg: Deliver, _ctx: &Context) { + tracing::info!("[{}] got: {} says '{}'", self.name, msg.from, msg.text); } } diff --git a/examples/chat_room_threads/src/main.rs b/examples/chat_room_threads/src/main.rs index b408c8e..2165184 100644 --- a/examples/chat_room_threads/src/main.rs +++ b/examples/chat_room_threads/src/main.rs @@ -1,37 +1,33 @@ +//! Chat room example — Approach B (protocol traits + protocol_impl!, sync/threads). + +mod protocols; mod room; mod user; use std::thread; use std::time::Duration; -use room::{ChatRoom, ChatRoomApi}; +use protocols::{AsBroadcaster, ChatBroadcaster}; +use room::ChatRoom; use spawned_concurrency::threads::ActorStart; use spawned_rt::threads as rt; -use user::{User, UserApi}; +use user::{User, UserActions}; fn main() { rt::run(|| { let room = ChatRoom::new().start(); - let alice = User::new("Alice".into()).start(); let bob = User::new("Bob".into()).start(); - // Register users in the room (send — fire-and-forget) - alice.join_room(room.clone()).unwrap(); - bob.join_room(room.clone()).unwrap(); - - // Let join messages propagate (user -> room) + alice.join_room(room.as_broadcaster()).unwrap(); + bob.join_room(room.as_broadcaster()).unwrap(); thread::sleep(Duration::from_millis(10)); - // Query members (request — blocking) let members = room.members().unwrap(); tracing::info!("Members in room: {:?}", members); - // Chat (send — fire-and-forget) alice.say("Hello everyone!".into()).unwrap(); bob.say("Hi Alice!".into()).unwrap(); - - // Give time for messages to propagate thread::sleep(Duration::from_millis(100)); tracing::info!("Chat room demo complete"); diff --git a/examples/chat_room_threads/src/protocols.rs b/examples/chat_room_threads/src/protocols.rs new file mode 100644 index 0000000..80249ef --- /dev/null +++ b/examples/chat_room_threads/src/protocols.rs @@ -0,0 +1,25 @@ +//! Protocol traits — cross-actor contracts (sync/threads version). + +use spawned_concurrency::error::ActorError; +use std::sync::Arc; + +pub type BroadcasterRef = Arc; +pub type ParticipantRef = Arc; + +pub trait ChatBroadcaster: Send + Sync { + fn say(&self, from: String, text: String) -> Result<(), ActorError>; + fn add_member(&self, name: String, participant: ParticipantRef) -> Result<(), ActorError>; + fn members(&self) -> Result, ActorError>; +} + +pub trait ChatParticipant: Send + Sync { + fn deliver(&self, from: String, text: String) -> Result<(), ActorError>; +} + +pub trait AsBroadcaster { + fn as_broadcaster(&self) -> BroadcasterRef; +} + +pub trait AsParticipant { + fn as_participant(&self) -> ParticipantRef; +} diff --git a/examples/chat_room_threads/src/room.rs b/examples/chat_room_threads/src/room.rs index 7108abf..7c70de4 100644 --- a/examples/chat_room_threads/src/room.rs +++ b/examples/chat_room_threads/src/room.rs @@ -1,35 +1,45 @@ -use spawned_concurrency::actor_api; +//! ChatRoom actor — knows about protocol traits, not User (sync/threads version). + +use spawned_concurrency::protocol_impl; use spawned_concurrency::request_messages; use spawned_concurrency::send_messages; -use spawned_concurrency::threads::{Actor, ActorRef, Context, Handler, Recipient}; +use spawned_concurrency::threads::{Actor, ActorRef, Context, Handler}; use spawned_macros::actor; +use std::sync::Arc; + +use crate::protocols::{AsBroadcaster, BroadcasterRef, ChatBroadcaster, ParticipantRef}; -// -- Messages -- +// -- Internal messages -- send_messages! { Say { from: String, text: String }; - Deliver { from: String, text: String }; - Join { name: String, inbox: Recipient } + Join { name: String, participant: ParticipantRef } } request_messages! { Members -> Vec } -// -- API -- +// -- Protocol bridge -- -actor_api! { - pub ChatRoomApi for ActorRef { +protocol_impl! { + ChatBroadcaster for ActorRef { send fn say(from: String, text: String) => Say; - send fn add_member(name: String, inbox: Recipient) => Join; - request fn members() -> Vec => Members; + send fn add_member(name: String, participant: ParticipantRef) => Join; + request sync fn members() -> Vec => Members; + } +} + +impl AsBroadcaster for ActorRef { + fn as_broadcaster(&self) -> BroadcasterRef { + Arc::new(self.clone()) } } // -- Actor -- pub struct ChatRoom { - members: Vec<(String, Recipient)>, + members: Vec<(String, ParticipantRef)>, } impl Actor for ChatRoom {} @@ -42,24 +52,21 @@ impl ChatRoom { } } + #[send_handler] + fn handle_join(&mut self, msg: Join, _ctx: &Context) { + tracing::info!("[room] {} joined", msg.name); + self.members.push((msg.name, msg.participant)); + } + #[send_handler] fn handle_say(&mut self, msg: Say, _ctx: &Context) { - for (name, inbox) in &self.members { + for (name, participant) in &self.members { if *name != msg.from { - let _ = inbox.send(Deliver { - from: msg.from.clone(), - text: msg.text.clone(), - }); + let _ = participant.deliver(msg.from.clone(), msg.text.clone()); } } } - #[send_handler] - fn handle_join(&mut self, msg: Join, _ctx: &Context) { - tracing::info!("[room] {} joined", msg.name); - self.members.push((msg.name, msg.inbox)); - } - #[request_handler] fn handle_members(&mut self, _msg: Members, _ctx: &Context) -> Vec { self.members.iter().map(|(name, _)| name.clone()).collect() diff --git a/examples/chat_room_threads/src/user.rs b/examples/chat_room_threads/src/user.rs index 4dfe6a5..b42c196 100644 --- a/examples/chat_room_threads/src/user.rs +++ b/examples/chat_room_threads/src/user.rs @@ -1,31 +1,55 @@ -use spawned_concurrency::actor_api; +//! User actor — knows about protocol traits, not ChatRoom (sync/threads version). + +use spawned_concurrency::error::ActorError; +use spawned_concurrency::protocol_impl; use spawned_concurrency::send_messages; use spawned_concurrency::threads::{Actor, ActorRef, Context, Handler}; use spawned_macros::actor; +use std::sync::Arc; -use crate::room::{ChatRoom, ChatRoomApi, Deliver}; +use crate::protocols::{AsParticipant, BroadcasterRef, ChatParticipant, ParticipantRef}; -// -- Messages -- +// -- Internal messages -- send_messages! { + Deliver { from: String, text: String }; SayToRoom { text: String }; - JoinRoom { room: ActorRef } + JoinRoom { room: BroadcasterRef } +} + +// -- Protocol bridge (ChatParticipant) -- + +protocol_impl! { + ChatParticipant for ActorRef { + send fn deliver(from: String, text: String) => Deliver; + } } -// -- API -- +impl AsParticipant for ActorRef { + fn as_participant(&self) -> ParticipantRef { + Arc::new(self.clone()) + } +} + +// -- Caller API -- + +pub trait UserActions { + fn say(&self, text: String) -> Result<(), ActorError>; + fn join_room(&self, room: BroadcasterRef) -> Result<(), ActorError>; +} -actor_api! { - pub UserApi for ActorRef { +protocol_impl! { + UserActions for ActorRef { send fn say(text: String) => SayToRoom; - send fn join_room(room: ActorRef) => JoinRoom; + send fn join_room(room: BroadcasterRef) => JoinRoom; } } // -- Actor -- pub struct User { - pub name: String, - room: Option>, + name: String, + room: Option, } impl Actor for User {} @@ -36,6 +60,14 @@ impl User { Self { name, room: None } } + #[send_handler] + fn handle_join_room(&mut self, msg: JoinRoom, ctx: &Context) { + let _ = msg + .room + .add_member(self.name.clone(), ctx.actor_ref().as_participant()); + self.room = Some(msg.room); + } + #[send_handler] fn handle_say_to_room(&mut self, msg: SayToRoom, _ctx: &Context) { if let Some(ref room) = self.room { @@ -43,12 +75,6 @@ impl User { } } - #[send_handler] - fn handle_join_room(&mut self, msg: JoinRoom, ctx: &Context) { - let _ = msg.room.add_member(self.name.clone(), ctx.recipient::()); - self.room = Some(msg.room); - } - #[send_handler] fn handle_deliver(&mut self, msg: Deliver, _ctx: &Context) { tracing::info!("[{}] got: {} says '{}'", self.name, msg.from, msg.text); diff --git a/examples/ping_pong/src/consumer.rs b/examples/ping_pong/src/consumer.rs index f5e9cb8..25cbfa8 100644 --- a/examples/ping_pong/src/consumer.rs +++ b/examples/ping_pong/src/consumer.rs @@ -1,7 +1,8 @@ -use spawned_concurrency::tasks::{Actor, Context, Handler}; +use spawned_concurrency::protocol_impl; +use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; use crate::messages::Ping; -use crate::protocols::PongInbox; +use crate::protocols::{PingReceiver, PongInbox}; pub struct Consumer { pub producer: PongInbox, @@ -15,3 +16,9 @@ impl Handler for Consumer { let _ = self.producer.pong(); } } + +protocol_impl! { + PingReceiver for ActorRef { + send fn ping() => Ping; + } +} diff --git a/examples/ping_pong/src/main.rs b/examples/ping_pong/src/main.rs index ed45ef3..3378436 100644 --- a/examples/ping_pong/src/main.rs +++ b/examples/ping_pong/src/main.rs @@ -1,9 +1,7 @@ -//! Ping-pong example demonstrating bidirectional communication -//! between actors using protocol traits for type-erased messaging. +//! Ping-pong example — Approach B (protocol traits + protocol_impl!). //! -//! This solves the circular dependency problem: Consumer and Producer -//! don't need to know each other's concrete types — they only know -//! about the protocol traits they implement (PingReceiver and PongReceiver). +//! Consumer and Producer don't know each other's concrete types. +//! They only depend on the PingReceiver and PongReceiver protocol traits. mod consumer; mod messages; @@ -19,24 +17,19 @@ use std::time::Duration; fn main() { rt::run(async { - // Start the producer first let producer = Producer { consumer: None }.start(); - // Start the consumer with an Arc pointing to the producer let consumer = Consumer { producer: Arc::new(producer.clone()), } .start(); - // Wire up the producer with the consumer's Arc producer .send(SetConsumer(Arc::new(consumer.clone()))) .unwrap(); - // Kick off the ping-pong loop consumer.send(messages::Ping).unwrap(); - // Let them ping-pong for a bit rt::sleep(Duration::from_millis(1)).await; }) } diff --git a/examples/ping_pong/src/messages.rs b/examples/ping_pong/src/messages.rs index 850fd2b..2740ff9 100644 --- a/examples/ping_pong/src/messages.rs +++ b/examples/ping_pong/src/messages.rs @@ -1,13 +1,6 @@ -use spawned_concurrency::message::Message; +use spawned_concurrency::send_messages; -#[derive(Debug)] -pub struct Ping; -impl Message for Ping { - type Result = (); -} - -#[derive(Debug)] -pub struct Pong; -impl Message for Pong { - type Result = (); +send_messages! { + Ping; + Pong } diff --git a/examples/ping_pong/src/producer.rs b/examples/ping_pong/src/producer.rs index 4514c7f..de1f46e 100644 --- a/examples/ping_pong/src/producer.rs +++ b/examples/ping_pong/src/producer.rs @@ -1,18 +1,14 @@ use spawned_concurrency::message::Message; -use spawned_concurrency::tasks::{Actor, Context, Handler}; +use spawned_concurrency::protocol_impl; +use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; use crate::messages::Pong; -use crate::protocols::PingInbox; +use crate::protocols::{PingInbox, PongReceiver}; pub struct SetConsumer(pub PingInbox); impl Message for SetConsumer { type Result = (); } -impl std::fmt::Debug for SetConsumer { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_tuple("SetConsumer").finish() - } -} pub struct Producer { pub consumer: Option, @@ -34,3 +30,9 @@ impl Handler for Producer { } } } + +protocol_impl! { + PongReceiver for ActorRef { + send fn pong() => Pong; + } +} diff --git a/examples/ping_pong/src/protocols.rs b/examples/ping_pong/src/protocols.rs index 0361985..c4f4ad2 100644 --- a/examples/ping_pong/src/protocols.rs +++ b/examples/ping_pong/src/protocols.rs @@ -1,13 +1,6 @@ use spawned_concurrency::error::ActorError; -use spawned_concurrency::tasks::{ActorRef, Handler}; use std::sync::Arc; -use crate::consumer::Consumer; -use crate::messages::{Ping, Pong}; -use crate::producer::Producer; - -// --- Protocol traits: cross-actor contracts --- - pub trait PingReceiver: Send + Sync { fn ping(&self) -> Result<(), ActorError>; } @@ -16,25 +9,5 @@ pub trait PongReceiver: Send + Sync { fn pong(&self) -> Result<(), ActorError>; } -// --- Bridge impls --- - -impl PingReceiver for ActorRef -where - Consumer: Handler, -{ - fn ping(&self) -> Result<(), ActorError> { - self.send(Ping) - } -} - -impl PongReceiver for ActorRef -where - Producer: Handler, -{ - fn pong(&self) -> Result<(), ActorError> { - self.send(Pong) - } -} - pub type PingInbox = Arc; pub type PongInbox = Arc; diff --git a/examples/ping_pong_threads/src/consumer.rs b/examples/ping_pong_threads/src/consumer.rs index 3f24494..bce60ae 100644 --- a/examples/ping_pong_threads/src/consumer.rs +++ b/examples/ping_pong_threads/src/consumer.rs @@ -1,7 +1,8 @@ -use spawned_concurrency::threads::{Actor, Context, Handler}; +use spawned_concurrency::protocol_impl; +use spawned_concurrency::threads::{Actor, ActorRef, Context, Handler}; use crate::messages::Ping; -use crate::protocols::PongInbox; +use crate::protocols::{PingReceiver, PongInbox}; pub struct Consumer { pub producer: PongInbox, @@ -15,3 +16,9 @@ impl Handler for Consumer { let _ = self.producer.pong(); } } + +protocol_impl! { + PingReceiver for ActorRef { + send fn ping() => Ping; + } +} diff --git a/examples/ping_pong_threads/src/main.rs b/examples/ping_pong_threads/src/main.rs index 8afc8ee..2eeeaa9 100644 --- a/examples/ping_pong_threads/src/main.rs +++ b/examples/ping_pong_threads/src/main.rs @@ -13,24 +13,19 @@ use std::sync::Arc; fn main() { rt::run(|| { - // Start the producer first let producer = Producer { consumer: None }.start(); - // Start the consumer with an Arc pointing to the producer let consumer = Consumer { producer: Arc::new(producer.clone()), } .start(); - // Wire up the producer with the consumer's Arc producer .send(SetConsumer(Arc::new(consumer.clone()))) .unwrap(); - // Kick off the ping-pong loop consumer.send(messages::Ping).unwrap(); - // Let them ping-pong for a bit thread::sleep(Duration::from_millis(1)); }) } diff --git a/examples/ping_pong_threads/src/producer.rs b/examples/ping_pong_threads/src/producer.rs index b5190f9..847a67f 100644 --- a/examples/ping_pong_threads/src/producer.rs +++ b/examples/ping_pong_threads/src/producer.rs @@ -1,18 +1,14 @@ use spawned_concurrency::message::Message; -use spawned_concurrency::threads::{Actor, Context, Handler}; +use spawned_concurrency::protocol_impl; +use spawned_concurrency::threads::{Actor, ActorRef, Context, Handler}; use crate::messages::Pong; -use crate::protocols::PingInbox; +use crate::protocols::{PingInbox, PongReceiver}; pub struct SetConsumer(pub PingInbox); impl Message for SetConsumer { type Result = (); } -impl std::fmt::Debug for SetConsumer { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_tuple("SetConsumer").finish() - } -} pub struct Producer { pub consumer: Option, @@ -34,3 +30,9 @@ impl Handler for Producer { } } } + +protocol_impl! { + PongReceiver for ActorRef { + send fn pong() => Pong; + } +} diff --git a/examples/ping_pong_threads/src/protocols.rs b/examples/ping_pong_threads/src/protocols.rs index 99c5275..c4f4ad2 100644 --- a/examples/ping_pong_threads/src/protocols.rs +++ b/examples/ping_pong_threads/src/protocols.rs @@ -1,13 +1,6 @@ use spawned_concurrency::error::ActorError; -use spawned_concurrency::threads::{ActorRef, Handler}; use std::sync::Arc; -use crate::consumer::Consumer; -use crate::messages::{Ping, Pong}; -use crate::producer::Producer; - -// --- Protocol traits: cross-actor contracts --- - pub trait PingReceiver: Send + Sync { fn ping(&self) -> Result<(), ActorError>; } @@ -16,25 +9,5 @@ pub trait PongReceiver: Send + Sync { fn pong(&self) -> Result<(), ActorError>; } -// --- Bridge impls --- - -impl PingReceiver for ActorRef -where - Consumer: Handler, -{ - fn ping(&self) -> Result<(), ActorError> { - self.send(Ping) - } -} - -impl PongReceiver for ActorRef -where - Producer: Handler, -{ - fn pong(&self) -> Result<(), ActorError> { - self.send(Pong) - } -} - pub type PingInbox = Arc; pub type PongInbox = Arc; From 147e04cf8f0b535a5698031c075326b12493492e Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Fri, 20 Feb 2026 12:40:08 -0300 Subject: [PATCH 13/20] refactor: align main.rs caller API across approaches A and B --- examples/chat_room/src/main.rs | 6 +++--- examples/chat_room/src/user.rs | 15 +++++++++------ examples/chat_room_threads/src/main.rs | 6 +++--- examples/chat_room_threads/src/user.rs | 15 +++++++++------ examples/ping_pong/src/consumer.rs | 9 ++++++++- examples/ping_pong/src/main.rs | 6 +++--- examples/ping_pong/src/producer.rs | 9 ++++++++- examples/ping_pong/src/protocols.rs | 8 ++++++++ examples/ping_pong_threads/src/consumer.rs | 9 ++++++++- examples/ping_pong_threads/src/main.rs | 6 +++--- examples/ping_pong_threads/src/producer.rs | 9 ++++++++- examples/ping_pong_threads/src/protocols.rs | 8 ++++++++ 12 files changed, 78 insertions(+), 28 deletions(-) diff --git a/examples/chat_room/src/main.rs b/examples/chat_room/src/main.rs index 3b35782..dad9cdc 100644 --- a/examples/chat_room/src/main.rs +++ b/examples/chat_room/src/main.rs @@ -8,7 +8,7 @@ mod protocols; mod room; mod user; -use protocols::{AsBroadcaster, ChatBroadcaster}; +use protocols::ChatBroadcaster; use room::ChatRoom; use spawned_concurrency::tasks::ActorStart as _; use spawned_rt::tasks as rt; @@ -21,8 +21,8 @@ fn main() { let alice = User::new("Alice".into()).start(); let bob = User::new("Bob".into()).start(); - alice.join_room(room.as_broadcaster()).unwrap(); - bob.join_room(room.as_broadcaster()).unwrap(); + alice.join_room(room.clone()).unwrap(); + bob.join_room(room.clone()).unwrap(); rt::sleep(Duration::from_millis(10)).await; let members = room.members().await.unwrap(); diff --git a/examples/chat_room/src/user.rs b/examples/chat_room/src/user.rs index d3c1a3c..20992fe 100644 --- a/examples/chat_room/src/user.rs +++ b/examples/chat_room/src/user.rs @@ -7,7 +7,7 @@ use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; use spawned_macros::actor; use std::sync::Arc; -use crate::protocols::{AsParticipant, BroadcasterRef, ChatParticipant, ParticipantRef}; +use crate::protocols::{AsBroadcaster, AsParticipant, BroadcasterRef, ChatParticipant, ParticipantRef}; // -- Internal messages -- @@ -35,13 +35,16 @@ impl AsParticipant for ActorRef { pub trait UserActions { fn say(&self, text: String) -> Result<(), ActorError>; - fn join_room(&self, room: BroadcasterRef) -> Result<(), ActorError>; + fn join_room(&self, room: impl AsBroadcaster) -> Result<(), ActorError>; } -protocol_impl! { - UserActions for ActorRef { - send fn say(text: String) => SayToRoom; - send fn join_room(room: BroadcasterRef) => JoinRoom; +impl UserActions for ActorRef { + fn say(&self, text: String) -> Result<(), ActorError> { + self.send(SayToRoom { text }) + } + + fn join_room(&self, room: impl AsBroadcaster) -> Result<(), ActorError> { + self.send(JoinRoom { room: room.as_broadcaster() }) } } diff --git a/examples/chat_room_threads/src/main.rs b/examples/chat_room_threads/src/main.rs index 2165184..abc83af 100644 --- a/examples/chat_room_threads/src/main.rs +++ b/examples/chat_room_threads/src/main.rs @@ -7,7 +7,7 @@ mod user; use std::thread; use std::time::Duration; -use protocols::{AsBroadcaster, ChatBroadcaster}; +use protocols::ChatBroadcaster; use room::ChatRoom; use spawned_concurrency::threads::ActorStart; use spawned_rt::threads as rt; @@ -19,8 +19,8 @@ fn main() { let alice = User::new("Alice".into()).start(); let bob = User::new("Bob".into()).start(); - alice.join_room(room.as_broadcaster()).unwrap(); - bob.join_room(room.as_broadcaster()).unwrap(); + alice.join_room(room.clone()).unwrap(); + bob.join_room(room.clone()).unwrap(); thread::sleep(Duration::from_millis(10)); let members = room.members().unwrap(); diff --git a/examples/chat_room_threads/src/user.rs b/examples/chat_room_threads/src/user.rs index b42c196..cfde347 100644 --- a/examples/chat_room_threads/src/user.rs +++ b/examples/chat_room_threads/src/user.rs @@ -7,7 +7,7 @@ use spawned_concurrency::threads::{Actor, ActorRef, Context, Handler}; use spawned_macros::actor; use std::sync::Arc; -use crate::protocols::{AsParticipant, BroadcasterRef, ChatParticipant, ParticipantRef}; +use crate::protocols::{AsBroadcaster, AsParticipant, BroadcasterRef, ChatParticipant, ParticipantRef}; // -- Internal messages -- @@ -35,13 +35,16 @@ impl AsParticipant for ActorRef { pub trait UserActions { fn say(&self, text: String) -> Result<(), ActorError>; - fn join_room(&self, room: BroadcasterRef) -> Result<(), ActorError>; + fn join_room(&self, room: impl AsBroadcaster) -> Result<(), ActorError>; } -protocol_impl! { - UserActions for ActorRef { - send fn say(text: String) => SayToRoom; - send fn join_room(room: BroadcasterRef) => JoinRoom; +impl UserActions for ActorRef { + fn say(&self, text: String) -> Result<(), ActorError> { + self.send(SayToRoom { text }) + } + + fn join_room(&self, room: impl AsBroadcaster) -> Result<(), ActorError> { + self.send(JoinRoom { room: room.as_broadcaster() }) } } diff --git a/examples/ping_pong/src/consumer.rs b/examples/ping_pong/src/consumer.rs index 25cbfa8..0265a4d 100644 --- a/examples/ping_pong/src/consumer.rs +++ b/examples/ping_pong/src/consumer.rs @@ -1,8 +1,9 @@ use spawned_concurrency::protocol_impl; use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; +use std::sync::Arc; use crate::messages::Ping; -use crate::protocols::{PingReceiver, PongInbox}; +use crate::protocols::{AsPingReceiver, PingInbox, PingReceiver, PongInbox}; pub struct Consumer { pub producer: PongInbox, @@ -22,3 +23,9 @@ protocol_impl! { send fn ping() => Ping; } } + +impl AsPingReceiver for ActorRef { + fn as_ping_receiver(&self) -> PingInbox { + Arc::new(self.clone()) + } +} diff --git a/examples/ping_pong/src/main.rs b/examples/ping_pong/src/main.rs index 3378436..92cedba 100644 --- a/examples/ping_pong/src/main.rs +++ b/examples/ping_pong/src/main.rs @@ -10,9 +10,9 @@ mod protocols; use consumer::Consumer; use producer::{Producer, SetConsumer}; +use protocols::{AsPingReceiver, AsPongReceiver}; use spawned_concurrency::tasks::ActorStart as _; use spawned_rt::tasks as rt; -use std::sync::Arc; use std::time::Duration; fn main() { @@ -20,12 +20,12 @@ fn main() { let producer = Producer { consumer: None }.start(); let consumer = Consumer { - producer: Arc::new(producer.clone()), + producer: producer.as_pong_receiver(), } .start(); producer - .send(SetConsumer(Arc::new(consumer.clone()))) + .send(SetConsumer(consumer.as_ping_receiver())) .unwrap(); consumer.send(messages::Ping).unwrap(); diff --git a/examples/ping_pong/src/producer.rs b/examples/ping_pong/src/producer.rs index de1f46e..4b39123 100644 --- a/examples/ping_pong/src/producer.rs +++ b/examples/ping_pong/src/producer.rs @@ -1,9 +1,10 @@ use spawned_concurrency::message::Message; use spawned_concurrency::protocol_impl; use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; +use std::sync::Arc; use crate::messages::Pong; -use crate::protocols::{PingInbox, PongReceiver}; +use crate::protocols::{AsPongReceiver, PingInbox, PongInbox, PongReceiver}; pub struct SetConsumer(pub PingInbox); impl Message for SetConsumer { @@ -36,3 +37,9 @@ protocol_impl! { send fn pong() => Pong; } } + +impl AsPongReceiver for ActorRef { + fn as_pong_receiver(&self) -> PongInbox { + Arc::new(self.clone()) + } +} diff --git a/examples/ping_pong/src/protocols.rs b/examples/ping_pong/src/protocols.rs index c4f4ad2..c5b2dc2 100644 --- a/examples/ping_pong/src/protocols.rs +++ b/examples/ping_pong/src/protocols.rs @@ -11,3 +11,11 @@ pub trait PongReceiver: Send + Sync { pub type PingInbox = Arc; pub type PongInbox = Arc; + +pub trait AsPingReceiver { + fn as_ping_receiver(&self) -> PingInbox; +} + +pub trait AsPongReceiver { + fn as_pong_receiver(&self) -> PongInbox; +} diff --git a/examples/ping_pong_threads/src/consumer.rs b/examples/ping_pong_threads/src/consumer.rs index bce60ae..ec63ebf 100644 --- a/examples/ping_pong_threads/src/consumer.rs +++ b/examples/ping_pong_threads/src/consumer.rs @@ -1,8 +1,9 @@ use spawned_concurrency::protocol_impl; use spawned_concurrency::threads::{Actor, ActorRef, Context, Handler}; +use std::sync::Arc; use crate::messages::Ping; -use crate::protocols::{PingReceiver, PongInbox}; +use crate::protocols::{AsPingReceiver, PingInbox, PingReceiver, PongInbox}; pub struct Consumer { pub producer: PongInbox, @@ -22,3 +23,9 @@ protocol_impl! { send fn ping() => Ping; } } + +impl AsPingReceiver for ActorRef { + fn as_ping_receiver(&self) -> PingInbox { + Arc::new(self.clone()) + } +} diff --git a/examples/ping_pong_threads/src/main.rs b/examples/ping_pong_threads/src/main.rs index 2eeeaa9..3bd1954 100644 --- a/examples/ping_pong_threads/src/main.rs +++ b/examples/ping_pong_threads/src/main.rs @@ -7,21 +7,21 @@ use std::{thread, time::Duration}; use consumer::Consumer; use producer::{Producer, SetConsumer}; +use protocols::{AsPingReceiver, AsPongReceiver}; use spawned_concurrency::threads::ActorStart as _; use spawned_rt::threads as rt; -use std::sync::Arc; fn main() { rt::run(|| { let producer = Producer { consumer: None }.start(); let consumer = Consumer { - producer: Arc::new(producer.clone()), + producer: producer.as_pong_receiver(), } .start(); producer - .send(SetConsumer(Arc::new(consumer.clone()))) + .send(SetConsumer(consumer.as_ping_receiver())) .unwrap(); consumer.send(messages::Ping).unwrap(); diff --git a/examples/ping_pong_threads/src/producer.rs b/examples/ping_pong_threads/src/producer.rs index 847a67f..c2af323 100644 --- a/examples/ping_pong_threads/src/producer.rs +++ b/examples/ping_pong_threads/src/producer.rs @@ -1,9 +1,10 @@ use spawned_concurrency::message::Message; use spawned_concurrency::protocol_impl; use spawned_concurrency::threads::{Actor, ActorRef, Context, Handler}; +use std::sync::Arc; use crate::messages::Pong; -use crate::protocols::{PingInbox, PongReceiver}; +use crate::protocols::{AsPongReceiver, PingInbox, PongInbox, PongReceiver}; pub struct SetConsumer(pub PingInbox); impl Message for SetConsumer { @@ -36,3 +37,9 @@ protocol_impl! { send fn pong() => Pong; } } + +impl AsPongReceiver for ActorRef { + fn as_pong_receiver(&self) -> PongInbox { + Arc::new(self.clone()) + } +} diff --git a/examples/ping_pong_threads/src/protocols.rs b/examples/ping_pong_threads/src/protocols.rs index c4f4ad2..c5b2dc2 100644 --- a/examples/ping_pong_threads/src/protocols.rs +++ b/examples/ping_pong_threads/src/protocols.rs @@ -11,3 +11,11 @@ pub trait PongReceiver: Send + Sync { pub type PingInbox = Arc; pub type PongInbox = Arc; + +pub trait AsPingReceiver { + fn as_ping_receiver(&self) -> PingInbox; +} + +pub trait AsPongReceiver { + fn as_pong_receiver(&self) -> PongInbox; +} From eb51795a5adba13458890dd63b4c76c2bc5505c0 Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Mon, 23 Feb 2026 19:10:25 -0300 Subject: [PATCH 14/20] feat: add #[protocol] macro with blanket impls, rewrite all examples --- Cargo.lock | 2 + examples/chat_room/src/main.rs | 18 +- examples/chat_room/src/protocols.rs | 28 +- examples/chat_room/src/room.rs | 58 +-- examples/chat_room/src/user.rs | 70 +--- examples/chat_room_threads/src/main.rs | 14 +- examples/chat_room_threads/src/protocols.rs | 25 +- examples/chat_room_threads/src/room.rs | 59 +-- examples/chat_room_threads/src/user.rs | 70 +--- examples/ping_pong/Cargo.toml | 1 + examples/ping_pong/src/consumer.rs | 29 +- examples/ping_pong/src/main.rs | 10 +- examples/ping_pong/src/messages.rs | 6 - examples/ping_pong/src/producer.rs | 36 +- examples/ping_pong/src/protocols.rs | 17 +- examples/ping_pong_threads/Cargo.toml | 1 + examples/ping_pong_threads/src/consumer.rs | 29 +- examples/ping_pong_threads/src/main.rs | 5 +- examples/ping_pong_threads/src/messages.rs | 6 - examples/ping_pong_threads/src/producer.rs | 36 +- examples/ping_pong_threads/src/protocols.rs | 17 +- macros/src/lib.rs | 379 ++++++++++++++++++-- 22 files changed, 491 insertions(+), 425 deletions(-) delete mode 100644 examples/ping_pong/src/messages.rs delete mode 100644 examples/ping_pong_threads/src/messages.rs diff --git a/Cargo.lock b/Cargo.lock index 089bb36..fbd8372 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -904,6 +904,7 @@ name = "ping_pong" version = "0.1.0" dependencies = [ "spawned-concurrency", + "spawned-macros", "spawned-rt", "tracing", ] @@ -913,6 +914,7 @@ name = "ping_pong_threads" version = "0.1.0" dependencies = [ "spawned-concurrency", + "spawned-macros", "spawned-rt", "tracing", ] diff --git a/examples/chat_room/src/main.rs b/examples/chat_room/src/main.rs index dad9cdc..a8c3904 100644 --- a/examples/chat_room/src/main.rs +++ b/examples/chat_room/src/main.rs @@ -1,19 +1,13 @@ -//! Chat room example — Approach B (protocol traits + protocol_impl!). -//! -//! Room and User have ZERO direct dependencies on each other. -//! Both depend only on the protocol traits in `protocols.rs`. -//! Cross-boundary erasure is via `BroadcasterRef` and `ParticipantRef`. - mod protocols; mod room; mod user; -use protocols::ChatBroadcaster; +use protocols::{AsRoom, RoomProtocol, UserProtocol}; use room::ChatRoom; use spawned_concurrency::tasks::ActorStart as _; use spawned_rt::tasks as rt; use std::time::Duration; -use user::{User, UserActions}; +use user::User; fn main() { rt::run(async { @@ -21,15 +15,15 @@ fn main() { let alice = User::new("Alice".into()).start(); let bob = User::new("Bob".into()).start(); - alice.join_room(room.clone()).unwrap(); - bob.join_room(room.clone()).unwrap(); + alice.join_room(room.as_room()).unwrap(); + bob.join_room(room.as_room()).unwrap(); rt::sleep(Duration::from_millis(10)).await; let members = room.members().await.unwrap(); tracing::info!("Members: {:?}", members); - alice.say("Hello everyone!".into()).unwrap(); - bob.say("Hey Alice!".into()).unwrap(); + alice.speak("Hello everyone!".into()).unwrap(); + bob.speak("Hey Alice!".into()).unwrap(); rt::sleep(Duration::from_millis(50)).await; }) diff --git a/examples/chat_room/src/protocols.rs b/examples/chat_room/src/protocols.rs index cf52ac5..490b0c3 100644 --- a/examples/chat_room/src/protocols.rs +++ b/examples/chat_room/src/protocols.rs @@ -1,29 +1,21 @@ -//! Protocol traits — cross-actor contracts. -//! -//! Neither `ChatRoom` nor `User` appears here. Both actors depend only -//! on these traits, breaking circular dependencies completely. - use spawned_concurrency::error::ActorError; use spawned_concurrency::tasks::Response; +use spawned_macros::protocol; use std::sync::Arc; -pub type BroadcasterRef = Arc; -pub type ParticipantRef = Arc; +pub type RoomRef = Arc; +pub type UserRef = Arc; -pub trait ChatBroadcaster: Send + Sync { +#[protocol] +pub trait RoomProtocol: Send + Sync { fn say(&self, from: String, text: String) -> Result<(), ActorError>; - fn add_member(&self, name: String, participant: ParticipantRef) -> Result<(), ActorError>; + fn add_member(&self, name: String, user: UserRef) -> Result<(), ActorError>; fn members(&self) -> Response>; } -pub trait ChatParticipant: Send + Sync { +#[protocol] +pub trait UserProtocol: Send + Sync { fn deliver(&self, from: String, text: String) -> Result<(), ActorError>; -} - -pub trait AsBroadcaster { - fn as_broadcaster(&self) -> BroadcasterRef; -} - -pub trait AsParticipant { - fn as_participant(&self) -> ParticipantRef; + fn speak(&self, text: String) -> Result<(), ActorError>; + fn join_room(&self, room: RoomRef) -> Result<(), ActorError>; } diff --git a/examples/chat_room/src/room.rs b/examples/chat_room/src/room.rs index e2654be..1809cc0 100644 --- a/examples/chat_room/src/room.rs +++ b/examples/chat_room/src/room.rs @@ -1,45 +1,11 @@ -//! ChatRoom actor — knows about protocol traits, not User. - -use spawned_concurrency::protocol_impl; -use spawned_concurrency::request_messages; -use spawned_concurrency::send_messages; -use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; +use spawned_concurrency::tasks::{Actor, Context, Handler}; use spawned_macros::actor; -use std::sync::Arc; - -use crate::protocols::{AsBroadcaster, BroadcasterRef, ChatBroadcaster, ParticipantRef}; - -// -- Internal messages -- - -send_messages! { - Say { from: String, text: String }; - Join { name: String, participant: ParticipantRef } -} - -request_messages! { - Members -> Vec -} - -// -- Protocol bridge -- - -protocol_impl! { - ChatBroadcaster for ActorRef { - send fn say(from: String, text: String) => Say; - send fn add_member(name: String, participant: ParticipantRef) => Join; - request fn members() -> Vec => Members; - } -} -impl AsBroadcaster for ActorRef { - fn as_broadcaster(&self) -> BroadcasterRef { - Arc::new(self.clone()) - } -} - -// -- Actor -- +use crate::protocols::room_protocol::{AddMember, Members, Say}; +use crate::protocols::UserRef; pub struct ChatRoom { - members: Vec<(String, ParticipantRef)>, + members: Vec<(String, UserRef)>, } impl Actor for ChatRoom {} @@ -52,22 +18,22 @@ impl ChatRoom { } } - #[send_handler] - async fn handle_join(&mut self, msg: Join, _ctx: &Context) { - tracing::info!("[room] {} joined", msg.name); - self.members.push((msg.name, msg.participant)); - } - #[send_handler] async fn handle_say(&mut self, msg: Say, _ctx: &Context) { tracing::info!("[room] {} says: {}", msg.from, msg.text); - for (name, participant) in &self.members { + for (name, user) in &self.members { if *name != msg.from { - let _ = participant.deliver(msg.from.clone(), msg.text.clone()); + let _ = user.deliver(msg.from.clone(), msg.text.clone()); } } } + #[send_handler] + async fn handle_add_member(&mut self, msg: AddMember, _ctx: &Context) { + tracing::info!("[room] {} joined", msg.name); + self.members.push((msg.name, msg.user)); + } + #[request_handler] async fn handle_members(&mut self, _msg: Members, _ctx: &Context) -> Vec { self.members.iter().map(|(name, _)| name.clone()).collect() diff --git a/examples/chat_room/src/user.rs b/examples/chat_room/src/user.rs index 20992fe..7204f40 100644 --- a/examples/chat_room/src/user.rs +++ b/examples/chat_room/src/user.rs @@ -1,58 +1,12 @@ -//! User actor — knows about protocol traits, not ChatRoom. - -use spawned_concurrency::error::ActorError; -use spawned_concurrency::protocol_impl; -use spawned_concurrency::send_messages; -use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; +use spawned_concurrency::tasks::{Actor, Context, Handler}; use spawned_macros::actor; -use std::sync::Arc; - -use crate::protocols::{AsBroadcaster, AsParticipant, BroadcasterRef, ChatParticipant, ParticipantRef}; - -// -- Internal messages -- - -send_messages! { - Deliver { from: String, text: String }; - SayToRoom { text: String }; - JoinRoom { room: BroadcasterRef } -} - -// -- Protocol bridge (ChatParticipant) -- - -protocol_impl! { - ChatParticipant for ActorRef { - send fn deliver(from: String, text: String) => Deliver; - } -} - -impl AsParticipant for ActorRef { - fn as_participant(&self) -> ParticipantRef { - Arc::new(self.clone()) - } -} - -// -- Caller API -- - -pub trait UserActions { - fn say(&self, text: String) -> Result<(), ActorError>; - fn join_room(&self, room: impl AsBroadcaster) -> Result<(), ActorError>; -} - -impl UserActions for ActorRef { - fn say(&self, text: String) -> Result<(), ActorError> { - self.send(SayToRoom { text }) - } - - fn join_room(&self, room: impl AsBroadcaster) -> Result<(), ActorError> { - self.send(JoinRoom { room: room.as_broadcaster() }) - } -} -// -- Actor -- +use crate::protocols::user_protocol::{Deliver, JoinRoom, Speak}; +use crate::protocols::{AsUser, RoomRef}; pub struct User { name: String, - room: Option, + room: Option, } impl Actor for User {} @@ -64,22 +18,22 @@ impl User { } #[send_handler] - async fn handle_join_room(&mut self, msg: JoinRoom, ctx: &Context) { - let _ = msg - .room - .add_member(self.name.clone(), ctx.actor_ref().as_participant()); - self.room = Some(msg.room); + async fn handle_deliver(&mut self, msg: Deliver, _ctx: &Context) { + tracing::info!("[{}] got: {} says '{}'", self.name, msg.from, msg.text); } #[send_handler] - async fn handle_say_to_room(&mut self, msg: SayToRoom, _ctx: &Context) { + async fn handle_speak(&mut self, msg: Speak, _ctx: &Context) { if let Some(ref room) = self.room { let _ = room.say(self.name.clone(), msg.text); } } #[send_handler] - async fn handle_deliver(&mut self, msg: Deliver, _ctx: &Context) { - tracing::info!("[{}] got: {} says '{}'", self.name, msg.from, msg.text); + async fn handle_join_room(&mut self, msg: JoinRoom, ctx: &Context) { + let _ = msg + .room + .add_member(self.name.clone(), ctx.actor_ref().as_user()); + self.room = Some(msg.room); } } diff --git a/examples/chat_room_threads/src/main.rs b/examples/chat_room_threads/src/main.rs index abc83af..e7cbbfd 100644 --- a/examples/chat_room_threads/src/main.rs +++ b/examples/chat_room_threads/src/main.rs @@ -1,5 +1,3 @@ -//! Chat room example — Approach B (protocol traits + protocol_impl!, sync/threads). - mod protocols; mod room; mod user; @@ -7,11 +5,11 @@ mod user; use std::thread; use std::time::Duration; -use protocols::ChatBroadcaster; +use protocols::{AsRoom, RoomProtocol, UserProtocol}; use room::ChatRoom; use spawned_concurrency::threads::ActorStart; use spawned_rt::threads as rt; -use user::{User, UserActions}; +use user::User; fn main() { rt::run(|| { @@ -19,15 +17,15 @@ fn main() { let alice = User::new("Alice".into()).start(); let bob = User::new("Bob".into()).start(); - alice.join_room(room.clone()).unwrap(); - bob.join_room(room.clone()).unwrap(); + alice.join_room(room.as_room()).unwrap(); + bob.join_room(room.as_room()).unwrap(); thread::sleep(Duration::from_millis(10)); let members = room.members().unwrap(); tracing::info!("Members in room: {:?}", members); - alice.say("Hello everyone!".into()).unwrap(); - bob.say("Hi Alice!".into()).unwrap(); + alice.speak("Hello everyone!".into()).unwrap(); + bob.speak("Hi Alice!".into()).unwrap(); thread::sleep(Duration::from_millis(100)); tracing::info!("Chat room demo complete"); diff --git a/examples/chat_room_threads/src/protocols.rs b/examples/chat_room_threads/src/protocols.rs index 80249ef..8b84e59 100644 --- a/examples/chat_room_threads/src/protocols.rs +++ b/examples/chat_room_threads/src/protocols.rs @@ -1,25 +1,20 @@ -//! Protocol traits — cross-actor contracts (sync/threads version). - use spawned_concurrency::error::ActorError; +use spawned_macros::protocol; use std::sync::Arc; -pub type BroadcasterRef = Arc; -pub type ParticipantRef = Arc; +pub type RoomRef = Arc; +pub type UserRef = Arc; -pub trait ChatBroadcaster: Send + Sync { +#[protocol] +pub trait RoomProtocol: Send + Sync { fn say(&self, from: String, text: String) -> Result<(), ActorError>; - fn add_member(&self, name: String, participant: ParticipantRef) -> Result<(), ActorError>; + fn add_member(&self, name: String, user: UserRef) -> Result<(), ActorError>; fn members(&self) -> Result, ActorError>; } -pub trait ChatParticipant: Send + Sync { +#[protocol] +pub trait UserProtocol: Send + Sync { fn deliver(&self, from: String, text: String) -> Result<(), ActorError>; -} - -pub trait AsBroadcaster { - fn as_broadcaster(&self) -> BroadcasterRef; -} - -pub trait AsParticipant { - fn as_participant(&self) -> ParticipantRef; + fn speak(&self, text: String) -> Result<(), ActorError>; + fn join_room(&self, room: RoomRef) -> Result<(), ActorError>; } diff --git a/examples/chat_room_threads/src/room.rs b/examples/chat_room_threads/src/room.rs index 7c70de4..5a3eac0 100644 --- a/examples/chat_room_threads/src/room.rs +++ b/examples/chat_room_threads/src/room.rs @@ -1,45 +1,11 @@ -//! ChatRoom actor — knows about protocol traits, not User (sync/threads version). - -use spawned_concurrency::protocol_impl; -use spawned_concurrency::request_messages; -use spawned_concurrency::send_messages; -use spawned_concurrency::threads::{Actor, ActorRef, Context, Handler}; +use spawned_concurrency::threads::{Actor, Context, Handler}; use spawned_macros::actor; -use std::sync::Arc; - -use crate::protocols::{AsBroadcaster, BroadcasterRef, ChatBroadcaster, ParticipantRef}; - -// -- Internal messages -- - -send_messages! { - Say { from: String, text: String }; - Join { name: String, participant: ParticipantRef } -} - -request_messages! { - Members -> Vec -} - -// -- Protocol bridge -- - -protocol_impl! { - ChatBroadcaster for ActorRef { - send fn say(from: String, text: String) => Say; - send fn add_member(name: String, participant: ParticipantRef) => Join; - request sync fn members() -> Vec => Members; - } -} -impl AsBroadcaster for ActorRef { - fn as_broadcaster(&self) -> BroadcasterRef { - Arc::new(self.clone()) - } -} - -// -- Actor -- +use crate::protocols::room_protocol::{AddMember, Members, Say}; +use crate::protocols::UserRef; pub struct ChatRoom { - members: Vec<(String, ParticipantRef)>, + members: Vec<(String, UserRef)>, } impl Actor for ChatRoom {} @@ -52,21 +18,22 @@ impl ChatRoom { } } - #[send_handler] - fn handle_join(&mut self, msg: Join, _ctx: &Context) { - tracing::info!("[room] {} joined", msg.name); - self.members.push((msg.name, msg.participant)); - } - #[send_handler] fn handle_say(&mut self, msg: Say, _ctx: &Context) { - for (name, participant) in &self.members { + tracing::info!("[room] {} says: {}", msg.from, msg.text); + for (name, user) in &self.members { if *name != msg.from { - let _ = participant.deliver(msg.from.clone(), msg.text.clone()); + let _ = user.deliver(msg.from.clone(), msg.text.clone()); } } } + #[send_handler] + fn handle_add_member(&mut self, msg: AddMember, _ctx: &Context) { + tracing::info!("[room] {} joined", msg.name); + self.members.push((msg.name, msg.user)); + } + #[request_handler] fn handle_members(&mut self, _msg: Members, _ctx: &Context) -> Vec { self.members.iter().map(|(name, _)| name.clone()).collect() diff --git a/examples/chat_room_threads/src/user.rs b/examples/chat_room_threads/src/user.rs index cfde347..d1517a7 100644 --- a/examples/chat_room_threads/src/user.rs +++ b/examples/chat_room_threads/src/user.rs @@ -1,58 +1,12 @@ -//! User actor — knows about protocol traits, not ChatRoom (sync/threads version). - -use spawned_concurrency::error::ActorError; -use spawned_concurrency::protocol_impl; -use spawned_concurrency::send_messages; -use spawned_concurrency::threads::{Actor, ActorRef, Context, Handler}; +use spawned_concurrency::threads::{Actor, Context, Handler}; use spawned_macros::actor; -use std::sync::Arc; - -use crate::protocols::{AsBroadcaster, AsParticipant, BroadcasterRef, ChatParticipant, ParticipantRef}; - -// -- Internal messages -- - -send_messages! { - Deliver { from: String, text: String }; - SayToRoom { text: String }; - JoinRoom { room: BroadcasterRef } -} - -// -- Protocol bridge (ChatParticipant) -- - -protocol_impl! { - ChatParticipant for ActorRef { - send fn deliver(from: String, text: String) => Deliver; - } -} - -impl AsParticipant for ActorRef { - fn as_participant(&self) -> ParticipantRef { - Arc::new(self.clone()) - } -} - -// -- Caller API -- - -pub trait UserActions { - fn say(&self, text: String) -> Result<(), ActorError>; - fn join_room(&self, room: impl AsBroadcaster) -> Result<(), ActorError>; -} - -impl UserActions for ActorRef { - fn say(&self, text: String) -> Result<(), ActorError> { - self.send(SayToRoom { text }) - } - - fn join_room(&self, room: impl AsBroadcaster) -> Result<(), ActorError> { - self.send(JoinRoom { room: room.as_broadcaster() }) - } -} -// -- Actor -- +use crate::protocols::user_protocol::{Deliver, JoinRoom, Speak}; +use crate::protocols::{AsUser, RoomRef}; pub struct User { name: String, - room: Option, + room: Option, } impl Actor for User {} @@ -64,22 +18,22 @@ impl User { } #[send_handler] - fn handle_join_room(&mut self, msg: JoinRoom, ctx: &Context) { - let _ = msg - .room - .add_member(self.name.clone(), ctx.actor_ref().as_participant()); - self.room = Some(msg.room); + fn handle_deliver(&mut self, msg: Deliver, _ctx: &Context) { + tracing::info!("[{}] got: {} says '{}'", self.name, msg.from, msg.text); } #[send_handler] - fn handle_say_to_room(&mut self, msg: SayToRoom, _ctx: &Context) { + fn handle_speak(&mut self, msg: Speak, _ctx: &Context) { if let Some(ref room) = self.room { let _ = room.say(self.name.clone(), msg.text); } } #[send_handler] - fn handle_deliver(&mut self, msg: Deliver, _ctx: &Context) { - tracing::info!("[{}] got: {} says '{}'", self.name, msg.from, msg.text); + fn handle_join_room(&mut self, msg: JoinRoom, ctx: &Context) { + let _ = msg + .room + .add_member(self.name.clone(), ctx.actor_ref().as_user()); + self.room = Some(msg.room); } } diff --git a/examples/ping_pong/Cargo.toml b/examples/ping_pong/Cargo.toml index 67266c7..ee6c988 100644 --- a/examples/ping_pong/Cargo.toml +++ b/examples/ping_pong/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] spawned-rt = { workspace = true } spawned-concurrency = { workspace = true } +spawned-macros = { workspace = true } tracing = { workspace = true } [[bin]] diff --git a/examples/ping_pong/src/consumer.rs b/examples/ping_pong/src/consumer.rs index 0265a4d..0615bea 100644 --- a/examples/ping_pong/src/consumer.rs +++ b/examples/ping_pong/src/consumer.rs @@ -1,31 +1,20 @@ -use spawned_concurrency::protocol_impl; -use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; -use std::sync::Arc; +use spawned_concurrency::tasks::{Actor, Context, Handler}; +use spawned_macros::actor; -use crate::messages::Ping; -use crate::protocols::{AsPingReceiver, PingInbox, PingReceiver, PongInbox}; +use crate::protocols::ping_receiver::Ping; +use crate::protocols::PongReceiverRef; pub struct Consumer { - pub producer: PongInbox, + pub producer: PongReceiverRef, } impl Actor for Consumer {} -impl Handler for Consumer { - async fn handle(&mut self, _msg: Ping, _ctx: &Context) { +#[actor] +impl Consumer { + #[send_handler] + async fn handle_ping(&mut self, _msg: Ping, _ctx: &Context) { tracing::info!("Consumer received Ping, sending Pong"); let _ = self.producer.pong(); } } - -protocol_impl! { - PingReceiver for ActorRef { - send fn ping() => Ping; - } -} - -impl AsPingReceiver for ActorRef { - fn as_ping_receiver(&self) -> PingInbox { - Arc::new(self.clone()) - } -} diff --git a/examples/ping_pong/src/main.rs b/examples/ping_pong/src/main.rs index 92cedba..2518302 100644 --- a/examples/ping_pong/src/main.rs +++ b/examples/ping_pong/src/main.rs @@ -1,16 +1,10 @@ -//! Ping-pong example — Approach B (protocol traits + protocol_impl!). -//! -//! Consumer and Producer don't know each other's concrete types. -//! They only depend on the PingReceiver and PongReceiver protocol traits. - mod consumer; -mod messages; mod producer; mod protocols; use consumer::Consumer; use producer::{Producer, SetConsumer}; -use protocols::{AsPingReceiver, AsPongReceiver}; +use protocols::{AsPingReceiver, AsPongReceiver, PingReceiver}; use spawned_concurrency::tasks::ActorStart as _; use spawned_rt::tasks as rt; use std::time::Duration; @@ -28,7 +22,7 @@ fn main() { .send(SetConsumer(consumer.as_ping_receiver())) .unwrap(); - consumer.send(messages::Ping).unwrap(); + consumer.ping().unwrap(); rt::sleep(Duration::from_millis(1)).await; }) diff --git a/examples/ping_pong/src/messages.rs b/examples/ping_pong/src/messages.rs deleted file mode 100644 index 2740ff9..0000000 --- a/examples/ping_pong/src/messages.rs +++ /dev/null @@ -1,6 +0,0 @@ -use spawned_concurrency::send_messages; - -send_messages! { - Ping; - Pong -} diff --git a/examples/ping_pong/src/producer.rs b/examples/ping_pong/src/producer.rs index 4b39123..82f85cc 100644 --- a/examples/ping_pong/src/producer.rs +++ b/examples/ping_pong/src/producer.rs @@ -1,45 +1,33 @@ use spawned_concurrency::message::Message; -use spawned_concurrency::protocol_impl; -use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; -use std::sync::Arc; +use spawned_concurrency::tasks::{Actor, Context, Handler}; +use spawned_macros::actor; -use crate::messages::Pong; -use crate::protocols::{AsPongReceiver, PingInbox, PongInbox, PongReceiver}; +use crate::protocols::pong_receiver::Pong; +use crate::protocols::PingReceiverRef; -pub struct SetConsumer(pub PingInbox); +pub struct SetConsumer(pub PingReceiverRef); impl Message for SetConsumer { type Result = (); } pub struct Producer { - pub consumer: Option, + pub consumer: Option, } impl Actor for Producer {} -impl Handler for Producer { - async fn handle(&mut self, msg: SetConsumer, _ctx: &Context) { +#[actor] +impl Producer { + #[send_handler] + async fn handle_set_consumer(&mut self, msg: SetConsumer, _ctx: &Context) { self.consumer = Some(msg.0); } -} -impl Handler for Producer { - async fn handle(&mut self, _msg: Pong, _ctx: &Context) { + #[send_handler] + async fn handle_pong(&mut self, _msg: Pong, _ctx: &Context) { tracing::info!("Producer received Pong, sending Ping"); if let Some(consumer) = &self.consumer { let _ = consumer.ping(); } } } - -protocol_impl! { - PongReceiver for ActorRef { - send fn pong() => Pong; - } -} - -impl AsPongReceiver for ActorRef { - fn as_pong_receiver(&self) -> PongInbox { - Arc::new(self.clone()) - } -} diff --git a/examples/ping_pong/src/protocols.rs b/examples/ping_pong/src/protocols.rs index c5b2dc2..0bc9fcd 100644 --- a/examples/ping_pong/src/protocols.rs +++ b/examples/ping_pong/src/protocols.rs @@ -1,21 +1,16 @@ use spawned_concurrency::error::ActorError; +use spawned_macros::protocol; use std::sync::Arc; +pub type PingReceiverRef = Arc; +pub type PongReceiverRef = Arc; + +#[protocol] pub trait PingReceiver: Send + Sync { fn ping(&self) -> Result<(), ActorError>; } +#[protocol] pub trait PongReceiver: Send + Sync { fn pong(&self) -> Result<(), ActorError>; } - -pub type PingInbox = Arc; -pub type PongInbox = Arc; - -pub trait AsPingReceiver { - fn as_ping_receiver(&self) -> PingInbox; -} - -pub trait AsPongReceiver { - fn as_pong_receiver(&self) -> PongInbox; -} diff --git a/examples/ping_pong_threads/Cargo.toml b/examples/ping_pong_threads/Cargo.toml index fb2b28a..43422b6 100644 --- a/examples/ping_pong_threads/Cargo.toml +++ b/examples/ping_pong_threads/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] spawned-rt = { workspace = true } spawned-concurrency = { workspace = true } +spawned-macros = { workspace = true } tracing = { workspace = true } [[bin]] diff --git a/examples/ping_pong_threads/src/consumer.rs b/examples/ping_pong_threads/src/consumer.rs index ec63ebf..383baa3 100644 --- a/examples/ping_pong_threads/src/consumer.rs +++ b/examples/ping_pong_threads/src/consumer.rs @@ -1,31 +1,20 @@ -use spawned_concurrency::protocol_impl; -use spawned_concurrency::threads::{Actor, ActorRef, Context, Handler}; -use std::sync::Arc; +use spawned_concurrency::threads::{Actor, Context, Handler}; +use spawned_macros::actor; -use crate::messages::Ping; -use crate::protocols::{AsPingReceiver, PingInbox, PingReceiver, PongInbox}; +use crate::protocols::ping_receiver::Ping; +use crate::protocols::PongReceiverRef; pub struct Consumer { - pub producer: PongInbox, + pub producer: PongReceiverRef, } impl Actor for Consumer {} -impl Handler for Consumer { - fn handle(&mut self, _msg: Ping, _ctx: &Context) { +#[actor] +impl Consumer { + #[send_handler] + fn handle_ping(&mut self, _msg: Ping, _ctx: &Context) { tracing::info!("Consumer received Ping, sending Pong"); let _ = self.producer.pong(); } } - -protocol_impl! { - PingReceiver for ActorRef { - send fn ping() => Ping; - } -} - -impl AsPingReceiver for ActorRef { - fn as_ping_receiver(&self) -> PingInbox { - Arc::new(self.clone()) - } -} diff --git a/examples/ping_pong_threads/src/main.rs b/examples/ping_pong_threads/src/main.rs index 3bd1954..97695c7 100644 --- a/examples/ping_pong_threads/src/main.rs +++ b/examples/ping_pong_threads/src/main.rs @@ -1,5 +1,4 @@ mod consumer; -mod messages; mod producer; mod protocols; @@ -7,7 +6,7 @@ use std::{thread, time::Duration}; use consumer::Consumer; use producer::{Producer, SetConsumer}; -use protocols::{AsPingReceiver, AsPongReceiver}; +use protocols::{AsPingReceiver, AsPongReceiver, PingReceiver}; use spawned_concurrency::threads::ActorStart as _; use spawned_rt::threads as rt; @@ -24,7 +23,7 @@ fn main() { .send(SetConsumer(consumer.as_ping_receiver())) .unwrap(); - consumer.send(messages::Ping).unwrap(); + consumer.ping().unwrap(); thread::sleep(Duration::from_millis(1)); }) diff --git a/examples/ping_pong_threads/src/messages.rs b/examples/ping_pong_threads/src/messages.rs deleted file mode 100644 index 2740ff9..0000000 --- a/examples/ping_pong_threads/src/messages.rs +++ /dev/null @@ -1,6 +0,0 @@ -use spawned_concurrency::send_messages; - -send_messages! { - Ping; - Pong -} diff --git a/examples/ping_pong_threads/src/producer.rs b/examples/ping_pong_threads/src/producer.rs index c2af323..6230213 100644 --- a/examples/ping_pong_threads/src/producer.rs +++ b/examples/ping_pong_threads/src/producer.rs @@ -1,45 +1,33 @@ use spawned_concurrency::message::Message; -use spawned_concurrency::protocol_impl; -use spawned_concurrency::threads::{Actor, ActorRef, Context, Handler}; -use std::sync::Arc; +use spawned_concurrency::threads::{Actor, Context, Handler}; +use spawned_macros::actor; -use crate::messages::Pong; -use crate::protocols::{AsPongReceiver, PingInbox, PongInbox, PongReceiver}; +use crate::protocols::pong_receiver::Pong; +use crate::protocols::PingReceiverRef; -pub struct SetConsumer(pub PingInbox); +pub struct SetConsumer(pub PingReceiverRef); impl Message for SetConsumer { type Result = (); } pub struct Producer { - pub consumer: Option, + pub consumer: Option, } impl Actor for Producer {} -impl Handler for Producer { - fn handle(&mut self, msg: SetConsumer, _ctx: &Context) { +#[actor] +impl Producer { + #[send_handler] + fn handle_set_consumer(&mut self, msg: SetConsumer, _ctx: &Context) { self.consumer = Some(msg.0); } -} -impl Handler for Producer { - fn handle(&mut self, _msg: Pong, _ctx: &Context) { + #[send_handler] + fn handle_pong(&mut self, _msg: Pong, _ctx: &Context) { tracing::info!("Producer received Pong, sending Ping"); if let Some(consumer) = &self.consumer { let _ = consumer.ping(); } } } - -protocol_impl! { - PongReceiver for ActorRef { - send fn pong() => Pong; - } -} - -impl AsPongReceiver for ActorRef { - fn as_pong_receiver(&self) -> PongInbox { - Arc::new(self.clone()) - } -} diff --git a/examples/ping_pong_threads/src/protocols.rs b/examples/ping_pong_threads/src/protocols.rs index c5b2dc2..0bc9fcd 100644 --- a/examples/ping_pong_threads/src/protocols.rs +++ b/examples/ping_pong_threads/src/protocols.rs @@ -1,21 +1,16 @@ use spawned_concurrency::error::ActorError; +use spawned_macros::protocol; use std::sync::Arc; +pub type PingReceiverRef = Arc; +pub type PongReceiverRef = Arc; + +#[protocol] pub trait PingReceiver: Send + Sync { fn ping(&self) -> Result<(), ActorError>; } +#[protocol] pub trait PongReceiver: Send + Sync { fn pong(&self) -> Result<(), ActorError>; } - -pub type PingInbox = Arc; -pub type PongInbox = Arc; - -pub trait AsPingReceiver { - fn as_ping_receiver(&self) -> PingInbox; -} - -pub trait AsPongReceiver { - fn as_pong_receiver(&self) -> PongInbox; -} diff --git a/macros/src/lib.rs b/macros/src/lib.rs index f8dda17..63c5f51 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -1,36 +1,351 @@ use proc_macro::TokenStream; -use quote::quote; -use syn::{parse_macro_input, FnArg, ImplItem, ItemImpl, Pat, ReturnType, Type}; +use quote::{format_ident, quote}; +use syn::{ + parse_macro_input, FnArg, GenericArgument, Ident, ImplItem, ItemImpl, ItemTrait, Pat, + PathArguments, ReturnType, TraitItem, Type, TypePath, +}; + +// --- Helpers for #[protocol] --- + +fn to_snake_case(s: &str) -> String { + let mut result = String::new(); + for (i, ch) in s.chars().enumerate() { + if ch.is_uppercase() { + if i > 0 { + result.push('_'); + } + result.push(ch.to_ascii_lowercase()); + } else { + result.push(ch); + } + } + result +} + +fn to_pascal_case(s: &str) -> String { + s.split('_') + .map(|part| { + let mut chars = part.chars(); + match chars.next() { + None => String::new(), + Some(c) => c.to_uppercase().to_string() + chars.as_str(), + } + }) + .collect() +} + +fn strip_protocol_suffix(name: &str) -> String { + name.strip_suffix("Protocol").unwrap_or(name).to_string() +} + +enum MethodKind { + Send, + AsyncRequest(Box), + SyncRequest(Box), +} + +fn classify_return_type(ret: &ReturnType) -> MethodKind { + match ret { + ReturnType::Default => MethodKind::Send, + ReturnType::Type(_, ty) => { + if let Some(inner) = extract_response_inner(ty) { + return MethodKind::AsyncRequest(inner); + } + if let Some(inner) = extract_result_inner(ty) { + if is_unit_type(&inner) { + return MethodKind::Send; + } + return MethodKind::SyncRequest(inner); + } + MethodKind::Send + } + } +} + +fn extract_response_inner(ty: &Type) -> Option> { + if let Type::Path(TypePath { path, .. }) = ty { + let seg = path.segments.last()?; + if seg.ident == "Response" { + if let PathArguments::AngleBracketed(args) = &seg.arguments { + if let Some(GenericArgument::Type(inner)) = args.args.first() { + return Some(Box::new(inner.clone())); + } + } + } + } + None +} + +fn extract_result_inner(ty: &Type) -> Option> { + if let Type::Path(TypePath { path, .. }) = ty { + let seg = path.segments.last()?; + if seg.ident == "Result" { + if let PathArguments::AngleBracketed(args) = &seg.arguments { + if let Some(GenericArgument::Type(inner)) = args.args.first() { + return Some(Box::new(inner.clone())); + } + } + } + } + None +} + +fn is_unit_type(ty: &Type) -> bool { + if let Type::Tuple(tuple) = ty { + return tuple.elems.is_empty(); + } + false +} + +/// Generates a blanket `impl Protocol for ActorRef` and `impl AsX for ActorRef` +/// for a given runtime path (tasks or threads). +fn generate_blanket_impl( + trait_name: &Ident, + mod_name: &Ident, + ref_name: &Ident, + converter_trait: &Ident, + converter_method: &Ident, + methods: &[ProtocolMethodInfo], + runtime_path: &proc_macro2::TokenStream, +) -> proc_macro2::TokenStream { + let handler_bounds: Vec<_> = methods + .iter() + .map(|m| { + let sn = &m.struct_name; + quote! { #runtime_path::Handler<#mod_name::#sn> } + }) + .collect(); + + let method_impls: Vec<_> = methods + .iter() + .map(|m| { + let method_name = &m.method_name; + let field_names = &m.field_names; + let params: Vec<_> = m.params.iter().collect(); + let ret_ty = &m.ret_type; + + let struct_name = &m.struct_name; + let msg_construct = if field_names.is_empty() { + quote! { #mod_name::#struct_name } + } else { + quote! { #mod_name::#struct_name { #(#field_names),* } } + }; + + match &m.kind { + MethodKind::Send => { + quote! { + fn #method_name(&self, #(#params),*) #ret_ty { + self.send(#msg_construct) + } + } + } + MethodKind::AsyncRequest(_) => { + quote! { + fn #method_name(&self, #(#params),*) #ret_ty { + spawned_concurrency::tasks::Response::from( + self.request_raw(#msg_construct), + ) + } + } + } + MethodKind::SyncRequest(_) => { + quote! { + fn #method_name(&self, #(#params),*) #ret_ty { + self.request(#msg_construct) + } + } + } + } + }) + .collect(); + + quote! { + impl<__A: #runtime_path::Actor #(+ #handler_bounds)*> #trait_name + for #runtime_path::ActorRef<__A> + { + #(#method_impls)* + } + + impl<__A: #runtime_path::Actor #(+ #handler_bounds)*> #converter_trait + for #runtime_path::ActorRef<__A> + { + fn #converter_method(&self) -> #ref_name { + ::std::sync::Arc::new(self.clone()) + } + } + } +} + +struct ProtocolMethodInfo { + method_name: Ident, + struct_name: Ident, + field_names: Vec, + field_types: Vec>, + kind: MethodKind, + params: Vec, + ret_type: ReturnType, +} + +#[proc_macro_attribute] +pub fn protocol(_attr: TokenStream, item: TokenStream) -> TokenStream { + let trait_def = parse_macro_input!(item as ItemTrait); + let trait_name = &trait_def.ident; + let trait_vis = &trait_def.vis; + + let base_name = strip_protocol_suffix(&trait_name.to_string()); + let mod_name = format_ident!("{}", to_snake_case(&trait_name.to_string())); + let ref_name = format_ident!("{}Ref", base_name); + let converter_trait = format_ident!("As{}", base_name); + let converter_method = format_ident!("as_{}", to_snake_case(&base_name)); + + let mut methods: Vec = Vec::new(); + let mut has_async_request = false; + let mut has_sync_request = false; + + for item in &trait_def.items { + if let TraitItem::Fn(method) = item { + let method_name = method.sig.ident.clone(); + let struct_name = format_ident!("{}", to_pascal_case(&method_name.to_string())); + + let mut field_names: Vec = Vec::new(); + let mut field_types: Vec> = Vec::new(); + let mut params: Vec = Vec::new(); + + for arg in method.sig.inputs.iter().skip(1) { + if let FnArg::Typed(pat_type) = arg { + if let Pat::Ident(pat_ident) = &*pat_type.pat { + field_names.push(pat_ident.ident.clone()); + field_types.push(pat_type.ty.clone()); + } + } + params.push(arg.clone()); + } + + let kind = classify_return_type(&method.sig.output); + match &kind { + MethodKind::AsyncRequest(_) => has_async_request = true, + MethodKind::SyncRequest(_) => has_sync_request = true, + MethodKind::Send => {} + } + + methods.push(ProtocolMethodInfo { + method_name, + struct_name, + field_names, + field_types, + kind, + params, + ret_type: method.sig.output.clone(), + }); + } + } + + // Generate message structs + let msg_structs: Vec<_> = methods + .iter() + .map(|m| { + let struct_name = &m.struct_name; + let field_names = &m.field_names; + let field_types = &m.field_types; + let msg_result_ty: Box = match &m.kind { + MethodKind::Send => syn::parse_quote! { () }, + MethodKind::AsyncRequest(inner) | MethodKind::SyncRequest(inner) => inner.clone(), + }; + + if field_names.is_empty() { + quote! { + pub struct #struct_name; + impl Message for #struct_name { + type Result = #msg_result_ty; + } + } + } else { + quote! { + pub struct #struct_name { + #(pub #field_names: #field_types,)* + } + impl Message for #struct_name { + type Result = #msg_result_ty; + } + } + } + }) + .collect(); + + // Generate blanket impls based on protocol mode + let blanket_impls = if has_async_request { + let tasks = quote! { spawned_concurrency::tasks }; + generate_blanket_impl( + trait_name, + &mod_name, + &ref_name, + &converter_trait, + &converter_method, + &methods, + &tasks, + ) + } else if has_sync_request { + let threads = quote! { spawned_concurrency::threads }; + generate_blanket_impl( + trait_name, + &mod_name, + &ref_name, + &converter_trait, + &converter_method, + &methods, + &threads, + ) + } else { + // Send-only: generate for both runtimes + let tasks = quote! { spawned_concurrency::tasks }; + let threads = quote! { spawned_concurrency::threads }; + let tasks_impl = generate_blanket_impl( + trait_name, + &mod_name, + &ref_name, + &converter_trait, + &converter_method, + &methods, + &tasks, + ); + let threads_impl = generate_blanket_impl( + trait_name, + &mod_name, + &ref_name, + &converter_trait, + &converter_method, + &methods, + &threads, + ); + quote! { #tasks_impl #threads_impl } + }; + + let output = quote! { + #trait_def + + #trait_vis mod #mod_name { + use super::*; + use spawned_concurrency::message::Message; + #(#msg_structs)* + } + + #trait_vis trait #converter_trait { + fn #converter_method(&self) -> #ref_name; + } + + impl #converter_trait for #ref_name { + fn #converter_method(&self) -> #ref_name { + ::std::sync::Arc::clone(self) + } + } + + #blanket_impls + }; + + output.into() +} /// Attribute macro for actor impl blocks. -/// -/// Place `#[actor]` on an `impl MyActor` block containing methods annotated -/// with `#[send_handler]` or `#[request_handler]`. For each annotated method, -/// the macro generates a corresponding `impl Handler for MyActor` block. -/// -/// Use `#[send_handler]` for fire-and-forget messages (no return value): -/// -/// ```ignore -/// #[send_handler] -/// async fn on_deposit(&mut self, msg: Deposit, ctx: &Context) { ... } -/// ``` -/// -/// Use `#[request_handler]` for request-response messages (returns a value): -/// -/// ```ignore -/// #[request_handler] -/// async fn on_balance(&mut self, msg: GetBalance, ctx: &Context) -> u64 { ... } -/// ``` -/// -/// Sync handlers (for the `threads` module) omit `async`: -/// -/// ```ignore -/// #[send_handler] -/// fn on_deposit(&mut self, msg: Deposit, ctx: &Context) { ... } -/// ``` -/// -/// The generic `#[handler]` attribute is also supported for backwards -/// compatibility and works for both send and request handlers. #[proc_macro_attribute] pub fn actor(_attr: TokenStream, item: TokenStream) -> TokenStream { let mut impl_block = parse_macro_input!(item as ItemImpl); @@ -58,7 +373,9 @@ pub fn actor(_attr: TokenStream, item: TokenStream) -> TokenStream { let msg_ty = match method.sig.inputs.iter().nth(1) { Some(FnArg::Typed(pat_type)) => { if let Pat::Ident(pat_ident) = &*pat_type.pat { - if pat_ident.ident == "_" || pat_ident.ident.to_string().starts_with('_') { + if pat_ident.ident == "_" + || pat_ident.ident.to_string().starts_with('_') + { // Still use the type } } From 37578d9f5484ae098ebbda3612b76542f47c28a7 Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Tue, 24 Feb 2026 12:39:14 -0300 Subject: [PATCH 15/20] feat: add #[actor(protocol = X)] syntax, auto-generate impl Actor, rename converters to ToXRef --- examples/chat_room/src/main.rs | 10 +- examples/chat_room/src/protocols.rs | 2 +- examples/chat_room/src/room.rs | 6 +- examples/chat_room/src/user.rs | 12 +- examples/chat_room_threads/src/main.rs | 6 +- examples/chat_room_threads/src/room.rs | 6 +- examples/chat_room_threads/src/user.rs | 8 +- examples/ping_pong/src/consumer.rs | 6 +- examples/ping_pong/src/main.rs | 6 +- examples/ping_pong/src/producer.rs | 6 +- examples/ping_pong_threads/src/consumer.rs | 6 +- examples/ping_pong_threads/src/main.rs | 6 +- examples/ping_pong_threads/src/producer.rs | 6 +- examples/service_discovery/src/main.rs | 2 - macros/src/lib.rs | 150 ++++++++++++++++++--- 15 files changed, 167 insertions(+), 71 deletions(-) diff --git a/examples/chat_room/src/main.rs b/examples/chat_room/src/main.rs index a8c3904..7b2c7d4 100644 --- a/examples/chat_room/src/main.rs +++ b/examples/chat_room/src/main.rs @@ -2,7 +2,7 @@ mod protocols; mod room; mod user; -use protocols::{AsRoom, RoomProtocol, UserProtocol}; +use protocols::{RoomProtocol, ToRoomRef, UserProtocol}; use room::ChatRoom; use spawned_concurrency::tasks::ActorStart as _; use spawned_rt::tasks as rt; @@ -15,15 +15,15 @@ fn main() { let alice = User::new("Alice".into()).start(); let bob = User::new("Bob".into()).start(); - alice.join_room(room.as_room()).unwrap(); - bob.join_room(room.as_room()).unwrap(); + alice.join_room(room.to_room_ref()).unwrap(); + bob.join_room(room.to_room_ref()).unwrap(); rt::sleep(Duration::from_millis(10)).await; let members = room.members().await.unwrap(); tracing::info!("Members: {:?}", members); - alice.speak("Hello everyone!".into()).unwrap(); - bob.speak("Hey Alice!".into()).unwrap(); + alice.say("Hello everyone!".into()).unwrap(); + bob.say("Hey Alice!".into()).unwrap(); rt::sleep(Duration::from_millis(50)).await; }) diff --git a/examples/chat_room/src/protocols.rs b/examples/chat_room/src/protocols.rs index 490b0c3..cd70dea 100644 --- a/examples/chat_room/src/protocols.rs +++ b/examples/chat_room/src/protocols.rs @@ -16,6 +16,6 @@ pub trait RoomProtocol: Send + Sync { #[protocol] pub trait UserProtocol: Send + Sync { fn deliver(&self, from: String, text: String) -> Result<(), ActorError>; - fn speak(&self, text: String) -> Result<(), ActorError>; + fn say(&self, text: String) -> Result<(), ActorError>; fn join_room(&self, room: RoomRef) -> Result<(), ActorError>; } diff --git a/examples/chat_room/src/room.rs b/examples/chat_room/src/room.rs index 1809cc0..a303f5e 100644 --- a/examples/chat_room/src/room.rs +++ b/examples/chat_room/src/room.rs @@ -2,15 +2,13 @@ use spawned_concurrency::tasks::{Actor, Context, Handler}; use spawned_macros::actor; use crate::protocols::room_protocol::{AddMember, Members, Say}; -use crate::protocols::UserRef; +use crate::protocols::{RoomProtocol, UserRef}; pub struct ChatRoom { members: Vec<(String, UserRef)>, } -impl Actor for ChatRoom {} - -#[actor] +#[actor(protocol = RoomProtocol)] impl ChatRoom { pub fn new() -> Self { Self { diff --git a/examples/chat_room/src/user.rs b/examples/chat_room/src/user.rs index 7204f40..b41fbaa 100644 --- a/examples/chat_room/src/user.rs +++ b/examples/chat_room/src/user.rs @@ -1,17 +1,15 @@ use spawned_concurrency::tasks::{Actor, Context, Handler}; use spawned_macros::actor; -use crate::protocols::user_protocol::{Deliver, JoinRoom, Speak}; -use crate::protocols::{AsUser, RoomRef}; +use crate::protocols::user_protocol::{Deliver, JoinRoom, Say}; +use crate::protocols::{RoomRef, ToUserRef, UserProtocol}; pub struct User { name: String, room: Option, } -impl Actor for User {} - -#[actor] +#[actor(protocol = UserProtocol)] impl User { pub fn new(name: String) -> Self { Self { name, room: None } @@ -23,7 +21,7 @@ impl User { } #[send_handler] - async fn handle_speak(&mut self, msg: Speak, _ctx: &Context) { + async fn handle_say(&mut self, msg: Say, _ctx: &Context) { if let Some(ref room) = self.room { let _ = room.say(self.name.clone(), msg.text); } @@ -33,7 +31,7 @@ impl User { async fn handle_join_room(&mut self, msg: JoinRoom, ctx: &Context) { let _ = msg .room - .add_member(self.name.clone(), ctx.actor_ref().as_user()); + .add_member(self.name.clone(), ctx.actor_ref().to_user_ref()); self.room = Some(msg.room); } } diff --git a/examples/chat_room_threads/src/main.rs b/examples/chat_room_threads/src/main.rs index e7cbbfd..780972e 100644 --- a/examples/chat_room_threads/src/main.rs +++ b/examples/chat_room_threads/src/main.rs @@ -5,7 +5,7 @@ mod user; use std::thread; use std::time::Duration; -use protocols::{AsRoom, RoomProtocol, UserProtocol}; +use protocols::{RoomProtocol, ToRoomRef, UserProtocol}; use room::ChatRoom; use spawned_concurrency::threads::ActorStart; use spawned_rt::threads as rt; @@ -17,8 +17,8 @@ fn main() { let alice = User::new("Alice".into()).start(); let bob = User::new("Bob".into()).start(); - alice.join_room(room.as_room()).unwrap(); - bob.join_room(room.as_room()).unwrap(); + alice.join_room(room.to_room_ref()).unwrap(); + bob.join_room(room.to_room_ref()).unwrap(); thread::sleep(Duration::from_millis(10)); let members = room.members().unwrap(); diff --git a/examples/chat_room_threads/src/room.rs b/examples/chat_room_threads/src/room.rs index 5a3eac0..34fceee 100644 --- a/examples/chat_room_threads/src/room.rs +++ b/examples/chat_room_threads/src/room.rs @@ -2,15 +2,13 @@ use spawned_concurrency::threads::{Actor, Context, Handler}; use spawned_macros::actor; use crate::protocols::room_protocol::{AddMember, Members, Say}; -use crate::protocols::UserRef; +use crate::protocols::{RoomProtocol, UserRef}; pub struct ChatRoom { members: Vec<(String, UserRef)>, } -impl Actor for ChatRoom {} - -#[actor] +#[actor(protocol = RoomProtocol)] impl ChatRoom { pub fn new() -> Self { Self { diff --git a/examples/chat_room_threads/src/user.rs b/examples/chat_room_threads/src/user.rs index d1517a7..e4734ca 100644 --- a/examples/chat_room_threads/src/user.rs +++ b/examples/chat_room_threads/src/user.rs @@ -2,16 +2,14 @@ use spawned_concurrency::threads::{Actor, Context, Handler}; use spawned_macros::actor; use crate::protocols::user_protocol::{Deliver, JoinRoom, Speak}; -use crate::protocols::{AsUser, RoomRef}; +use crate::protocols::{RoomRef, ToUserRef, UserProtocol}; pub struct User { name: String, room: Option, } -impl Actor for User {} - -#[actor] +#[actor(protocol = UserProtocol)] impl User { pub fn new(name: String) -> Self { Self { name, room: None } @@ -33,7 +31,7 @@ impl User { fn handle_join_room(&mut self, msg: JoinRoom, ctx: &Context) { let _ = msg .room - .add_member(self.name.clone(), ctx.actor_ref().as_user()); + .add_member(self.name.clone(), ctx.actor_ref().to_user_ref()); self.room = Some(msg.room); } } diff --git a/examples/ping_pong/src/consumer.rs b/examples/ping_pong/src/consumer.rs index 0615bea..c3cf036 100644 --- a/examples/ping_pong/src/consumer.rs +++ b/examples/ping_pong/src/consumer.rs @@ -2,15 +2,13 @@ use spawned_concurrency::tasks::{Actor, Context, Handler}; use spawned_macros::actor; use crate::protocols::ping_receiver::Ping; -use crate::protocols::PongReceiverRef; +use crate::protocols::{PingReceiver, PongReceiverRef}; pub struct Consumer { pub producer: PongReceiverRef, } -impl Actor for Consumer {} - -#[actor] +#[actor(protocol = PingReceiver)] impl Consumer { #[send_handler] async fn handle_ping(&mut self, _msg: Ping, _ctx: &Context) { diff --git a/examples/ping_pong/src/main.rs b/examples/ping_pong/src/main.rs index 2518302..2eb4de9 100644 --- a/examples/ping_pong/src/main.rs +++ b/examples/ping_pong/src/main.rs @@ -4,7 +4,7 @@ mod protocols; use consumer::Consumer; use producer::{Producer, SetConsumer}; -use protocols::{AsPingReceiver, AsPongReceiver, PingReceiver}; +use protocols::{PingReceiver, ToPingReceiverRef, ToPongReceiverRef}; use spawned_concurrency::tasks::ActorStart as _; use spawned_rt::tasks as rt; use std::time::Duration; @@ -14,12 +14,12 @@ fn main() { let producer = Producer { consumer: None }.start(); let consumer = Consumer { - producer: producer.as_pong_receiver(), + producer: producer.to_pong_receiver_ref(), } .start(); producer - .send(SetConsumer(consumer.as_ping_receiver())) + .send(SetConsumer(consumer.to_ping_receiver_ref())) .unwrap(); consumer.ping().unwrap(); diff --git a/examples/ping_pong/src/producer.rs b/examples/ping_pong/src/producer.rs index 82f85cc..83771d2 100644 --- a/examples/ping_pong/src/producer.rs +++ b/examples/ping_pong/src/producer.rs @@ -3,7 +3,7 @@ use spawned_concurrency::tasks::{Actor, Context, Handler}; use spawned_macros::actor; use crate::protocols::pong_receiver::Pong; -use crate::protocols::PingReceiverRef; +use crate::protocols::{PingReceiverRef, PongReceiver}; pub struct SetConsumer(pub PingReceiverRef); impl Message for SetConsumer { @@ -14,9 +14,7 @@ pub struct Producer { pub consumer: Option, } -impl Actor for Producer {} - -#[actor] +#[actor(protocol = PongReceiver)] impl Producer { #[send_handler] async fn handle_set_consumer(&mut self, msg: SetConsumer, _ctx: &Context) { diff --git a/examples/ping_pong_threads/src/consumer.rs b/examples/ping_pong_threads/src/consumer.rs index 383baa3..6862ef8 100644 --- a/examples/ping_pong_threads/src/consumer.rs +++ b/examples/ping_pong_threads/src/consumer.rs @@ -2,15 +2,13 @@ use spawned_concurrency::threads::{Actor, Context, Handler}; use spawned_macros::actor; use crate::protocols::ping_receiver::Ping; -use crate::protocols::PongReceiverRef; +use crate::protocols::{PingReceiver, PongReceiverRef}; pub struct Consumer { pub producer: PongReceiverRef, } -impl Actor for Consumer {} - -#[actor] +#[actor(protocol = PingReceiver)] impl Consumer { #[send_handler] fn handle_ping(&mut self, _msg: Ping, _ctx: &Context) { diff --git a/examples/ping_pong_threads/src/main.rs b/examples/ping_pong_threads/src/main.rs index 97695c7..860f8f1 100644 --- a/examples/ping_pong_threads/src/main.rs +++ b/examples/ping_pong_threads/src/main.rs @@ -6,7 +6,7 @@ use std::{thread, time::Duration}; use consumer::Consumer; use producer::{Producer, SetConsumer}; -use protocols::{AsPingReceiver, AsPongReceiver, PingReceiver}; +use protocols::{PingReceiver, ToPingReceiverRef, ToPongReceiverRef}; use spawned_concurrency::threads::ActorStart as _; use spawned_rt::threads as rt; @@ -15,12 +15,12 @@ fn main() { let producer = Producer { consumer: None }.start(); let consumer = Consumer { - producer: producer.as_pong_receiver(), + producer: producer.to_pong_receiver_ref(), } .start(); producer - .send(SetConsumer(consumer.as_ping_receiver())) + .send(SetConsumer(consumer.to_ping_receiver_ref())) .unwrap(); consumer.ping().unwrap(); diff --git a/examples/ping_pong_threads/src/producer.rs b/examples/ping_pong_threads/src/producer.rs index 6230213..5106d13 100644 --- a/examples/ping_pong_threads/src/producer.rs +++ b/examples/ping_pong_threads/src/producer.rs @@ -3,7 +3,7 @@ use spawned_concurrency::threads::{Actor, Context, Handler}; use spawned_macros::actor; use crate::protocols::pong_receiver::Pong; -use crate::protocols::PingReceiverRef; +use crate::protocols::{PingReceiverRef, PongReceiver}; pub struct SetConsumer(pub PingReceiverRef); impl Message for SetConsumer { @@ -14,9 +14,7 @@ pub struct Producer { pub consumer: Option, } -impl Actor for Producer {} - -#[actor] +#[actor(protocol = PongReceiver)] impl Producer { #[send_handler] fn handle_set_consumer(&mut self, msg: SetConsumer, _ctx: &Context) { diff --git a/examples/service_discovery/src/main.rs b/examples/service_discovery/src/main.rs index 7b282d4..078f32a 100644 --- a/examples/service_discovery/src/main.rs +++ b/examples/service_discovery/src/main.rs @@ -29,8 +29,6 @@ impl ServiceRegistry { } } -impl Actor for ServiceRegistry {} - #[actor] impl ServiceRegistry { #[handler] diff --git a/macros/src/lib.rs b/macros/src/lib.rs index 63c5f51..3728604 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -1,8 +1,8 @@ use proc_macro::TokenStream; use quote::{format_ident, quote}; use syn::{ - parse_macro_input, FnArg, GenericArgument, Ident, ImplItem, ItemImpl, ItemTrait, Pat, - PathArguments, ReturnType, TraitItem, Type, TypePath, + parse::Parse, parse_macro_input, FnArg, GenericArgument, Ident, ImplItem, ImplItemFn, + ItemImpl, ItemTrait, Pat, PathArguments, ReturnType, TraitItem, Type, TypePath, }; // --- Helpers for #[protocol] --- @@ -97,7 +97,7 @@ fn is_unit_type(ty: &Type) -> bool { false } -/// Generates a blanket `impl Protocol for ActorRef` and `impl AsX for ActorRef` +/// Generates a blanket `impl Protocol for ActorRef` and `impl ToXRef for ActorRef` /// for a given runtime path (tasks or threads). fn generate_blanket_impl( trait_name: &Ident, @@ -195,8 +195,8 @@ pub fn protocol(_attr: TokenStream, item: TokenStream) -> TokenStream { let base_name = strip_protocol_suffix(&trait_name.to_string()); let mod_name = format_ident!("{}", to_snake_case(&trait_name.to_string())); let ref_name = format_ident!("{}Ref", base_name); - let converter_trait = format_ident!("As{}", base_name); - let converter_method = format_ident!("as_{}", to_snake_case(&base_name)); + let converter_trait = format_ident!("To{}Ref", base_name); + let converter_method = format_ident!("to_{}_ref", to_snake_case(&base_name)); let mut methods: Vec = Vec::new(); let mut has_async_request = false; @@ -346,13 +346,99 @@ pub fn protocol(_attr: TokenStream, item: TokenStream) -> TokenStream { } /// Attribute macro for actor impl blocks. +/// +/// Generates `impl Actor for T` automatically. Use `#[started]` and `#[stopped]` +/// on methods to override lifecycle callbacks. +/// +/// Use `protocol = TraitName` to assert the actor implements a protocol: +/// ```ignore +/// #[actor(protocol = RoomProtocol)] +/// ``` +/// For multiple protocols use the list form: +/// ```ignore +/// #[actor(protocol(RoomProtocol, AnotherProtocol))] +/// ``` #[proc_macro_attribute] -pub fn actor(_attr: TokenStream, item: TokenStream) -> TokenStream { +pub fn actor(attr: TokenStream, item: TokenStream) -> TokenStream { let mut impl_block = parse_macro_input!(item as ItemImpl); let self_ty = &impl_block.self_ty; let (impl_generics, _, where_clause) = impl_block.generics.split_for_impl(); + // --- Parse named parameters from #[actor(protocol = X)] or #[actor(protocol(X, Y))] --- + let bridge_traits: Vec = if attr.is_empty() { + Vec::new() + } else { + let parser = |input: syn::parse::ParseStream| -> syn::Result> { + let mut protocols = Vec::new(); + while !input.is_empty() { + let key: Ident = input.parse()?; + if key != "protocol" { + return Err(syn::Error::new(key.span(), "unknown parameter, expected `protocol`")); + } + if input.peek(syn::Token![=]) { + // protocol = TraitName + let _: syn::Token![=] = input.parse()?; + protocols.push(input.parse()?); + } else { + // protocol(Trait1, Trait2) + let content; + syn::parenthesized!(content in input); + let punctuated = content.parse_terminated(Ident::parse, syn::Token![,])?; + protocols.extend(punctuated); + } + if input.peek(syn::Token![,]) { + let _: syn::Token![,] = input.parse()?; + } + } + Ok(protocols) + }; + match syn::parse::Parser::parse(parser, attr) { + Ok(traits) => traits, + Err(e) => return e.to_compile_error().into(), + } + }; + + // --- Extract #[started] and #[stopped] lifecycle methods --- + let mut started_method: Option = None; + let mut stopped_method: Option = None; + let mut has_async = false; + + let mut items_to_keep = Vec::new(); + for item in impl_block.items.drain(..) { + if let ImplItem::Fn(ref method) = item { + let is_started = method.attrs.iter().any(|a| a.path().is_ident("started")); + let is_stopped = method.attrs.iter().any(|a| a.path().is_ident("stopped")); + + if is_started { + let mut m = method.clone(); + m.attrs.retain(|a| !a.path().is_ident("started")); + m.vis = syn::Visibility::Inherited; + m.sig.ident = format_ident!("started"); + if m.sig.asyncness.is_some() { + has_async = true; + } + started_method = Some(m); + continue; + } + + if is_stopped { + let mut m = method.clone(); + m.attrs.retain(|a| !a.path().is_ident("stopped")); + m.vis = syn::Visibility::Inherited; + m.sig.ident = format_ident!("stopped"); + if m.sig.asyncness.is_some() { + has_async = true; + } + stopped_method = Some(m); + continue; + } + } + items_to_keep.push(item); + } + impl_block.items = items_to_keep; + + // --- Process handler methods --- let mut handler_impls = Vec::new(); for item in &mut impl_block.items { @@ -367,20 +453,13 @@ pub fn actor(_attr: TokenStream, item: TokenStream) -> TokenStream { method.attrs.remove(idx); let method_name = &method.sig.ident; - let is_async = method.sig.asyncness.is_some(); + if method.sig.asyncness.is_some() { + has_async = true; + } // Extract message type from 2nd parameter (index 1, after &mut self) let msg_ty = match method.sig.inputs.iter().nth(1) { - Some(FnArg::Typed(pat_type)) => { - if let Pat::Ident(pat_ident) = &*pat_type.pat { - if pat_ident.ident == "_" - || pat_ident.ident.to_string().starts_with('_') - { - // Still use the type - } - } - &*pat_type.ty - } + Some(FnArg::Typed(pat_type)) => &*pat_type.ty, _ => { return syn::Error::new_spanned( &method.sig, @@ -397,7 +476,7 @@ pub fn actor(_attr: TokenStream, item: TokenStream) -> TokenStream { ReturnType::Type(_, ty) => ty.clone(), }; - let handler_impl = if is_async { + let handler_impl = if method.sig.asyncness.is_some() { quote! { impl #impl_generics Handler<#msg_ty> for #self_ty #where_clause { async fn handle(&mut self, msg: #msg_ty, ctx: &Context) -> #ret_ty { @@ -420,9 +499,44 @@ pub fn actor(_attr: TokenStream, item: TokenStream) -> TokenStream { } } + // --- Generate impl Actor --- + let lifecycle_methods: Vec<&ImplItemFn> = [started_method.as_ref(), stopped_method.as_ref()] + .into_iter() + .flatten() + .collect(); + + let actor_impl = quote! { + impl #impl_generics Actor for #self_ty #where_clause { + #(#lifecycle_methods)* + } + }; + + // --- Generate bridge assertions --- + let runtime_path = if has_async { + quote! { spawned_concurrency::tasks } + } else { + quote! { spawned_concurrency::threads } + }; + + let bridge_asserts: Vec<_> = bridge_traits + .iter() + .map(|trait_name| { + quote! { + const _: () = { + fn _assert_bridge<__T: #trait_name>() {} + fn _check() { + _assert_bridge::<#runtime_path::ActorRef<#self_ty>>(); + } + }; + } + }) + .collect(); + let output = quote! { + #actor_impl #impl_block #(#handler_impls)* + #(#bridge_asserts)* }; output.into() From ad7e44828c4183675539500ca82c9ebc10d965a3 Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Tue, 24 Feb 2026 16:49:04 -0300 Subject: [PATCH 16/20] fix: remove unnecessary Box in Vec> (clippy vec_box) --- macros/src/lib.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/macros/src/lib.rs b/macros/src/lib.rs index 3728604..16b00d9 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -180,7 +180,7 @@ struct ProtocolMethodInfo { method_name: Ident, struct_name: Ident, field_names: Vec, - field_types: Vec>, + field_types: Vec, kind: MethodKind, params: Vec, ret_type: ReturnType, @@ -208,14 +208,14 @@ pub fn protocol(_attr: TokenStream, item: TokenStream) -> TokenStream { let struct_name = format_ident!("{}", to_pascal_case(&method_name.to_string())); let mut field_names: Vec = Vec::new(); - let mut field_types: Vec> = Vec::new(); + let mut field_types: Vec = Vec::new(); let mut params: Vec = Vec::new(); for arg in method.sig.inputs.iter().skip(1) { if let FnArg::Typed(pat_type) = arg { if let Pat::Ident(pat_ident) = &*pat_type.pat { field_names.push(pat_ident.ident.clone()); - field_types.push(pat_type.ty.clone()); + field_types.push((*pat_type.ty).clone()); } } params.push(arg.clone()); From 5adffd8b7346b09b84d1bf30e17da1459a53c78f Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Tue, 24 Feb 2026 17:51:22 -0300 Subject: [PATCH 17/20] feat: migrate all examples to #[protocol] + #[actor(protocol = X)] macros --- Cargo.lock | 7 ++ concurrency/src/tasks/actor.rs | 5 +- examples/bank/Cargo.toml | 1 + examples/bank/src/main.rs | 27 +++---- examples/bank/src/messages.rs | 49 ------------ examples/bank/src/protocols.rs | 29 +++++++ examples/bank/src/server.rs | 30 +++---- examples/bank_threads/Cargo.toml | 1 + examples/bank_threads/src/main.rs | 27 +++---- examples/bank_threads/src/messages.rs | 49 ------------ examples/bank_threads/src/protocols.rs | 29 +++++++ examples/bank_threads/src/server.rs | 30 +++---- examples/name_server/Cargo.toml | 1 + examples/name_server/src/main.rs | 15 ++-- examples/name_server/src/messages.rs | 24 ------ examples/name_server/src/protocols.rs | 17 ++++ examples/name_server/src/server.rs | 17 ++-- examples/service_discovery/src/main.rs | 87 ++++----------------- examples/service_discovery/src/protocols.rs | 12 +++ examples/service_discovery/src/server.rs | 38 +++++++++ examples/signal_test/Cargo.toml | 5 +- examples/signal_test/src/main.rs | 57 +++++--------- examples/signal_test_threads/Cargo.toml | 5 +- examples/signal_test_threads/src/main.rs | 57 +++++--------- examples/updater/Cargo.toml | 1 + examples/updater/src/main.rs | 2 +- examples/updater/src/messages.rs | 7 -- examples/updater/src/protocols.rs | 11 +++ examples/updater/src/server.rs | 13 +-- examples/updater_threads/Cargo.toml | 1 + examples/updater_threads/src/main.rs | 2 +- examples/updater_threads/src/messages.rs | 7 -- examples/updater_threads/src/protocols.rs | 11 +++ examples/updater_threads/src/server.rs | 13 +-- macros/src/lib.rs | 2 + 35 files changed, 307 insertions(+), 382 deletions(-) delete mode 100644 examples/bank/src/messages.rs create mode 100644 examples/bank/src/protocols.rs delete mode 100644 examples/bank_threads/src/messages.rs create mode 100644 examples/bank_threads/src/protocols.rs delete mode 100644 examples/name_server/src/messages.rs create mode 100644 examples/name_server/src/protocols.rs create mode 100644 examples/service_discovery/src/protocols.rs create mode 100644 examples/service_discovery/src/server.rs delete mode 100644 examples/updater/src/messages.rs create mode 100644 examples/updater/src/protocols.rs delete mode 100644 examples/updater_threads/src/messages.rs create mode 100644 examples/updater_threads/src/protocols.rs diff --git a/Cargo.lock b/Cargo.lock index fbd8372..af2a4cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,6 +22,7 @@ name = "bank" version = "0.1.0" dependencies = [ "spawned-concurrency", + "spawned-macros", "spawned-rt", "tracing", ] @@ -31,6 +32,7 @@ name = "bank_threads" version = "0.1.0" dependencies = [ "spawned-concurrency", + "spawned-macros", "spawned-rt", "tracing", ] @@ -751,6 +753,7 @@ name = "name_server" version = "0.1.0" dependencies = [ "spawned-concurrency", + "spawned-macros", "spawned-rt", "tracing", ] @@ -1190,6 +1193,7 @@ name = "signal_test" version = "0.1.0" dependencies = [ "spawned-concurrency", + "spawned-macros", "spawned-rt", "tokio", "tracing", @@ -1200,6 +1204,7 @@ name = "signal_test_threads" version = "0.1.0" dependencies = [ "spawned-concurrency", + "spawned-macros", "spawned-rt", "tracing", ] @@ -1529,6 +1534,7 @@ dependencies = [ "futures", "reqwest", "spawned-concurrency", + "spawned-macros", "spawned-rt", "tracing", ] @@ -1540,6 +1546,7 @@ dependencies = [ "futures", "reqwest", "spawned-concurrency", + "spawned-macros", "spawned-rt", "tracing", ] diff --git a/concurrency/src/tasks/actor.rs b/concurrency/src/tasks/actor.rs index 7ee362a..45c3c8b 100644 --- a/concurrency/src/tasks/actor.rs +++ b/concurrency/src/tasks/actor.rs @@ -823,9 +823,8 @@ mod tests { let count_after_join = tick_count.load(atomic::Ordering::SeqCst); assert!( count_after_join >= 8, - "Ticker should have completed ~10 ticks during the 500ms join(), but only got {}. \ - This suggests join() blocked the runtime.", - count_after_join + "Ticker should have completed ~10 ticks during the 500ms join(), but only got {count_after_join}. \ + This suggests join() blocked the runtime." ); }); } diff --git a/examples/bank/Cargo.toml b/examples/bank/Cargo.toml index 86c85cc..2d794ab 100644 --- a/examples/bank/Cargo.toml +++ b/examples/bank/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] spawned-rt = { workspace = true } spawned-concurrency = { workspace = true } +spawned-macros = { workspace = true } tracing = { workspace = true } [[bin]] diff --git a/examples/bank/src/main.rs b/examples/bank/src/main.rs index 7572645..716e95e 100644 --- a/examples/bank/src/main.rs +++ b/examples/bank/src/main.rs @@ -1,12 +1,7 @@ -//! Bank example using the new Handler API. -//! -//! Based on Joe's Armstrong book: Programming Erlang, Second edition -//! Section 22.1 - The Road to the Generic Server - -mod messages; +mod protocols; mod server; -use messages::*; +use protocols::{BankError, BankOutMessage, BankProtocol}; use server::Bank; use spawned_concurrency::tasks::ActorStart as _; use spawned_rt::tasks as rt; @@ -16,7 +11,7 @@ fn main() { let bank = Bank::new().start(); // Testing initial balance for "main" account - let result = bank.request(Withdraw { who: "main".into(), amount: 15 }).await.unwrap(); + let result = bank.withdraw("main".into(), 15).await.unwrap(); tracing::info!("Withdraw result {result:?}"); assert_eq!( result, @@ -29,17 +24,17 @@ fn main() { let joe = "Joe".to_string(); // Error on deposit for a non-existent account - let result = bank.request(Deposit { who: joe.clone(), amount: 10 }).await.unwrap(); + let result = bank.deposit(joe.clone(), 10).await.unwrap(); tracing::info!("Deposit result {result:?}"); assert_eq!(result, Err(BankError::NotACustomer { who: joe.clone() })); // Account creation - let result = bank.request(NewAccount { who: joe.clone() }).await.unwrap(); + let result = bank.new_account(joe.clone()).await.unwrap(); tracing::info!("New account result {result:?}"); assert_eq!(result, Ok(BankOutMessage::Welcome { who: joe.clone() })); // Deposit - let result = bank.request(Deposit { who: joe.clone(), amount: 10 }).await.unwrap(); + let result = bank.deposit(joe.clone(), 10).await.unwrap(); tracing::info!("Deposit result {result:?}"); assert_eq!( result, @@ -47,7 +42,7 @@ fn main() { ); // Deposit - let result = bank.request(Deposit { who: joe.clone(), amount: 30 }).await.unwrap(); + let result = bank.deposit(joe.clone(), 30).await.unwrap(); tracing::info!("Deposit result {result:?}"); assert_eq!( result, @@ -55,7 +50,7 @@ fn main() { ); // Withdrawal - let result = bank.request(Withdraw { who: joe.clone(), amount: 15 }).await.unwrap(); + let result = bank.withdraw(joe.clone(), 15).await.unwrap(); tracing::info!("Withdraw result {result:?}"); assert_eq!( result, @@ -63,7 +58,7 @@ fn main() { ); // Withdrawal with not enough balance - let result = bank.request(Withdraw { who: joe.clone(), amount: 45 }).await.unwrap(); + let result = bank.withdraw(joe.clone(), 45).await.unwrap(); tracing::info!("Withdraw result {result:?}"); assert_eq!( result, @@ -71,7 +66,7 @@ fn main() { ); // Full withdrawal - let result = bank.request(Withdraw { who: joe.clone(), amount: 25 }).await.unwrap(); + let result = bank.withdraw(joe.clone(), 25).await.unwrap(); tracing::info!("Withdraw result {result:?}"); assert_eq!( result, @@ -79,7 +74,7 @@ fn main() { ); // Stopping the bank - let result = bank.request(Stop).await.unwrap(); + let result = bank.stop().await.unwrap(); tracing::info!("Stop result {result:?}"); assert_eq!(result, Ok(BankOutMessage::Stopped)); }) diff --git a/examples/bank/src/messages.rs b/examples/bank/src/messages.rs deleted file mode 100644 index 908ea45..0000000 --- a/examples/bank/src/messages.rs +++ /dev/null @@ -1,49 +0,0 @@ -use spawned_concurrency::message::Message; - -#[derive(Debug)] -pub struct NewAccount { - pub who: String, -} -impl Message for NewAccount { - type Result = Result; -} - -#[derive(Debug)] -pub struct Deposit { - pub who: String, - pub amount: i32, -} -impl Message for Deposit { - type Result = Result; -} - -#[derive(Debug)] -pub struct Withdraw { - pub who: String, - pub amount: i32, -} -impl Message for Withdraw { - type Result = Result; -} - -#[derive(Debug)] -pub struct Stop; -impl Message for Stop { - type Result = Result; -} - -#[derive(Debug, Clone, PartialEq)] -pub enum BankOutMessage { - Welcome { who: String }, - Balance { who: String, amount: i32 }, - WithdrawOk { who: String, amount: i32 }, - Stopped, -} - -#[derive(Debug, Clone, PartialEq)] -pub enum BankError { - AlreadyACustomer { who: String }, - NotACustomer { who: String }, - InsufficientBalance { who: String, amount: i32 }, - ServerError, -} diff --git a/examples/bank/src/protocols.rs b/examples/bank/src/protocols.rs new file mode 100644 index 0000000..520847c --- /dev/null +++ b/examples/bank/src/protocols.rs @@ -0,0 +1,29 @@ +use spawned_concurrency::tasks::Response; +use spawned_macros::protocol; +use std::sync::Arc; + +pub type BankRef = Arc; +pub type MsgResult = Result; + +#[derive(Debug, Clone, PartialEq)] +pub enum BankOutMessage { + Welcome { who: String }, + Balance { who: String, amount: i32 }, + WithdrawOk { who: String, amount: i32 }, + Stopped, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum BankError { + AlreadyACustomer { who: String }, + NotACustomer { who: String }, + InsufficientBalance { who: String, amount: i32 }, +} + +#[protocol] +pub trait BankProtocol: Send + Sync { + fn new_account(&self, who: String) -> Response; + fn deposit(&self, who: String, amount: i32) -> Response; + fn withdraw(&self, who: String, amount: i32) -> Response; + fn stop(&self) -> Response; +} diff --git a/examples/bank/src/server.rs b/examples/bank/src/server.rs index 7293ca5..f9f9deb 100644 --- a/examples/bank/src/server.rs +++ b/examples/bank/src/server.rs @@ -1,31 +1,30 @@ use std::collections::HashMap; use spawned_concurrency::tasks::{Actor, Context, Handler}; +use spawned_macros::actor; -use crate::messages::*; - -type MsgResult = Result; +use crate::protocols::bank_protocol::{Deposit, NewAccount, Stop, Withdraw}; +use crate::protocols::{BankError, BankOutMessage, BankProtocol, MsgResult}; pub struct Bank { accounts: HashMap, } +#[actor(protocol = BankProtocol)] impl Bank { pub fn new() -> Self { Bank { accounts: HashMap::new(), } } -} -impl Actor for Bank { + #[started] async fn started(&mut self, _ctx: &Context) { self.accounts.insert("main".to_string(), 1000); } -} -impl Handler for Bank { - async fn handle(&mut self, msg: NewAccount, _ctx: &Context) -> MsgResult { + #[request_handler] + async fn handle_new_account(&mut self, msg: NewAccount, _ctx: &Context) -> MsgResult { match self.accounts.get(&msg.who) { Some(_) => Err(BankError::AlreadyACustomer { who: msg.who }), None => { @@ -34,10 +33,9 @@ impl Handler for Bank { } } } -} -impl Handler for Bank { - async fn handle(&mut self, msg: Deposit, _ctx: &Context) -> MsgResult { + #[request_handler] + async fn handle_deposit(&mut self, msg: Deposit, _ctx: &Context) -> MsgResult { match self.accounts.get(&msg.who) { Some(current) => { let new_amount = current + msg.amount; @@ -50,10 +48,9 @@ impl Handler for Bank { None => Err(BankError::NotACustomer { who: msg.who }), } } -} -impl Handler for Bank { - async fn handle(&mut self, msg: Withdraw, _ctx: &Context) -> MsgResult { + #[request_handler] + async fn handle_withdraw(&mut self, msg: Withdraw, _ctx: &Context) -> MsgResult { match self.accounts.get(&msg.who) { Some(¤t) if current < msg.amount => { Err(BankError::InsufficientBalance { @@ -72,10 +69,9 @@ impl Handler for Bank { None => Err(BankError::NotACustomer { who: msg.who }), } } -} -impl Handler for Bank { - async fn handle(&mut self, _msg: Stop, ctx: &Context) -> MsgResult { + #[request_handler] + async fn handle_stop(&mut self, _msg: Stop, ctx: &Context) -> MsgResult { ctx.stop(); Ok(BankOutMessage::Stopped) } diff --git a/examples/bank_threads/Cargo.toml b/examples/bank_threads/Cargo.toml index 0f4f4e0..1e34b12 100644 --- a/examples/bank_threads/Cargo.toml +++ b/examples/bank_threads/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] spawned-rt = { workspace = true } spawned-concurrency = { workspace = true } +spawned-macros = { workspace = true } tracing = { workspace = true } [[bin]] diff --git a/examples/bank_threads/src/main.rs b/examples/bank_threads/src/main.rs index 4f65316..3e56553 100644 --- a/examples/bank_threads/src/main.rs +++ b/examples/bank_threads/src/main.rs @@ -1,12 +1,7 @@ -//! Bank example using threads Actor with the new Handler API. -//! -//! Based on Joe's Armstrong book: Programming Erlang, Second edition -//! Section 22.1 - The Road to the Generic Server - -mod messages; +mod protocols; mod server; -use messages::*; +use protocols::{BankError, BankOutMessage, BankProtocol}; use server::Bank; use spawned_concurrency::threads::ActorStart as _; use spawned_rt::threads as rt; @@ -16,7 +11,7 @@ fn main() { let bank = Bank::new().start(); // Testing initial balance for "main" account - let result = bank.request(Withdraw { who: "main".into(), amount: 15 }).unwrap(); + let result = bank.withdraw("main".into(), 15).unwrap(); tracing::info!("Withdraw result {result:?}"); assert_eq!( result, @@ -29,17 +24,17 @@ fn main() { let joe = "Joe".to_string(); // Error on deposit for a non-existent account - let result = bank.request(Deposit { who: joe.clone(), amount: 10 }).unwrap(); + let result = bank.deposit(joe.clone(), 10).unwrap(); tracing::info!("Deposit result {result:?}"); assert_eq!(result, Err(BankError::NotACustomer { who: joe.clone() })); // Account creation - let result = bank.request(NewAccount { who: joe.clone() }).unwrap(); + let result = bank.new_account(joe.clone()).unwrap(); tracing::info!("New account result {result:?}"); assert_eq!(result, Ok(BankOutMessage::Welcome { who: joe.clone() })); // Deposit - let result = bank.request(Deposit { who: joe.clone(), amount: 10 }).unwrap(); + let result = bank.deposit(joe.clone(), 10).unwrap(); tracing::info!("Deposit result {result:?}"); assert_eq!( result, @@ -47,7 +42,7 @@ fn main() { ); // Deposit - let result = bank.request(Deposit { who: joe.clone(), amount: 30 }).unwrap(); + let result = bank.deposit(joe.clone(), 30).unwrap(); tracing::info!("Deposit result {result:?}"); assert_eq!( result, @@ -55,7 +50,7 @@ fn main() { ); // Withdrawal - let result = bank.request(Withdraw { who: joe.clone(), amount: 15 }).unwrap(); + let result = bank.withdraw(joe.clone(), 15).unwrap(); tracing::info!("Withdraw result {result:?}"); assert_eq!( result, @@ -63,7 +58,7 @@ fn main() { ); // Withdrawal with not enough balance - let result = bank.request(Withdraw { who: joe.clone(), amount: 45 }).unwrap(); + let result = bank.withdraw(joe.clone(), 45).unwrap(); tracing::info!("Withdraw result {result:?}"); assert_eq!( result, @@ -71,7 +66,7 @@ fn main() { ); // Full withdrawal - let result = bank.request(Withdraw { who: joe.clone(), amount: 25 }).unwrap(); + let result = bank.withdraw(joe.clone(), 25).unwrap(); tracing::info!("Withdraw result {result:?}"); assert_eq!( result, @@ -79,7 +74,7 @@ fn main() { ); // Stopping the bank - let result = bank.request(Stop).unwrap(); + let result = bank.stop().unwrap(); tracing::info!("Stop result {result:?}"); assert_eq!(result, Ok(BankOutMessage::Stopped)); }) diff --git a/examples/bank_threads/src/messages.rs b/examples/bank_threads/src/messages.rs deleted file mode 100644 index 908ea45..0000000 --- a/examples/bank_threads/src/messages.rs +++ /dev/null @@ -1,49 +0,0 @@ -use spawned_concurrency::message::Message; - -#[derive(Debug)] -pub struct NewAccount { - pub who: String, -} -impl Message for NewAccount { - type Result = Result; -} - -#[derive(Debug)] -pub struct Deposit { - pub who: String, - pub amount: i32, -} -impl Message for Deposit { - type Result = Result; -} - -#[derive(Debug)] -pub struct Withdraw { - pub who: String, - pub amount: i32, -} -impl Message for Withdraw { - type Result = Result; -} - -#[derive(Debug)] -pub struct Stop; -impl Message for Stop { - type Result = Result; -} - -#[derive(Debug, Clone, PartialEq)] -pub enum BankOutMessage { - Welcome { who: String }, - Balance { who: String, amount: i32 }, - WithdrawOk { who: String, amount: i32 }, - Stopped, -} - -#[derive(Debug, Clone, PartialEq)] -pub enum BankError { - AlreadyACustomer { who: String }, - NotACustomer { who: String }, - InsufficientBalance { who: String, amount: i32 }, - ServerError, -} diff --git a/examples/bank_threads/src/protocols.rs b/examples/bank_threads/src/protocols.rs new file mode 100644 index 0000000..b04052a --- /dev/null +++ b/examples/bank_threads/src/protocols.rs @@ -0,0 +1,29 @@ +use spawned_concurrency::error::ActorError; +use spawned_macros::protocol; +use std::sync::Arc; + +pub type BankRef = Arc; +pub type MsgResult = Result; + +#[derive(Debug, Clone, PartialEq)] +pub enum BankOutMessage { + Welcome { who: String }, + Balance { who: String, amount: i32 }, + WithdrawOk { who: String, amount: i32 }, + Stopped, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum BankError { + AlreadyACustomer { who: String }, + NotACustomer { who: String }, + InsufficientBalance { who: String, amount: i32 }, +} + +#[protocol] +pub trait BankProtocol: Send + Sync { + fn new_account(&self, who: String) -> Result; + fn deposit(&self, who: String, amount: i32) -> Result; + fn withdraw(&self, who: String, amount: i32) -> Result; + fn stop(&self) -> Result; +} diff --git a/examples/bank_threads/src/server.rs b/examples/bank_threads/src/server.rs index f0cce1f..ec76243 100644 --- a/examples/bank_threads/src/server.rs +++ b/examples/bank_threads/src/server.rs @@ -1,31 +1,30 @@ use std::collections::HashMap; use spawned_concurrency::threads::{Actor, Context, Handler}; +use spawned_macros::actor; -use crate::messages::*; - -type MsgResult = Result; +use crate::protocols::bank_protocol::{Deposit, NewAccount, Stop, Withdraw}; +use crate::protocols::{BankError, BankOutMessage, BankProtocol, MsgResult}; pub struct Bank { accounts: HashMap, } +#[actor(protocol = BankProtocol)] impl Bank { pub fn new() -> Self { Bank { accounts: HashMap::new(), } } -} -impl Actor for Bank { + #[started] fn started(&mut self, _ctx: &Context) { self.accounts.insert("main".to_string(), 1000); } -} -impl Handler for Bank { - fn handle(&mut self, msg: NewAccount, _ctx: &Context) -> MsgResult { + #[request_handler] + fn handle_new_account(&mut self, msg: NewAccount, _ctx: &Context) -> MsgResult { match self.accounts.get(&msg.who) { Some(_) => Err(BankError::AlreadyACustomer { who: msg.who }), None => { @@ -34,10 +33,9 @@ impl Handler for Bank { } } } -} -impl Handler for Bank { - fn handle(&mut self, msg: Deposit, _ctx: &Context) -> MsgResult { + #[request_handler] + fn handle_deposit(&mut self, msg: Deposit, _ctx: &Context) -> MsgResult { match self.accounts.get(&msg.who) { Some(current) => { let new_amount = current + msg.amount; @@ -50,10 +48,9 @@ impl Handler for Bank { None => Err(BankError::NotACustomer { who: msg.who }), } } -} -impl Handler for Bank { - fn handle(&mut self, msg: Withdraw, _ctx: &Context) -> MsgResult { + #[request_handler] + fn handle_withdraw(&mut self, msg: Withdraw, _ctx: &Context) -> MsgResult { match self.accounts.get(&msg.who) { Some(¤t) if current < msg.amount => { Err(BankError::InsufficientBalance { @@ -72,10 +69,9 @@ impl Handler for Bank { None => Err(BankError::NotACustomer { who: msg.who }), } } -} -impl Handler for Bank { - fn handle(&mut self, _msg: Stop, ctx: &Context) -> MsgResult { + #[request_handler] + fn handle_stop(&mut self, _msg: Stop, ctx: &Context) -> MsgResult { ctx.stop(); Ok(BankOutMessage::Stopped) } diff --git a/examples/name_server/Cargo.toml b/examples/name_server/Cargo.toml index 77d23f8..60bf8c9 100644 --- a/examples/name_server/Cargo.toml +++ b/examples/name_server/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] spawned-rt = { workspace = true } spawned-concurrency = { workspace = true } +spawned-macros = { workspace = true } tracing = { workspace = true } [[bin]] diff --git a/examples/name_server/src/main.rs b/examples/name_server/src/main.rs index 7da4ab8..310936d 100644 --- a/examples/name_server/src/main.rs +++ b/examples/name_server/src/main.rs @@ -1,12 +1,7 @@ -//! Name server example using the new Handler API. -//! -//! Based on Joe's Armstrong book: Programming Erlang, Second edition -//! Section 22.1 - The Road to the Generic Server - -mod messages; +mod protocols; mod server; -use messages::*; +use protocols::{FindResult, NameServerProtocol}; use server::NameServer; use spawned_concurrency::tasks::ActorStart as _; use spawned_rt::tasks as rt; @@ -15,16 +10,16 @@ fn main() { rt::run(async { let ns = NameServer::new().start(); - ns.request(Add { key: "Joe".into(), value: "At Home".into() }).await.unwrap(); + ns.add("Joe".into(), "At Home".into()).await.unwrap(); - let result = ns.request(Find { key: "Joe".into() }).await.unwrap(); + let result = ns.find("Joe".into()).await.unwrap(); tracing::info!("Retrieving value result: {result:?}"); assert_eq!( result, FindResult::Found { value: "At Home".to_string() } ); - let result = ns.request(Find { key: "Bob".into() }).await.unwrap(); + let result = ns.find("Bob".into()).await.unwrap(); tracing::info!("Retrieving value result: {result:?}"); assert_eq!(result, FindResult::NotFound); }) diff --git a/examples/name_server/src/messages.rs b/examples/name_server/src/messages.rs deleted file mode 100644 index 3b58499..0000000 --- a/examples/name_server/src/messages.rs +++ /dev/null @@ -1,24 +0,0 @@ -use spawned_concurrency::message::Message; - -#[derive(Debug)] -pub struct Add { - pub key: String, - pub value: String, -} -impl Message for Add { - type Result = (); -} - -#[derive(Debug)] -pub struct Find { - pub key: String, -} -impl Message for Find { - type Result = FindResult; -} - -#[derive(Debug, Clone, PartialEq)] -pub enum FindResult { - Found { value: String }, - NotFound, -} diff --git a/examples/name_server/src/protocols.rs b/examples/name_server/src/protocols.rs new file mode 100644 index 0000000..c86ce4e --- /dev/null +++ b/examples/name_server/src/protocols.rs @@ -0,0 +1,17 @@ +use spawned_concurrency::tasks::Response; +use spawned_macros::protocol; +use std::sync::Arc; + +pub type NameServerRef = Arc; + +#[derive(Debug, Clone, PartialEq)] +pub enum FindResult { + Found { value: String }, + NotFound, +} + +#[protocol] +pub trait NameServerProtocol: Send + Sync { + fn add(&self, key: String, value: String) -> Response<()>; + fn find(&self, key: String) -> Response; +} diff --git a/examples/name_server/src/server.rs b/examples/name_server/src/server.rs index 5f18b23..853eeae 100644 --- a/examples/name_server/src/server.rs +++ b/examples/name_server/src/server.rs @@ -1,31 +1,30 @@ use std::collections::HashMap; use spawned_concurrency::tasks::{Actor, Context, Handler}; +use spawned_macros::actor; -use crate::messages::*; +use crate::protocols::name_server_protocol::{Add, Find}; +use crate::protocols::{FindResult, NameServerProtocol}; pub struct NameServer { inner: HashMap, } +#[actor(protocol = NameServerProtocol)] impl NameServer { pub fn new() -> Self { NameServer { inner: HashMap::new(), } } -} - -impl Actor for NameServer {} -impl Handler for NameServer { - async fn handle(&mut self, msg: Add, _ctx: &Context) { + #[request_handler] + async fn handle_add(&mut self, msg: Add, _ctx: &Context) { self.inner.insert(msg.key, msg.value); } -} -impl Handler for NameServer { - async fn handle(&mut self, msg: Find, _ctx: &Context) -> FindResult { + #[request_handler] + async fn handle_find(&mut self, msg: Find, _ctx: &Context) -> FindResult { match self.inner.get(&msg.key) { Some(value) => FindResult::Found { value: value.clone() }, None => FindResult::NotFound, diff --git a/examples/service_discovery/src/main.rs b/examples/service_discovery/src/main.rs index 078f32a..f3548ae 100644 --- a/examples/service_discovery/src/main.rs +++ b/examples/service_discovery/src/main.rs @@ -1,87 +1,34 @@ -use std::collections::HashMap; -use std::time::Duration; +mod protocols; +mod server; -use spawned_concurrency::messages; +use protocols::{ServiceRegistryProtocol, ServiceRegistryRef, ToServiceRegistryRef}; +use server::ServiceRegistry; use spawned_concurrency::registry; -use spawned_concurrency::tasks::{Actor, ActorStart, Context, Handler, Recipient, request}; -use spawned_macros::actor; +use spawned_concurrency::tasks::ActorStart as _; use spawned_rt::tasks as rt; -// --- Messages --- - -messages! { - Register { name: String, address: String } -> (); - Lookup { name: String } -> Option; - ListAll -> Vec<(String, String)> -} - -// --- ServiceRegistry actor --- - -struct ServiceRegistry { - services: HashMap, -} - -impl ServiceRegistry { - fn new() -> Self { - Self { - services: HashMap::new(), - } - } -} - -#[actor] -impl ServiceRegistry { - #[handler] - async fn handle_register(&mut self, msg: Register, _ctx: &Context) { - tracing::info!("Registered service '{}' at {}", msg.name, msg.address); - self.services.insert(msg.name, msg.address); - } - - #[handler] - async fn handle_lookup(&mut self, msg: Lookup, _ctx: &Context) -> Option { - self.services.get(&msg.name).cloned() - } - - #[handler] - async fn handle_list_all(&mut self, _msg: ListAll, _ctx: &Context) -> Vec<(String, String)> { - self.services.iter().map(|(k, v)| (k.clone(), v.clone())).collect() - } -} - fn main() { rt::run(async { // Start the service registry actor let svc = ServiceRegistry::new().start(); - // Register it by name — other actors can discover it - registry::register("service_registry", svc.recipient::()).unwrap(); + // Register the protocol ref by name — other actors can discover it + registry::register("service_registry", svc.to_service_registry_ref()).unwrap(); // Register some services - svc.send(Register { - name: "web".into(), - address: "http://localhost:8080".into(), - }) - .unwrap(); + svc.register_service("web".into(), "http://localhost:8080".into()) + .await + .unwrap(); - svc.send(Register { - name: "db".into(), - address: "postgres://localhost:5432".into(), - }) - .unwrap(); + svc.register_service("db".into(), "postgres://localhost:5432".into()) + .await + .unwrap(); // A consumer discovers the registry by name (doesn't need to know ServiceRegistry type) - let lookup_recipient: Recipient = registry::whereis("service_registry").unwrap(); + let svc_ref: ServiceRegistryRef = registry::whereis("service_registry").unwrap(); - // Look up a service - let addr = request( - &*lookup_recipient, - Lookup { - name: "web".into(), - }, - Duration::from_secs(5), - ) - .await - .unwrap(); + // Look up a service through the discovered ref + let addr = svc_ref.lookup("web".into()).await.unwrap(); tracing::info!("Looked up 'web': {:?}", addr); // List all registered names in the registry @@ -89,7 +36,7 @@ fn main() { tracing::info!("Registry contains: {:?}", names); // Direct request for all services - let all = svc.request(ListAll).await.unwrap(); + let all = svc.list_all().await.unwrap(); tracing::info!("All services: {:?}", all); // Clean up diff --git a/examples/service_discovery/src/protocols.rs b/examples/service_discovery/src/protocols.rs new file mode 100644 index 0000000..c90ef8a --- /dev/null +++ b/examples/service_discovery/src/protocols.rs @@ -0,0 +1,12 @@ +use spawned_concurrency::tasks::Response; +use spawned_macros::protocol; +use std::sync::Arc; + +pub type ServiceRegistryRef = Arc; + +#[protocol] +pub trait ServiceRegistryProtocol: Send + Sync { + fn register_service(&self, name: String, address: String) -> Response<()>; + fn lookup(&self, name: String) -> Response>; + fn list_all(&self) -> Response>; +} diff --git a/examples/service_discovery/src/server.rs b/examples/service_discovery/src/server.rs new file mode 100644 index 0000000..81f79a1 --- /dev/null +++ b/examples/service_discovery/src/server.rs @@ -0,0 +1,38 @@ +use std::collections::HashMap; + +use spawned_concurrency::tasks::{Actor, Context, Handler}; +use spawned_macros::actor; + +use crate::protocols::service_registry_protocol::{ListAll, Lookup, RegisterService}; +use crate::protocols::ServiceRegistryProtocol; + +pub struct ServiceRegistry { + services: HashMap, +} + +impl ServiceRegistry { + pub fn new() -> Self { + Self { + services: HashMap::new(), + } + } +} + +#[actor(protocol = ServiceRegistryProtocol)] +impl ServiceRegistry { + #[request_handler] + async fn handle_register_service(&mut self, msg: RegisterService, _ctx: &Context) { + tracing::info!("Registered service '{}' at {}", msg.name, msg.address); + self.services.insert(msg.name, msg.address); + } + + #[request_handler] + async fn handle_lookup(&mut self, msg: Lookup, _ctx: &Context) -> Option { + self.services.get(&msg.name).cloned() + } + + #[request_handler] + async fn handle_list_all(&mut self, _msg: ListAll, _ctx: &Context) -> Vec<(String, String)> { + self.services.iter().map(|(k, v)| (k.clone(), v.clone())).collect() + } +} diff --git a/examples/signal_test/Cargo.toml b/examples/signal_test/Cargo.toml index 08aadac..4e13232 100644 --- a/examples/signal_test/Cargo.toml +++ b/examples/signal_test/Cargo.toml @@ -4,7 +4,8 @@ version = "0.1.0" edition = "2021" [dependencies] -spawned-concurrency = { path = "../../concurrency" } -spawned-rt = { path = "../../rt" } +spawned-concurrency = { workspace = true } +spawned-macros = { workspace = true } +spawned-rt = { workspace = true } tracing = { workspace = true } tokio = { version = "1", features = ["signal"] } diff --git a/examples/signal_test/src/main.rs b/examples/signal_test/src/main.rs index 7df9037..15c8e98 100644 --- a/examples/signal_test/src/main.rs +++ b/examples/signal_test/src/main.rs @@ -1,21 +1,22 @@ -//! Test to verify signal handling across different Actor backends (tasks version). -//! -//! This example demonstrates using `send_message_on` to handle Ctrl+C signals. -//! The signal handler is set up in the Actor's `started()` function. -//! -//! Run with: cargo run --bin signal_test -- [async|blocking|thread] -//! -//! Then press Ctrl+C and observe: -//! - Does the actor stop gracefully? -//! - Does teardown run? - -use spawned_concurrency::message::Message; +use spawned_concurrency::error::ActorError; use spawned_concurrency::tasks::{ send_interval, send_message_on, Actor, ActorStart as _, Backend, Context, Handler, }; +use spawned_macros::{actor, protocol}; use spawned_rt::tasks::{self as rt, CancellationToken}; +use std::sync::Arc; use std::{env, time::Duration}; +pub type TickingRef = Arc; + +#[protocol] +pub trait TickingProtocol: Send + Sync { + fn tick(&self) -> Result<(), ActorError>; + fn shutdown(&self) -> Result<(), ActorError>; +} + +use ticking_protocol::{Shutdown, Tick}; + struct TickingActor { name: String, count: u64, @@ -32,27 +33,16 @@ impl TickingActor { } } -#[derive(Debug, Clone)] -struct Tick; -impl Message for Tick { - type Result = (); -} - -#[derive(Debug)] -struct Shutdown; -impl Message for Shutdown { - type Result = (); -} - -impl Actor for TickingActor { +#[actor(protocol = TickingProtocol)] +impl TickingActor { + #[started] async fn started(&mut self, ctx: &Context) { tracing::info!("[{}] Actor initialized", self.name); - - // Set up periodic ticking — need an ActorRef to use with send_message_on let timer = send_interval(Duration::from_secs(1), ctx.clone(), Tick); self.timer_token = Some(timer.cancellation_token); } + #[stopped] async fn stopped(&mut self, _ctx: &Context) { tracing::info!( "[{}] Teardown called! Final count: {}", @@ -60,17 +50,15 @@ impl Actor for TickingActor { self.count ); } -} -impl Handler for TickingActor { - async fn handle(&mut self, _msg: Tick, _ctx: &Context) { + #[send_handler] + async fn handle_tick(&mut self, _msg: Tick, _ctx: &Context) { self.count += 1; tracing::info!("[{}] Tick #{}", self.name, self.count); } -} -impl Handler for TickingActor { - async fn handle(&mut self, _msg: Shutdown, ctx: &Context) { + #[send_handler] + async fn handle_shutdown(&mut self, _msg: Shutdown, ctx: &Context) { tracing::info!("[{}] Received shutdown signal", self.name); ctx.stop(); } @@ -84,7 +72,6 @@ fn main() { tracing::info!("Starting signal test with backend: {}", backend); tracing::info!("Press Ctrl+C to test signal handling..."); - // Start two actors - both should react to Ctrl+C let (actor1, actor2) = match backend { "async" => { tracing::info!("Using Backend::Async"); @@ -116,11 +103,9 @@ fn main() { } }; - // Set up Ctrl+C handler using send_message_on send_message_on(actor1.context(), rt::ctrl_c(), Shutdown); send_message_on(actor2.context(), rt::ctrl_c(), Shutdown); - // Wait for both actors to stop actor1.join().await; actor2.join().await; diff --git a/examples/signal_test_threads/Cargo.toml b/examples/signal_test_threads/Cargo.toml index a140d11..122d2df 100644 --- a/examples/signal_test_threads/Cargo.toml +++ b/examples/signal_test_threads/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -spawned-concurrency = { path = "../../concurrency" } -spawned-rt = { path = "../../rt" } +spawned-concurrency = { workspace = true } +spawned-macros = { workspace = true } +spawned-rt = { workspace = true } tracing = { workspace = true } diff --git a/examples/signal_test_threads/src/main.rs b/examples/signal_test_threads/src/main.rs index 82b96dd..1c20185 100644 --- a/examples/signal_test_threads/src/main.rs +++ b/examples/signal_test_threads/src/main.rs @@ -1,21 +1,22 @@ -//! Test to verify signal handling for threads Actor. -//! -//! This example demonstrates using `send_message_on` to handle Ctrl+C signals. -//! The signal handler is set up after starting the actors. -//! -//! Run with: cargo run --bin signal_test_threads -//! -//! Then press Ctrl+C and observe: -//! - Does the actor stop gracefully? -//! - Does teardown run? - -use spawned_concurrency::message::Message; +use spawned_concurrency::error::ActorError; use spawned_concurrency::threads::{ send_interval, send_message_on, Actor, ActorStart as _, Context, Handler, }; +use spawned_macros::{actor, protocol}; use spawned_rt::threads::{self as rt, CancellationToken}; +use std::sync::Arc; use std::time::Duration; +pub type TickingRef = Arc; + +#[protocol] +pub trait TickingProtocol: Send + Sync { + fn tick(&self) -> Result<(), ActorError>; + fn shutdown(&self) -> Result<(), ActorError>; +} + +use ticking_protocol::{Shutdown, Tick}; + struct TickingActor { name: String, count: u64, @@ -32,27 +33,16 @@ impl TickingActor { } } -#[derive(Debug, Clone)] -struct Tick; -impl Message for Tick { - type Result = (); -} - -#[derive(Debug)] -struct Shutdown; -impl Message for Shutdown { - type Result = (); -} - -impl Actor for TickingActor { +#[actor(protocol = TickingProtocol)] +impl TickingActor { + #[started] fn started(&mut self, ctx: &Context) { tracing::info!("[{}] Actor initialized", self.name); - - // Set up periodic ticking let timer = send_interval(Duration::from_secs(1), ctx.clone(), Tick); self.timer_token = Some(timer.cancellation_token); } + #[stopped] fn stopped(&mut self, _ctx: &Context) { tracing::info!( "[{}] Teardown called! Final count: {}", @@ -60,17 +50,15 @@ impl Actor for TickingActor { self.count ); } -} -impl Handler for TickingActor { - fn handle(&mut self, _msg: Tick, _ctx: &Context) { + #[send_handler] + fn handle_tick(&mut self, _msg: Tick, _ctx: &Context) { self.count += 1; tracing::info!("[{}] Tick #{}", self.name, self.count); } -} -impl Handler for TickingActor { - fn handle(&mut self, _msg: Shutdown, ctx: &Context) { + #[send_handler] + fn handle_shutdown(&mut self, _msg: Shutdown, ctx: &Context) { tracing::info!("[{}] Received shutdown signal", self.name); ctx.stop(); } @@ -81,15 +69,12 @@ fn main() { tracing::info!("Starting signal test for threads Actor"); tracing::info!("Press Ctrl+C to test signal handling..."); - // Start two actors - both will react to Ctrl+C let actor1 = TickingActor::new("actor-1").start(); let actor2 = TickingActor::new("actor-2").start(); - // Set up Ctrl+C handler using send_message_on send_message_on(actor1.context(), rt::ctrl_c(), Shutdown); send_message_on(actor2.context(), rt::ctrl_c(), Shutdown); - // Wait for both actors to stop actor1.join(); actor2.join(); diff --git a/examples/updater/Cargo.toml b/examples/updater/Cargo.toml index 15eba99..6e99e47 100644 --- a/examples/updater/Cargo.toml +++ b/examples/updater/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] spawned-rt = { workspace = true } spawned-concurrency = { workspace = true } +spawned-macros = { workspace = true } tracing = { workspace = true } reqwest = { version = "0.11", features = ["blocking"] } futures = "0.3.1" diff --git a/examples/updater/src/main.rs b/examples/updater/src/main.rs index 2058867..adc3770 100644 --- a/examples/updater/src/main.rs +++ b/examples/updater/src/main.rs @@ -3,7 +3,7 @@ //! Just activates periodically and performs an http request //! -mod messages; +mod protocols; mod server; use std::{thread, time::Duration}; diff --git a/examples/updater/src/messages.rs b/examples/updater/src/messages.rs deleted file mode 100644 index 5bf74d7..0000000 --- a/examples/updater/src/messages.rs +++ /dev/null @@ -1,7 +0,0 @@ -use spawned_concurrency::message::Message; - -#[derive(Debug, Clone)] -pub struct Check; -impl Message for Check { - type Result = (); -} diff --git a/examples/updater/src/protocols.rs b/examples/updater/src/protocols.rs new file mode 100644 index 0000000..f50699e --- /dev/null +++ b/examples/updater/src/protocols.rs @@ -0,0 +1,11 @@ +use spawned_concurrency::error::ActorError; +use spawned_macros::protocol; +use std::sync::Arc; + +pub type UpdaterRef = Arc; + +#[protocol] +#[allow(dead_code)] +pub trait UpdaterProtocol: Send + Sync { + fn check(&self) -> Result<(), ActorError>; +} diff --git a/examples/updater/src/server.rs b/examples/updater/src/server.rs index 6d77f88..c7d5530 100644 --- a/examples/updater/src/server.rs +++ b/examples/updater/src/server.rs @@ -1,9 +1,11 @@ use std::time::Duration; use spawned_concurrency::tasks::{send_interval, Actor, Context, Handler}; +use spawned_macros::actor; use spawned_rt::tasks::CancellationToken; -use crate::messages::Check; +use crate::protocols::updater_protocol::Check; +use crate::protocols::UpdaterProtocol; pub struct UpdaterServer { pub url: String, @@ -21,15 +23,16 @@ impl UpdaterServer { } } -impl Actor for UpdaterServer { +#[actor(protocol = UpdaterProtocol)] +impl UpdaterServer { + #[started] async fn started(&mut self, ctx: &Context) { let timer = send_interval(self.periodicity, ctx.clone(), Check); self.timer_token = Some(timer.cancellation_token); } -} -impl Handler for UpdaterServer { - async fn handle(&mut self, _msg: Check, _ctx: &Context) { + #[send_handler] + async fn handle_check(&mut self, _msg: Check, _ctx: &Context) { let url = self.url.clone(); tracing::info!("Fetching: {url}"); let resp = req(url).await; diff --git a/examples/updater_threads/Cargo.toml b/examples/updater_threads/Cargo.toml index 7266750..894732b 100644 --- a/examples/updater_threads/Cargo.toml +++ b/examples/updater_threads/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] spawned-rt = { workspace = true } spawned-concurrency = { workspace = true } +spawned-macros = { workspace = true } tracing = { workspace = true } reqwest = { version = "0.11", features = ["blocking"] } futures = "0.3.1" diff --git a/examples/updater_threads/src/main.rs b/examples/updater_threads/src/main.rs index 9711e73..27dcf6b 100644 --- a/examples/updater_threads/src/main.rs +++ b/examples/updater_threads/src/main.rs @@ -3,7 +3,7 @@ //! Just activates periodically and performs an http request //! -mod messages; +mod protocols; mod server; use std::{thread, time::Duration}; diff --git a/examples/updater_threads/src/messages.rs b/examples/updater_threads/src/messages.rs deleted file mode 100644 index 5bf74d7..0000000 --- a/examples/updater_threads/src/messages.rs +++ /dev/null @@ -1,7 +0,0 @@ -use spawned_concurrency::message::Message; - -#[derive(Debug, Clone)] -pub struct Check; -impl Message for Check { - type Result = (); -} diff --git a/examples/updater_threads/src/protocols.rs b/examples/updater_threads/src/protocols.rs new file mode 100644 index 0000000..f50699e --- /dev/null +++ b/examples/updater_threads/src/protocols.rs @@ -0,0 +1,11 @@ +use spawned_concurrency::error::ActorError; +use spawned_macros::protocol; +use std::sync::Arc; + +pub type UpdaterRef = Arc; + +#[protocol] +#[allow(dead_code)] +pub trait UpdaterProtocol: Send + Sync { + fn check(&self) -> Result<(), ActorError>; +} diff --git a/examples/updater_threads/src/server.rs b/examples/updater_threads/src/server.rs index b427dfa..a723bdf 100644 --- a/examples/updater_threads/src/server.rs +++ b/examples/updater_threads/src/server.rs @@ -1,23 +1,26 @@ use std::time::Duration; use spawned_concurrency::threads::{send_after, Actor, Context, Handler}; +use spawned_macros::actor; use spawned_rt::threads::block_on; -use crate::messages::Check; +use crate::protocols::updater_protocol::Check; +use crate::protocols::UpdaterProtocol; pub struct UpdaterServer { pub url: String, pub periodicity: Duration, } -impl Actor for UpdaterServer { +#[actor(protocol = UpdaterProtocol)] +impl UpdaterServer { + #[started] fn started(&mut self, ctx: &Context) { send_after(self.periodicity, ctx.clone(), Check); } -} -impl Handler for UpdaterServer { - fn handle(&mut self, _msg: Check, ctx: &Context) { + #[send_handler] + fn handle_check(&mut self, _msg: Check, ctx: &Context) { send_after(self.periodicity, ctx.clone(), Check); let url = self.url.clone(); tracing::info!("Fetching: {url}"); diff --git a/macros/src/lib.rs b/macros/src/lib.rs index 16b00d9..0f70956 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -254,6 +254,7 @@ pub fn protocol(_attr: TokenStream, item: TokenStream) -> TokenStream { if field_names.is_empty() { quote! { + #[derive(Clone)] pub struct #struct_name; impl Message for #struct_name { type Result = #msg_result_ty; @@ -261,6 +262,7 @@ pub fn protocol(_attr: TokenStream, item: TokenStream) -> TokenStream { } } else { quote! { + #[derive(Clone)] pub struct #struct_name { #(pub #field_names: #field_types,)* } From db1cb0c15adc74b97ebda36ccaa87f6688c7df92 Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Tue, 24 Feb 2026 17:54:55 -0300 Subject: [PATCH 18/20] fix: use underscore prefix instead of allow(dead_code) on unused protocol methods --- examples/updater/src/protocols.rs | 3 +-- examples/updater_threads/src/protocols.rs | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/examples/updater/src/protocols.rs b/examples/updater/src/protocols.rs index f50699e..4dc0165 100644 --- a/examples/updater/src/protocols.rs +++ b/examples/updater/src/protocols.rs @@ -5,7 +5,6 @@ use std::sync::Arc; pub type UpdaterRef = Arc; #[protocol] -#[allow(dead_code)] pub trait UpdaterProtocol: Send + Sync { - fn check(&self) -> Result<(), ActorError>; + fn _check(&self) -> Result<(), ActorError>; } diff --git a/examples/updater_threads/src/protocols.rs b/examples/updater_threads/src/protocols.rs index f50699e..4dc0165 100644 --- a/examples/updater_threads/src/protocols.rs +++ b/examples/updater_threads/src/protocols.rs @@ -5,7 +5,6 @@ use std::sync::Arc; pub type UpdaterRef = Arc; #[protocol] -#[allow(dead_code)] pub trait UpdaterProtocol: Send + Sync { - fn check(&self) -> Result<(), ActorError>; + fn _check(&self) -> Result<(), ActorError>; } From e5d5327f76ead79bce89d8ae80f794ba4b12a532 Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Wed, 25 Feb 2026 19:17:54 -0300 Subject: [PATCH 19/20] feat: propagate doc comments from #[protocol] traits to generated structs and blanket impls --- examples/chat_room/src/protocols.rs | 6 ++++++ macros/src/lib.rs | 20 ++++++++++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/examples/chat_room/src/protocols.rs b/examples/chat_room/src/protocols.rs index cd70dea..fc690e2 100644 --- a/examples/chat_room/src/protocols.rs +++ b/examples/chat_room/src/protocols.rs @@ -8,14 +8,20 @@ pub type UserRef = Arc; #[protocol] pub trait RoomProtocol: Send + Sync { + /// Broadcast a message from a user to all other members in the room. fn say(&self, from: String, text: String) -> Result<(), ActorError>; + /// Add a new member to the room. fn add_member(&self, name: String, user: UserRef) -> Result<(), ActorError>; + /// Return the list of member names currently in the room. fn members(&self) -> Response>; } #[protocol] pub trait UserProtocol: Send + Sync { + /// Deliver a message to this user's inbox. fn deliver(&self, from: String, text: String) -> Result<(), ActorError>; + /// Ask this user to say something in the room. fn say(&self, text: String) -> Result<(), ActorError>; + /// Tell this user to join a room. fn join_room(&self, room: RoomRef) -> Result<(), ActorError>; } diff --git a/macros/src/lib.rs b/macros/src/lib.rs index 0f70956..4035e48 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -1,8 +1,8 @@ use proc_macro::TokenStream; use quote::{format_ident, quote}; use syn::{ - parse::Parse, parse_macro_input, FnArg, GenericArgument, Ident, ImplItem, ImplItemFn, - ItemImpl, ItemTrait, Pat, PathArguments, ReturnType, TraitItem, Type, TypePath, + parse::Parse, parse_macro_input, Attribute, FnArg, GenericArgument, Ident, ImplItem, + ImplItemFn, ItemImpl, ItemTrait, Pat, PathArguments, ReturnType, TraitItem, Type, TypePath, }; // --- Helpers for #[protocol] --- @@ -123,6 +123,7 @@ fn generate_blanket_impl( let field_names = &m.field_names; let params: Vec<_> = m.params.iter().collect(); let ret_ty = &m.ret_type; + let doc_attrs = &m.doc_attrs; let struct_name = &m.struct_name; let msg_construct = if field_names.is_empty() { @@ -134,6 +135,7 @@ fn generate_blanket_impl( match &m.kind { MethodKind::Send => { quote! { + #(#doc_attrs)* fn #method_name(&self, #(#params),*) #ret_ty { self.send(#msg_construct) } @@ -141,6 +143,7 @@ fn generate_blanket_impl( } MethodKind::AsyncRequest(_) => { quote! { + #(#doc_attrs)* fn #method_name(&self, #(#params),*) #ret_ty { spawned_concurrency::tasks::Response::from( self.request_raw(#msg_construct), @@ -150,6 +153,7 @@ fn generate_blanket_impl( } MethodKind::SyncRequest(_) => { quote! { + #(#doc_attrs)* fn #method_name(&self, #(#params),*) #ret_ty { self.request(#msg_construct) } @@ -184,6 +188,7 @@ struct ProtocolMethodInfo { kind: MethodKind, params: Vec, ret_type: ReturnType, + doc_attrs: Vec, } #[proc_macro_attribute] @@ -228,6 +233,13 @@ pub fn protocol(_attr: TokenStream, item: TokenStream) -> TokenStream { MethodKind::Send => {} } + let doc_attrs: Vec = method + .attrs + .iter() + .filter(|a| a.path().is_ident("doc")) + .cloned() + .collect(); + methods.push(ProtocolMethodInfo { method_name, struct_name, @@ -236,6 +248,7 @@ pub fn protocol(_attr: TokenStream, item: TokenStream) -> TokenStream { kind, params, ret_type: method.sig.output.clone(), + doc_attrs, }); } } @@ -247,6 +260,7 @@ pub fn protocol(_attr: TokenStream, item: TokenStream) -> TokenStream { let struct_name = &m.struct_name; let field_names = &m.field_names; let field_types = &m.field_types; + let doc_attrs = &m.doc_attrs; let msg_result_ty: Box = match &m.kind { MethodKind::Send => syn::parse_quote! { () }, MethodKind::AsyncRequest(inner) | MethodKind::SyncRequest(inner) => inner.clone(), @@ -254,6 +268,7 @@ pub fn protocol(_attr: TokenStream, item: TokenStream) -> TokenStream { if field_names.is_empty() { quote! { + #(#doc_attrs)* #[derive(Clone)] pub struct #struct_name; impl Message for #struct_name { @@ -262,6 +277,7 @@ pub fn protocol(_attr: TokenStream, item: TokenStream) -> TokenStream { } } else { quote! { + #(#doc_attrs)* #[derive(Clone)] pub struct #struct_name { #(pub #field_names: #field_types,)* From 39b7df1adbcb466e15a8a9a330239ab87a6826b6 Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Wed, 25 Feb 2026 23:19:52 -0300 Subject: [PATCH 20/20] feat: generate protocol cross-reference docs on impl Actor blocks --- macros/src/lib.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/macros/src/lib.rs b/macros/src/lib.rs index 4035e48..a85a678 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -523,7 +523,26 @@ pub fn actor(attr: TokenStream, item: TokenStream) -> TokenStream { .flatten() .collect(); + let protocol_doc = if bridge_traits.is_empty() { + quote! {} + } else { + let lines: Vec = bridge_traits + .iter() + .map(|t| format!("- [`{t}`]")) + .collect(); + let doc_body = format!( + "# Protocol\n\n\ + When started, `ActorRef<{ty}>` implements:\n\n\ + {lines}\n\n\ + See the protocol trait docs for the full API.", + ty = quote!(#self_ty), + lines = lines.join("\n"), + ); + quote! { #[doc = #doc_body] } + }; + let actor_impl = quote! { + #protocol_doc impl #impl_generics Actor for #self_ty #where_clause { #(#lifecycle_methods)* }