diff --git a/docs/design/API_ALTERNATIVES_QUICK_REFERENCE.md b/docs/design/API_ALTERNATIVES_QUICK_REFERENCE.md new file mode 100644 index 0000000..d51adba --- /dev/null +++ b/docs/design/API_ALTERNATIVES_QUICK_REFERENCE.md @@ -0,0 +1,912 @@ +# API Redesign: Quick Reference + +Condensed version of [API_ALTERNATIVES_SUMMARY.md](./API_ALTERNATIVES_SUMMARY.md). Same structure, same code examples, shorter analysis. + +## Table of Contents + +- [The Three Problems](#the-three-problems) +- [The Chat Room Example](#the-chat-room-example) +- [Baseline: The Old API](#baseline-the-old-api) +- [Approach A: Handler\ + Recipient\](#approach-a-handlerm--recipientm) +- [Approach B: Protocol Traits](#approach-b-protocol-traits) +- [Approach C: Typed Wrappers](#approach-c-typed-wrappers) +- [Approach D: Derive Macro](#approach-d-derive-macro) +- [Approach E: AnyActorRef](#approach-e-anyactorref) +- [Approach F: PID Addressing](#approach-f-pid-addressing) +- [Registry & Service Discovery](#registry--service-discovery) +- [Comparison Matrix](#comparison-matrix) +- [Recommendation](#recommendation) +- [Branch Reference](#branch-reference) + +--- + +## The Three Problems + +**#144 — No per-message type safety.** The old API uses one enum for all requests and another for all replies. Callers must match impossible variants: + +```rust +match actor.request(Request::GetName).await? { + Reply::Name(n) => println!("{}", n), + Reply::Age(_) => unreachable!(), // impossible but required +} +``` + +**#145 — Circular dependencies.** When two actors communicate bidirectionally, storing `ActorRef` and `ActorRef` creates a module cycle. + +**Service discovery.** Real systems don't wire actors in `main.rs` — they discover each other at runtime by name. The registry API is the same across approaches, but **what you store** (and what the discoverer gets back) varies dramatically. + +--- + +## The Chat Room Example + +Every approach implements: **ChatRoom** ↔ **User** (bidirectional), `Members` request-reply, and a **late joiner (Charlie)** who discovers the room via the registry. This exercises #144, #145, and service discovery. + +--- + +## Baseline: The Old API + +Single-enum `Actor` trait with associated types for Request/Message/Reply: + +```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. Even ignoring that, 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 +} +``` + +--- + +## Approach A: Handler\ + Recipient\ + +**Status:** Implemented on `feat/approach-a`. 34 tests passing. + +Each message is its own struct with `type Result`. Actors implement `Handler` per message. Type erasure via `Recipient = Arc>`. Messages live in the module that handles them (room.rs defines Room's messages, user.rs defines User's messages). + +### Without macro (manual `impl Handler`) + +
+room.rs — defines Room's messages, imports Deliver from user + +```rust +use spawned_concurrency::message::Message; +use spawned_concurrency::tasks::{Actor, Context, Handler, Recipient}; +use crate::user::Deliver; + +// -- Messages (Room handles these) -- + +pub struct Say { pub from: String, pub text: String } +impl Message for Say { type Result = (); } + +pub struct Join { pub name: String, pub inbox: Recipient } +impl Message for Join { type Result = (); } + +pub struct Members; +impl Message for Members { type Result = Vec; } + +// -- Actor -- + +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() }); + } + } + } +} + +impl Handler for ChatRoom { + async fn handle(&mut self, _msg: Members, _ctx: &Context) -> Vec { + self.members.iter().map(|(name, _)| name.clone()).collect() + } +} +``` +
+ +
+user.rs — defines User's messages (including Deliver), imports Say from room + +```rust +use spawned_concurrency::message::Message; +use spawned_concurrency::tasks::{Actor, Context, Handler, Recipient}; +use crate::room::Say; + +// -- Messages (User handles these) -- + +pub struct Deliver { pub from: String, pub text: String } +impl Message for Deliver { type Result = (); } + +pub struct SayToRoom { pub text: String } +impl Message for SayToRoom { type Result = (); } + +// -- Actor -- + +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?; + +let members: Vec = room.send_request(Members).await?; + +alice.send_request(SayToRoom { text: "Hello everyone!".into() }).await?; +``` +
+ +### With `#[actor]` macro + `actor_api!` + +
+room.rs — macros eliminate both Handler and extension trait boilerplate + +```rust +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; +use crate::user::Deliver; + +// -- Messages -- + +send_messages! { + Say { 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 async 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] + 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 — defines Deliver (User's inbox message) + macro version + +```rust +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}; + +// -- Messages -- + +send_messages! { + Deliver { from: String, text: String }; + 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] + 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(); + +// Register the room by name +registry::register("general", room.clone()).unwrap(); + +alice.join_room(room.clone()).unwrap(); +bob.join_room(room.clone()).unwrap(); + +let members = room.members().await.unwrap(); + +alice.say("Hello everyone!".into()).unwrap(); +bob.say("Hi Alice!".into()).unwrap(); + +// Late joiner discovers the room — must know the concrete type +let charlie = User::new("Charlie".into()).start(); +let discovered: ActorRef = registry::whereis("general").unwrap(); +charlie.join_room(discovered).unwrap(); +``` +
+ +### Analysis + +Type erasure via `Recipient` — per-message granularity. Messages live in the actor that handles them (bidirectional module imports: room↔user). `actor_api!` provides method-call syntax. Proven Actix pattern. + +**Cross-crate limitation:** The bidirectional module imports work within a crate but become circular crate dependencies if Room and User are in separate crates. You'd need to extract shared types into a third crate — effectively recreating Approach B's `protocols.rs`. + +**Registry trade-off:** Register `ActorRef` (full API, but discoverer must know the concrete type) or `Recipient` per message (decoupled but fragmented). + +--- + +## Approach B: Protocol Traits + +**Status:** Implemented on `feat/approach-b`. 34 tests passing. + +Same `Handler` core as A. Solves #145 differently: each actor defines a **protocol trait** (its complete public API, like Erlang module exports). Actors communicate through `Arc`, never `ActorRef`. All types live in a shared `protocols.rs` — no bidirectional imports. + +`#[protocol]` generates message structs, converter traits, and blanket impls from the trait definition. Return types determine runtime mode: `Result<(), ActorError>` → send, `Response` → async request, `Result` → sync request. `Response` keeps traits object-safe (no RPITIT, no BoxFuture). + +### Without macro (expanded reference) + +
+ +
+room.rs — manual Actor + Handler impls + protocol assertion + +```rust +use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; + +use crate::protocols::room_protocol::{AddMember, Members, Say}; +use crate::protocols::{RoomProtocol, UserRef}; + +pub struct ChatRoom { + members: Vec<(String, UserRef)>, +} + +impl ChatRoom { + pub fn new() -> Self { + Self { members: Vec::new() } + } +} + +impl Actor for ChatRoom {} + +impl Handler for ChatRoom { + async fn handle(&mut self, msg: Say, _ctx: &Context) { + tracing::info!("[room] {} says: {}", msg.from, msg.text); + for (name, user) in &self.members { + if *name != msg.from { + let _ = user.deliver(msg.from.clone(), msg.text.clone()); + } + } + } +} + +impl Handler for ChatRoom { + async fn handle(&mut self, msg: AddMember, _ctx: &Context) { + tracing::info!("[room] {} joined", msg.name); + self.members.push((msg.name, msg.user)); + } +} + +impl Handler for ChatRoom { + async fn handle(&mut self, _msg: Members, _ctx: &Context) -> Vec { + self.members.iter().map(|(name, _)| name.clone()).collect() + } +} + +// Compile-time assertion: ActorRef must satisfy RoomProtocol +const _: () = { + fn _assert() {} + fn _check() { _assert::>(); } +}; +``` +
+ +
+user.rs — same pattern, manual Actor + Handler impls + +```rust +use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; + +use crate::protocols::user_protocol::{Deliver, JoinRoom, Say}; +use crate::protocols::{RoomRef, ToUserRef, UserProtocol}; + +pub struct User { + name: String, + room: Option, +} + +impl User { + pub fn new(name: String) -> Self { + Self { name, room: None } + } +} + +impl Actor for User {} + +impl Handler for User { + async fn handle(&mut self, msg: Deliver, _ctx: &Context) { + tracing::info!("[{}] got: {} says '{}'", self.name, msg.from, msg.text); + } +} + +impl Handler for User { + async fn handle(&mut self, msg: Say, _ctx: &Context) { + if let Some(ref room) = self.room { + let _ = room.say(self.name.clone(), msg.text); + } + } +} + +impl Handler for User { + async fn handle(&mut self, msg: JoinRoom, ctx: &Context) { + let _ = msg.room.add_member(self.name.clone(), ctx.actor_ref().to_user_ref()); + self.room = Some(msg.room); + } +} + +// Compile-time assertion: ActorRef must satisfy UserProtocol +const _: () = { + fn _assert() {} + fn _check() { _assert::>(); } +}; +``` +
+ +
+main.rs — identical to macro version (no macros used here) + +```rust +let room = ChatRoom::new().start(); +let alice = User::new("Alice".into()).start(); +let bob = User::new("Bob".into()).start(); + +// Register the room's protocol — not the concrete type +registry::register("general", room.to_room_ref()).unwrap(); + +alice.join_room(room.to_room_ref()).unwrap(); +bob.join_room(room.to_room_ref()).unwrap(); + +let members = room.members().await.unwrap(); + +alice.say("Hello everyone!".into()).unwrap(); +bob.say("Hey Alice!".into()).unwrap(); + +// Late joiner discovers the room — only needs the protocol, not the concrete type +let charlie = User::new("Charlie".into()).start(); +let discovered: RoomRef = registry::whereis("general").unwrap(); +charlie.join_room(discovered).unwrap(); +``` + +Protocol methods (`say`, `join_room`, `members`) are called directly on `ActorRef` via blanket impls. The `.to_room_ref()` conversion makes the protocol boundary explicit. +
+ +### With `#[protocol]` + `#[actor]` macros + +
+protocols.rs — one protocol per actor, #[protocol] generates everything + +```rust +use spawned_concurrency::error::ActorError; +use spawned_concurrency::tasks::Response; +use spawned_macros::protocol; +use std::sync::Arc; + +// Manual type aliases for circular references between protocols +pub type RoomRef = Arc; +pub type UserRef = Arc; + +// #[protocol] generates for each trait: +// Conversion trait: pub trait ToRoomRef { fn to_room_ref(&self) -> RoomRef; } +// Identity impl: impl ToRoomRef for RoomRef { ... } +// Message structs: room_protocol::{Say, AddMember, Members} with Message impls +// Blanket impl: impl + ...> RoomProtocol for ActorRef +// Blanket impl: impl + ...> ToRoomRef for ActorRef + +#[protocol] +pub trait RoomProtocol: Send + Sync { + fn say(&self, from: String, text: String) -> Result<(), ActorError>; + fn add_member(&self, name: String, user: UserRef) -> Result<(), ActorError>; + fn members(&self) -> Response>; +} + +#[protocol] +pub trait UserProtocol: Send + Sync { + fn deliver(&self, from: String, text: String) -> Result<(), ActorError>; + fn say(&self, text: String) -> Result<(), ActorError>; + fn join_room(&self, room: RoomRef) -> Result<(), ActorError>; +} +``` +
+ +
+room.rs#[actor(protocol = RoomProtocol)] generates Actor impl, Handler impls, and assertion + +```rust +use spawned_concurrency::tasks::{Actor, Context, Handler}; +use spawned_macros::actor; + +use crate::protocols::room_protocol::{AddMember, Members, Say}; +use crate::protocols::{RoomProtocol, UserRef}; + +pub struct ChatRoom { + members: Vec<(String, UserRef)>, +} + +#[actor(protocol = RoomProtocol)] +impl ChatRoom { + pub fn new() -> Self { + Self { members: Vec::new() } + } + + #[send_handler] + async fn handle_say(&mut self, msg: Say, _ctx: &Context) { + tracing::info!("[room] {} says: {}", msg.from, msg.text); + for (name, user) in &self.members { + if *name != msg.from { + 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() + } +} +``` +
+ +
+user.rs#[actor(protocol = UserProtocol)], symmetric with room.rs + +```rust +use spawned_concurrency::tasks::{Actor, Context, Handler}; +use spawned_macros::actor; + +use crate::protocols::user_protocol::{Deliver, JoinRoom, Say}; +use crate::protocols::{RoomRef, ToUserRef, UserProtocol}; + +pub struct User { + name: String, + room: Option, +} + +#[actor(protocol = UserProtocol)] +impl User { + pub fn new(name: String) -> Self { + Self { name, room: None } + } + + #[send_handler] + 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(&mut self, msg: Say, _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.actor_ref().to_user_ref()); + self.room = Some(msg.room); + } +} +``` +
+ +
+main.rs — protocol refs for wiring and discovery + +```rust +let room = ChatRoom::new().start(); +let alice = User::new("Alice".into()).start(); +let bob = User::new("Bob".into()).start(); + +// Register the room's protocol — not the concrete type +registry::register("general", room.to_room_ref()).unwrap(); + +alice.join_room(room.to_room_ref()).unwrap(); +bob.join_room(room.to_room_ref()).unwrap(); + +let members = room.members().await.unwrap(); + +alice.say("Hello everyone!".into()).unwrap(); +bob.say("Hey Alice!".into()).unwrap(); + +// Late joiner discovers the room — only needs the protocol, not the concrete type +let charlie = User::new("Charlie".into()).start(); +let discovered: RoomRef = registry::whereis("general").unwrap(); +charlie.join_room(discovered).unwrap(); +``` +
+ +### Analysis + +Protocol traits ARE the API — `RoomProtocol` tells you everything a room can do. All types in shared `protocols.rs` — zero cross-actor dependencies, crate-ready from day one. Best testability (mock protocol traits directly). Lowest boilerplate with macros: `#[protocol]` trait + `#[actor(protocol = X)]` handlers. + +**Registry:** One registration per protocol, full API, discoverer only needs the protocol trait (not the concrete type). This is the cleanest discovery story. + +--- + +## Approach C: Typed Wrappers + +**Status:** Design only. + +Keeps the old enum `Actor` trait. Adds typed convenience methods hiding enum matching. For #145, adds a second envelope-based channel alongside the enum channel. + +**Fatal flaw:** The old `Message` requires `Clone`, but `Recipient` (Arc-based) can't always satisfy it. Cross-boundary messages are forced onto the second channel, splitting actor logic across two dispatch systems. More confusion than a clean break. + +--- + +## Approach D: Derive Macro + +**Status:** Design only. + +`#[derive(ActorMessages)]` on an enum auto-generates per-variant message structs, `Message` impls, typed wrappers, and `Handler` delegation. + +Compact definition but generated code is invisible without `cargo expand`. Largest blast radius of any macro approach — must handle `Recipient` in fields, mixed send/request variants, `Clone` bounds. Trades visibility for conciseness. + +--- + +## Approach E: AnyActorRef + +**Status:** Design only. + +Fully type-erased `AnyActorRef = Arc` using `Box`. Callers use `send_any(Box::new(...))` and must `downcast()` replies. Solves #145 by erasing all types, but also erases compile-time safety. Wrong message types → runtime panics. + +--- + +## Approach F: PID Addressing + +**Status:** Design only. + +Every actor gets a `Pid(u64)`. Global registry maps `(Pid, TypeId)` → sender. Most Erlang-faithful, best for clustering (location-transparent). But: unregistered message type or dead PID → runtime error. Requires explicit `room.register::()` per message type. + +--- + +## Registry & Service Discovery + +Same API everywhere (`register`, `whereis`, `unregister`). The key difference is what you store and retrieve: + +```rust +// Approach A — discoverer must know the concrete type +registry::register("general", room.clone()).unwrap(); +let discovered: ActorRef = registry::whereis("general").unwrap(); + +// Approach B — discoverer only needs the protocol +registry::register("general", room.to_room_ref()).unwrap(); +let discovered: RoomRef = registry::whereis("general").unwrap(); +``` + +| Approach | Stored value | Type safety | Granularity | +|----------|-------------|-------------|-------------| +| **A: Recipient** | `ActorRef
` or `Recipient` | Compile-time (but coupled or fragmented) | Per actor or per message | +| **B: Protocol** | `Arc` | Compile-time, decoupled | Per protocol (one reg, full API) | +| **E: AnyActorRef** | `AnyActorRef` | Runtime only | Per actor, no type info | +| **F: PID** | `Pid` | Runtime only | Per actor | + +--- + +## Comparison Matrix + +| Dimension | A: Recipient | B: Protocol | C: Typed Wrappers | D: Derive | E: AnyActorRef | F: PID | +|-----------|:-----------:|:-----------:|:-----------------:|:---------:|:--------------:|:------:| +| **Implemented** | Yes | Yes | No | No | No | No | +| **Breaking change** | 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 | Runtime | +| **API at a glance** | `actor_api!` block | Protocol trait (best) | Wrapper fns | Annotated enum | Opaque | Opaque | +| **Boilerplate per msg** | struct + `actor_api!` line + handler | protocol method + handler | enum variant + wrapper + match | enum variant + annotation | struct | struct + registration | +| **main.rs ergonomics** | `alice.say("Hi")` | `room.say(...)`, `room.to_room_ref()` | `ChatRoom::say(&room, ...)` | `room.say(...)` | `send_any(Box::new(...))` | `send(pid, ...)` | +| **Registry discovery** | Must know concrete type or per-msg handles | Only needs protocol trait | Depends | Same as A | No type info | No type info | +| **Testability** | Good (mock Recipient) | Best (mock traits) | Good | Good | Fair | Hard | +| **Cross-crate ready** | Needs restructuring (bidirectional imports) | Yes (`protocols.rs` → shared crate) | N/A | N/A | N/A | N/A | +| **Dual-mode (async+threads)** | Works | Auto-detected | Complex | Complex | Works | Works | +| **Clustering readiness** | Needs RemoteRecipient | Needs remote bridge | Hard | Hard | Possible | Excellent | +| **Erlang alignment** | Actix-like | Most Erlang (protocol = module exports) | Actix-like | Actix-like | Erlang-ish | Erlang PIDs | + +--- + +## Recommendation + +**Approach A** is the most mature and balanced: +- Proven Actix pattern, 34 tests, full macro support +- `actor_api!` provides clean method-call syntax +- Registry trade-off: full API requires knowing concrete type, or per-message handles are fragmented +- Cross-crate: bidirectional imports need restructuring into a shared types crate + +**Approach B** is the strongest alternative with the most Erlang-like architecture: +- One protocol per actor = Erlang gen_server module exports +- `#[protocol]` + `#[actor(protocol = X)]` — lowest boilerplate, no separate API macros needed +- Best registry story: one registration, full API, no concrete type +- Best testability: mock protocol traits directly +- Crate-ready from day one: `protocols.rs` maps directly to a shared crate + +**C/D** add complexity trying to preserve the old enum API. **E/F** sacrifice compile-time safety (F may matter later for clustering). + +--- + +## Branch Reference + +| Branch | Description | +|--------|-------------| +| `main` | Old enum-based API (baseline) | +| [`feat/approach-a`](https://github.com/lambdaclass/spawned/tree/feat/approach-a) | **Approach A** — Recipient\ + actor_api! | +| [`feat/approach-b`](https://github.com/lambdaclass/spawned/tree/feat/approach-b) | **Approach B** — `#[protocol]` + `#[actor(protocol = X)]` | +| [`feat/actor-macro-registry`](https://github.com/lambdaclass/spawned/tree/de651ad21e2dd39babf534cb74174ae0fe3b399c) | `#[actor]` macro + named registry | +| `docs/api-comparison` | This document + full detailed comparison | + +See [API_ALTERNATIVES_SUMMARY.md](./API_ALTERNATIVES_SUMMARY.md) for the full detailed comparison with expanded analysis tables. diff --git a/docs/design/API_ALTERNATIVES_SUMMARY.md b/docs/design/API_ALTERNATIVES_SUMMARY.md new file mode 100644 index 0000000..8fc83e4 --- /dev/null +++ b/docs/design/API_ALTERNATIVES_SUMMARY.md @@ -0,0 +1,1457 @@ +# API Redesign: Alternatives Summary + +This document summarizes the different approaches explored for solving three critical API issues in spawned's actor framework. Each approach is illustrated with the **same example** — a chat room with bidirectional communication and runtime discovery — so the trade-offs in expressivity, readability, and ease of use can be compared directly. + +## Table of Contents + +- [The Three Problems](#the-three-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-one-protocol-per-actor) +- [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) +- [Registry & Service Discovery](#registry--service-discovery) +- [Macro Improvement Potential](#macro-improvement-potential) +- [Comparison Matrix](#comparison-matrix) +- [Recommendation](#recommendation) +- [Branch Reference](#branch-reference) + +--- + +## The Three 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! +``` + +### Service discovery: finding actors at runtime + +The examples above wire actors together in `main.rs`: `alice.join_room(room.clone())`. In real systems, actors discover each other at runtime — a new user joining doesn't have a direct reference to the room; it looks it up by name. + +The registry is a global name store (`HashMap>`) that maps names to values. But **what** you store determines what the discoverer gets back — and how much of the actor's API is available without knowing its concrete type: + +```rust +// The question: what type does the discoverer get back? +let room = registry::whereis::("general").unwrap(); + +// A: ActorRef — requires knowing the concrete actor type +// B: RoomRef (Arc) — requires only the protocol +``` + +This is where the #145 solutions diverge most clearly. Approach A's `Recipient` erases at the message level, so discovery returns per-message handles. Approach B's protocol traits erase at the actor level, so discovery returns the full actor API behind a single trait object. + +--- + +## 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 +- The room registers itself by name; a late joiner discovers it via the **registry** + +This exercises all three problems: #144 (typed request-reply), #145 (circular dependency breaking), and service discovery (registry lookup without direct references). + +--- + +## 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/approach-a`](https://github.com/lambdaclass/spawned/tree/feat/approach-a) (pure Recipient\ + actor_api!), [`feat/actor-macro-registry`](https://github.com/lambdaclass/spawned/tree/de651ad21e2dd39babf534cb74174ae0fe3b399c) (adds macro + registry), [`feat/handler-api-v0.5`](https://github.com/lambdaclass/spawned/tree/34bf9a759cda72e5311efda8f1fc8a5ae515129a) (early implementation), [`feat/critical-api-issues`](https://github.com/lambdaclass/spawned/tree/1ef33bf0c463543dca379463c554ccc5914c86ff) (design doc) + +**Status:** Fully implemented on `feat/approach-a`. 34 tests passing. All examples rewritten to pure Recipient\ + actor_api! pattern. + +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`) + +
+room.rs — defines Room's messages, imports Deliver from user + +```rust +use spawned_concurrency::message::Message; +use spawned_concurrency::tasks::{Actor, Context, Handler, Recipient}; +use crate::user::Deliver; + +// -- Messages (Room handles these) -- + +pub struct Say { pub from: String, pub text: String } +impl Message for Say { type Result = (); } + +pub struct Join { pub name: String, pub inbox: Recipient } +impl Message for Join { type Result = (); } + +pub struct Members; +impl Message for Members { type Result = Vec; } + +// -- Actor -- + +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() }); + } + } + } +} + +impl Handler for ChatRoom { + async fn handle(&mut self, _msg: Members, _ctx: &Context) -> Vec { + self.members.iter().map(|(name, _)| name.clone()).collect() + } +} +``` +
+ +
+user.rs — defines User's messages (including Deliver), imports Say from room + +```rust +use spawned_concurrency::message::Message; +use spawned_concurrency::tasks::{Actor, Context, Handler, Recipient}; +use crate::room::Say; + +// -- Messages (User handles these) -- + +pub struct Deliver { pub from: String, pub text: String } +impl Message for Deliver { type Result = (); } + +pub struct SayToRoom { pub text: String } +impl Message for SayToRoom { type Result = (); } + +// -- Actor -- + +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?; + +let members: Vec = room.send_request(Members).await?; + +alice.send_request(SayToRoom { text: "Hello everyone!".into() }).await?; +``` +
+ +### With `#[actor]` macro + `actor_api!` + +
+room.rs — macros eliminate both Handler and extension trait boilerplate + +```rust +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; +use crate::user::Deliver; + +// -- Messages -- + +send_messages! { + Say { 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 async 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] + 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 — defines Deliver (User's inbox message) + macro version + +```rust +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}; + +// -- Messages -- + +send_messages! { + Deliver { from: String, text: String }; + 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] + 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(); + +// Register the room by name +registry::register("general", room.clone()).unwrap(); + +alice.join_room(room.clone()).unwrap(); +bob.join_room(room.clone()).unwrap(); + +let members = room.members().await.unwrap(); + +alice.say("Hello everyone!".into()).unwrap(); +bob.say("Hi Alice!".into()).unwrap(); + +// Late joiner discovers the room — must know the concrete type +let charlie = User::new("Charlie".into()).start(); +let discovered: ActorRef = registry::whereis("general").unwrap(); +charlie.join_room(discovered).unwrap(); +``` +
+ +### Analysis + +| Dimension | Non-macro | With `#[actor]` macro + `actor_api!` | +|-----------|-----------|--------------------------------------| +| **Readability** | Each `impl Handler` block is self-contained. Messages live in the module that handles them. Bidirectional imports between sibling modules (room↔user) but no shared `messages.rs`. | `#[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`. Bidirectional module imports (room imports `Deliver` from user, user imports `Say` from room) — works within a crate but not across crates. | Same mechanism. The macros don't change how type erasure works. | +| **Registry** | Register individual `Recipient` per message type. Fine-grained but requires multiple registrations for a multi-message actor. | Register `ActorRef` — gives full API via `ChatRoomApi`, but the discoverer must know the concrete actor type. | +| **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 `#[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. + +**Cross-crate limitation:** In the macro version, `Deliver` lives in `user.rs` (the actor that handles it) and room imports it — creating a bidirectional module dependency. This works within a single crate (sibling modules can reference each other), but Rust forbids circular crate dependencies. If Room and User were in separate crates, you'd need to extract shared types (`Deliver`, `ChatRoomApi`) into a third crate — effectively recreating Approach B's `protocols.rs` pattern. This is the main motivation for Approach B: its `protocols.rs` structure maps directly to a separate crate with zero restructuring. + +--- + +## Approach B: Protocol Traits (one protocol per actor) + +**Branch:** [`feat/approach-b`](https://github.com/lambdaclass/spawned/tree/feat/approach-b) (`#[protocol]` + `#[actor(protocol = X)]` macros + Context::actor_ref()) + +**Status:** Fully implemented on `feat/approach-b`. 34 tests passing. All examples rewritten to protocol traits with `#[protocol]` and `#[actor(protocol = X)]` macros. + +Uses the same `Handler` and `#[actor]` macro as Approach A for #144. Solves #145 with a different philosophy inspired by Erlang's gen_server: each actor defines a **protocol trait** that represents its complete public API — like an Erlang module's exported functions. Actors communicate through protocol refs (`Arc`), never through concrete `ActorRef` types. One protocol per actor. + +**Key design:** `#[protocol]` on a trait definition generates everything — message structs from method signatures, conversion trait, blanket `impl Protocol for ActorRef
`, and blanket `impl ToXRef for ActorRef`. Names are derived by convention (`RoomProtocol` → `RoomRef`, `ToRoomRef`). `#[actor(protocol = X)]` auto-generates `impl Actor`, `Handler` impls from annotated methods, and a compile-time assertion that `ActorRef: Protocol`. `Response` enables async request-response on protocol traits without breaking object safety, and `Context::actor_ref()` lets actors obtain their own `ActorRef` for self-registration. + +### 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. + +### Without macro (expanded reference) + +This section shows what `#[protocol]` and `#[actor(protocol = X)]` generate under the hood. The macro versions follow below. + +
+protocols.rs — traits + message structs + blanket impls (all manual) + +```rust +use spawned_concurrency::error::ActorError; +use spawned_concurrency::message::Message; +use spawned_concurrency::tasks::{Actor, ActorRef, Handler, Response}; +use std::sync::Arc; + +// --- Type aliases (manually declared for cross-protocol references) --- + +pub type RoomRef = Arc; +pub type UserRef = Arc; + +// --- RoomProtocol --- + +pub trait RoomProtocol: Send + Sync { + fn say(&self, from: String, text: String) -> Result<(), ActorError>; + fn add_member(&self, name: String, user: UserRef) -> Result<(), ActorError>; + fn members(&self) -> Response>; +} + +pub mod room_protocol { + use super::*; + + pub struct Say { pub from: String, pub text: String } + impl Message for Say { type Result = (); } + + pub struct AddMember { pub name: String, pub user: UserRef } + impl Message for AddMember { type Result = (); } + + pub struct Members; + impl Message for Members { type Result = Vec; } +} + +pub trait ToRoomRef { + fn to_room_ref(&self) -> RoomRef; +} + +impl ToRoomRef for RoomRef { + fn to_room_ref(&self) -> RoomRef { + Arc::clone(self) + } +} + +impl + Handler + Handler> + RoomProtocol for ActorRef +{ + fn say(&self, from: String, text: String) -> Result<(), ActorError> { + self.send(room_protocol::Say { from, text }) + } + + fn add_member(&self, name: String, user: UserRef) -> Result<(), ActorError> { + self.send(room_protocol::AddMember { name, user }) + } + + fn members(&self) -> Response> { + Response::from(self.request_raw(room_protocol::Members)) + } +} + +impl + Handler + Handler> + ToRoomRef for ActorRef +{ + fn to_room_ref(&self) -> RoomRef { + Arc::new(self.clone()) + } +} + +// --- UserProtocol --- + +pub trait UserProtocol: Send + Sync { + fn deliver(&self, from: String, text: String) -> Result<(), ActorError>; + fn say(&self, text: String) -> Result<(), ActorError>; + fn join_room(&self, room: RoomRef) -> Result<(), ActorError>; +} + +pub mod user_protocol { + use super::*; + + pub struct Deliver { pub from: String, pub text: String } + impl Message for Deliver { type Result = (); } + + pub struct Say { pub text: String } + impl Message for Say { type Result = (); } + + pub struct JoinRoom { pub room: RoomRef } + impl Message for JoinRoom { type Result = (); } +} + +pub trait ToUserRef { + fn to_user_ref(&self) -> UserRef; +} + +impl ToUserRef for UserRef { + fn to_user_ref(&self) -> UserRef { + Arc::clone(self) + } +} + +impl + Handler + Handler> + UserProtocol for ActorRef +{ + fn deliver(&self, from: String, text: String) -> Result<(), ActorError> { + self.send(user_protocol::Deliver { from, text }) + } + + fn say(&self, text: String) -> Result<(), ActorError> { + self.send(user_protocol::Say { text }) + } + + fn join_room(&self, room: RoomRef) -> Result<(), ActorError> { + self.send(user_protocol::JoinRoom { room }) + } +} + +impl + Handler + Handler> + ToUserRef for ActorRef +{ + fn to_user_ref(&self) -> UserRef { + Arc::new(self.clone()) + } +} +``` +
+ +
+room.rs — manual Actor + Handler impls + protocol assertion + +```rust +use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; + +use crate::protocols::room_protocol::{AddMember, Members, Say}; +use crate::protocols::{RoomProtocol, UserRef}; + +pub struct ChatRoom { + members: Vec<(String, UserRef)>, +} + +impl ChatRoom { + pub fn new() -> Self { + Self { members: Vec::new() } + } +} + +impl Actor for ChatRoom {} + +impl Handler for ChatRoom { + async fn handle(&mut self, msg: Say, _ctx: &Context) { + tracing::info!("[room] {} says: {}", msg.from, msg.text); + for (name, user) in &self.members { + if *name != msg.from { + let _ = user.deliver(msg.from.clone(), msg.text.clone()); + } + } + } +} + +impl Handler for ChatRoom { + async fn handle(&mut self, msg: AddMember, _ctx: &Context) { + tracing::info!("[room] {} joined", msg.name); + self.members.push((msg.name, msg.user)); + } +} + +impl Handler for ChatRoom { + async fn handle(&mut self, _msg: Members, _ctx: &Context) -> Vec { + self.members.iter().map(|(name, _)| name.clone()).collect() + } +} + +// Compile-time assertion: ActorRef must satisfy RoomProtocol +const _: () = { + fn _assert() {} + fn _check() { _assert::>(); } +}; +``` +
+ +
+user.rs — same pattern, manual Actor + Handler impls + +```rust +use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; + +use crate::protocols::user_protocol::{Deliver, JoinRoom, Say}; +use crate::protocols::{RoomRef, ToUserRef, UserProtocol}; + +pub struct User { + name: String, + room: Option, +} + +impl User { + pub fn new(name: String) -> Self { + Self { name, room: None } + } +} + +impl Actor for User {} + +impl Handler for User { + async fn handle(&mut self, msg: Deliver, _ctx: &Context) { + tracing::info!("[{}] got: {} says '{}'", self.name, msg.from, msg.text); + } +} + +impl Handler for User { + async fn handle(&mut self, msg: Say, _ctx: &Context) { + if let Some(ref room) = self.room { + let _ = room.say(self.name.clone(), msg.text); + } + } +} + +impl Handler for User { + async fn handle(&mut self, msg: JoinRoom, ctx: &Context) { + let _ = msg.room.add_member(self.name.clone(), ctx.actor_ref().to_user_ref()); + self.room = Some(msg.room); + } +} + +// Compile-time assertion: ActorRef must satisfy UserProtocol +const _: () = { + fn _assert() {} + fn _check() { _assert::>(); } +}; +``` +
+ +
+main.rs — identical to macro version (no macros used here) + +```rust +let room = ChatRoom::new().start(); +let alice = User::new("Alice".into()).start(); +let bob = User::new("Bob".into()).start(); + +// Register the room's protocol — not the concrete type +registry::register("general", room.to_room_ref()).unwrap(); + +alice.join_room(room.to_room_ref()).unwrap(); +bob.join_room(room.to_room_ref()).unwrap(); + +let members = room.members().await.unwrap(); + +alice.say("Hello everyone!".into()).unwrap(); +bob.say("Hey Alice!".into()).unwrap(); + +// Late joiner discovers the room — only needs the protocol, not the concrete type +let charlie = User::new("Charlie".into()).start(); +let discovered: RoomRef = registry::whereis("general").unwrap(); +charlie.join_room(discovered).unwrap(); +``` + +Protocol methods (`say`, `join_room`, `members`) are called directly on `ActorRef` via blanket impls. The `.to_room_ref()` conversion makes the protocol boundary explicit. +
+ +### With `#[protocol]` + `#[actor]` macros + +
+protocols.rs — one protocol per actor, #[protocol] generates everything + +```rust +use spawned_concurrency::error::ActorError; +use spawned_concurrency::tasks::Response; +use spawned_macros::protocol; +use std::sync::Arc; + +// Manual type aliases for circular references between protocols +pub type RoomRef = Arc; +pub type UserRef = Arc; + +// #[protocol] generates for each trait: +// Conversion trait: pub trait ToRoomRef { fn to_room_ref(&self) -> RoomRef; } +// Identity impl: impl ToRoomRef for RoomRef { ... } +// Message structs: room_protocol::{Say, AddMember, Members} with Message impls +// Blanket impl: impl + ...> RoomProtocol for ActorRef +// Blanket impl: impl + ...> ToRoomRef for ActorRef + +#[protocol] +pub trait RoomProtocol: Send + Sync { + fn say(&self, from: String, text: String) -> Result<(), ActorError>; + fn add_member(&self, name: String, user: UserRef) -> Result<(), ActorError>; + fn members(&self) -> Response>; +} + +#[protocol] +pub trait UserProtocol: Send + Sync { + fn deliver(&self, from: String, text: String) -> Result<(), ActorError>; + fn say(&self, text: String) -> Result<(), ActorError>; + fn join_room(&self, room: RoomRef) -> Result<(), ActorError>; +} +``` +
+ +
+room.rs#[actor(protocol = RoomProtocol)] generates Actor impl, Handler impls, and assertion + +```rust +use spawned_concurrency::tasks::{Actor, Context, Handler}; +use spawned_macros::actor; + +use crate::protocols::room_protocol::{AddMember, Members, Say}; +use crate::protocols::{RoomProtocol, UserRef}; + +pub struct ChatRoom { + members: Vec<(String, UserRef)>, +} + +#[actor(protocol = RoomProtocol)] +impl ChatRoom { + pub fn new() -> Self { + Self { members: Vec::new() } + } + + #[send_handler] + async fn handle_say(&mut self, msg: Say, _ctx: &Context) { + tracing::info!("[room] {} says: {}", msg.from, msg.text); + for (name, user) in &self.members { + if *name != msg.from { + 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() + } +} +``` +
+ +
+user.rs#[actor(protocol = UserProtocol)], symmetric with room.rs + +```rust +use spawned_concurrency::tasks::{Actor, Context, Handler}; +use spawned_macros::actor; + +use crate::protocols::user_protocol::{Deliver, JoinRoom, Say}; +use crate::protocols::{RoomRef, ToUserRef, UserProtocol}; + +pub struct User { + name: String, + room: Option, +} + +#[actor(protocol = UserProtocol)] +impl User { + pub fn new(name: String) -> Self { + Self { name, room: None } + } + + #[send_handler] + 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(&mut self, msg: Say, _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.actor_ref().to_user_ref()); + self.room = Some(msg.room); + } +} +``` +
+ +
+main.rs — protocol refs for wiring and discovery + +```rust +let room = ChatRoom::new().start(); +let alice = User::new("Alice".into()).start(); +let bob = User::new("Bob".into()).start(); + +// Register the room's protocol — not the concrete type +registry::register("general", room.to_room_ref()).unwrap(); + +alice.join_room(room.to_room_ref()).unwrap(); +bob.join_room(room.to_room_ref()).unwrap(); + +let members = room.members().await.unwrap(); + +alice.say("Hello everyone!".into()).unwrap(); +bob.say("Hey Alice!".into()).unwrap(); + +// Late joiner discovers the room — only needs the protocol, not the concrete type +let charlie = User::new("Charlie".into()).start(); +let discovered: RoomRef = registry::whereis("general").unwrap(); +charlie.join_room(discovered).unwrap(); +``` +
+ +### Analysis + +| Dimension | Without macro | With `#[protocol]` + `#[actor]` macros | +|-----------|---------------|----------------------------------------| +| **Readability** | `protocols.rs` is a complete API index, but blanket impls, message submodules, and converter traits add visual noise. Actor files have one `impl Handler` block per message — clear but verbose. | `protocols.rs` is just trait definitions — the clearest API surface of all approaches. Actor files contain only the struct + annotated handler methods. `#[actor(protocol = RoomProtocol)]` makes the contract relationship explicit. | +| **API at a glance** | Protocol traits define the API, but you must scroll past message submodules, blanket impls, and converter traits to see the full picture. | Protocol traits ARE the API. `RoomProtocol` tells you exactly what a room can do. No separate `actor_api!` — the protocol is the single source of truth. | +| **Boilerplate** | High. Per protocol: message structs + `impl Message` in a submodule, converter trait + identity impl, blanket `impl Protocol for ActorRef
` + `impl ToXRef for ActorRef`. Per actor: `impl Actor`, one `impl Handler` per message, compile-time assertion const. | Minimal. Per protocol: one `#[protocol]` trait. Per actor: one `#[actor(protocol = X)]` line + handler methods. Everything else (message structs, converters, blanket impls, Actor impl, Handler impls, assertion) is generated. | +| **main.rs expressivity** | Identical — `alice.say("Hello")`, `room.members().await`, `room.to_room_ref()`. Blanket impls provide the same API surface. | Identical — same call syntax. The macros don't change how callers use the protocols. | +| **Request-response** | `Response` keeps protocol traits object-safe — no RPITIT, no `BoxFuture` boxing. | Same mechanism. The macros generate the same `Response::from(self.request_raw(...))` calls. | +| **Circular dep solution** | Actors hold `RoomRef` / `UserRef` — protocol-level refs. No `ActorRef`, no bidirectional module imports. Both depend only on `protocols.rs`. | Same mechanism. | +| **Registry** | Register `RoomRef` — one registration, full API, no concrete type needed. Identity `ToRoomRef` impl on `RoomRef` means discovered refs work directly. | Same — registry behavior is identical. | +| **Symmetry** | Room and User have identical structure, but each actor file has verbose handler impls + assertion boilerplate. | Room and User have identical structure: `#[actor(protocol = X)]` + handlers. No asymmetry. | +| **Testability** | Best of all approaches — mock `RoomProtocol` or `UserProtocol` directly in unit tests without running an actor system. | Same — protocol traits are the same, mocking works identically. | + +**Key insight:** The non-macro version reveals the full machinery: message submodules, blanket impls, converter traits, protocol assertions. It's entirely standard Rust — any developer can read and understand it without knowing the macros. The `#[protocol]` + `#[actor(protocol = X)]` macros eliminate all that scaffolding while preserving B's unique advantages: protocol-level contracts, best-in-class testability, and Erlang-like actor-level granularity. The protocol trait IS the API — no separate API layer needed. + +**Scaling:** Each new actor needs one protocol trait + one `#[actor(protocol = X)]` + handlers. Each new message is one method in the protocol + one handler. The cost is linear and symmetric — no distinction between "internal" and "cross-boundary" messages. Every message goes through the protocol. + +**Cross-crate scaling:** `protocols.rs` maps directly to a shared crate. Each actor crate depends only on the protocols crate, never on each other. No restructuring needed — the architecture is crate-ready from day one. In Approach A, the bidirectional module dependency (room imports `Deliver` from user, user imports `ChatRoomApi` from room) would become a circular crate dependency that Rust forbids. + +--- + +## Approach C: Typed Wrappers (non-breaking) + +**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. + +### 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`](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. + +### 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`](https://github.com/lambdaclass/spawned/blob/b0e5afb2c69e1f5b6ab8ee82b59582348877c819/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`](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. + +### 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." + +--- + +## Registry & Service Discovery + +The registry is a global `Any`-based name store: `HashMap>` with `RwLock`. The API (`register`, `whereis`, `unregister`, `registered`) stays the same across approaches. What changes is **what you store and what you get back** — and this is where Approach A and B diverge most visibly. + +The chat room examples above demonstrate this. Both register the room as `"general"` and a late joiner (Charlie) discovers it: + +```rust +// Approach A — stores and retrieves the concrete type +registry::register("general", room.clone()).unwrap(); // ActorRef +let discovered: ActorRef = registry::whereis("general").unwrap(); // caller must know ChatRoom + +// Approach B — stores and retrieves the protocol +registry::register("general", room.to_room_ref()).unwrap(); // RoomRef +let discovered: RoomRef = registry::whereis("general").unwrap(); // caller only needs the protocol +charlie.join_room(discovered).unwrap(); // works — RoomRef implements ToRoomRef (identity) +``` + +In A, the discoverer must import `ChatRoom` and `ChatRoomApi` to use the retrieved reference — it knows exactly which actor it's talking to. In B, the discoverer imports only `RoomRef` from `protocols.rs` — it knows *what the actor can do* without knowing *what actor it is*. Any actor implementing `RoomProtocol` could be behind that reference. + +### 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** | `ActorRef` or `Recipient` | Same | Compile-time, but `ActorRef` requires concrete type; `Recipient` is per-message | Per actor (full API but coupled) or per message (decoupled but fragmented) | +| **B: Protocol Traits** | `Arc` | `Arc` | Compile-time per protocol | Per protocol — one registration, full API, no concrete type | +| **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** faces a trade-off: register `ActorRef` for the full API (via `ChatRoomApi` extension trait), but the discoverer must know the concrete type — or register individual `Recipient` handles for type-erased per-message access, but then you need multiple registrations and the discoverer can only send one message type per handle. The chat room example uses `ActorRef` because `ChatRoomApi` provides the natural caller API. + +- **B** registers per protocol: `registry::register("general", room.to_room_ref())`. A consumer discovers a `RoomRef` (`Arc`) — it can call any method on the protocol (`say`, `add_member`, `members`). One registration, full API, no concrete type needed. This maps directly to Erlang's `register/whereis` pattern but with compile-time safety. + +- **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 — DONE (`#[protocol]` + `#[actor(protocol = X)]`) + +Two proc macro attributes — `#[protocol]` and `#[actor]` — eliminate all scaffolding. The protocol trait is the single source of truth: + +**`#[protocol]`** on the trait definition generates everything: conversion trait, identity impl, message structs, and blanket impls. Names are derived by convention (`RoomProtocol` → `RoomRef`, `ToRoomRef`): + +```rust +#[protocol] +pub trait RoomProtocol: Send + Sync { + fn say(&self, from: String, text: String) -> Result<(), ActorError>; + fn add_member(&self, name: String, user: UserRef) -> Result<(), ActorError>; + fn members(&self) -> Response>; +} +// Generates: +// trait ToRoomRef, impl ToRoomRef for RoomRef (identity) +// room_protocol::{Say, AddMember, Members} with Message impls +// blanket impl> RoomProtocol for ActorRef +// blanket impl> ToRoomRef for ActorRef +``` + +**`#[actor(protocol = X)]`** generates `impl Actor`, `Handler` impls from annotated methods, and a compile-time assertion: + +```rust +#[actor(protocol = RoomProtocol)] +impl ChatRoom { ... } +// Generates: impl Actor for ChatRoom {}, impl Handler per handler, static assertion +``` + +Each send method in the blanket impl generates `fn method(&self, ...) -> Result<(), ActorError> { self.send(Msg { ... }) }`. Each request method generates `fn method(&self, ...) -> Response { Response::from(self.request_raw(Msg)) }`. + +**Impact:** With the "one protocol per actor" model, every actor has the same structure: protocol trait → `#[actor(protocol = X)]` → handlers. No `send_messages!`/`request_messages!`, no separate caller API traits, no method duplication. The only macros needed: `#[protocol]` and `#[actor]`. B's boilerplate per message is now lower than A's (protocol method + handler vs struct + `actor_api!` line + handler). + +### 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. + +### 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 | Message structs + blanket impls + conversion traits + caller APIs | Done — `#[protocol]` + `#[actor(protocol = X)]` | +| **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:** With the "one protocol per actor" model, `#[protocol]` becomes the single source of truth — generating message structs, conversion traits, and blanket impls from method signatures. `#[actor(protocol = X)]` generates `impl Actor`, Handler impls, and a compile-time assertion. The protocol IS the actor's API, making separate `actor_api!` or `UserActions` traits unnecessary. B's total boilerplate is now lower than A's per message (protocol method + handler vs struct + macro line + handler), while providing better testability, crate-ready architecture, and Erlang-like actor-level granularity. + +--- + +## Comparison Matrix + +### Functional Dimensions + +| Dimension | A: Recipient | B: Protocol Traits | C: Typed Wrappers | D: Derive Macro | E: AnyActorRef | F: PID | +|-----------|-------------|-------------------|-------------------|-----------------|---------------|--------| +| **Status** | Implemented | Implemented | 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]` + `actor_api!` + message macros | `#[protocol]` + `#[actor(protocol = X)]` (no message macros needed) | N/A (enum-based) | Derive macro | `#[actor]` | `#[actor]` | +| **Dual-mode (async+threads)** | Works | Works | Complex (dual channel) | Complex | Works | Works | +| **Registry stores** | `ActorRef` (full API, coupled) or `Recipient` (per-message, decoupled) | `Arc` (full API, decoupled) | Mixed | `Recipient` | `AnyActorRef` | `Pid` | +| **Registry type safety** | Compile-time | Compile-time | Depends | Compile-time | Runtime | Runtime | +| **Registry discovery** | Discoverer must know concrete type or individual message types | Discoverer only needs the protocol trait | Depends | Same as A | No type info | No type info | + +### 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 for handlers. Actor files are pure implementation — struct + `#[actor(protocol = X)]` + handlers. | 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 trait IS the API — `RoomProtocol` and `UserProtocol` are the complete, single source of truth | 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 | `alice.say("Hi")`, `room.members().await`, `alice.join_room(room.to_room_ref())` | `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 + handler | Protocol method + handler (structs generated by `#[protocol]`) | Enum variant + wrapper + match arm | Enum variant + annotation | Struct | Struct + registration | +| **Debugging** | Standard Rust — all code visible | Standard Rust — blanket impls via `cargo expand` if needed | 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 | Low (`#[protocol]` proc macro + `Response`) | High (dual channel) | Very high (proc macro) | High (dispatch) | Medium (registry) | +| **Maintenance burden** | Low — proven Actix pattern | Low — protocol traits are user-space, macros are thin | 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) | Low (`#[protocol]` + `#[actor]` — write traits, add annotations) | Low (old API preserved) | Low (write enum, macro does rest) | Low concept, high debugging | Low concept, high registration overhead | +| **Erlang alignment** | Actix-like | Most Erlang — one protocol per actor = gen_server module exports | Actix-like | Actix-like | Erlang-ish | Erlang PIDs (no type safety) | +| **Macro improvement potential** | Already done (`actor_api!`) | Done — `#[protocol]` + `#[actor(protocol = X)]` generate everything | None (structural) | N/A (is a macro) | None (false safety) | Low (ergonomics only) | + +--- + +## 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 + `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 macros are additive, not essential +- Registry trade-off: register `ActorRef` for full API (discoverer must know concrete type) or register `Recipient` per message (decoupled but fragmented) + +**Approach B (Protocol Traits)** is a strong alternative with the most Erlang-like architecture: +- One protocol per actor — the protocol IS the actor's complete public API, like an Erlang gen_server's module exports +- `#[protocol]` generates everything from the trait definition: message structs, conversion trait, blanket impls. `#[actor(protocol = X)]` generates `impl Actor`, Handler impls, and compile-time assertion +- Lowest boilerplate per message: protocol method + handler (structs generated automatically) +- No `send_messages!`/`request_messages!`, no `actor_api!`, no manual caller API traits — just `#[protocol]` and `#[actor]` +- Perfect symmetry — every actor has the same structure: protocol → `#[actor(protocol = X)]` → handlers +- `Response` keeps protocol traits object-safe (structural mirror of the Envelope pattern) +- `Context::actor_ref()` lets actors convert themselves to protocol refs (e.g., `ctx.actor_ref().to_user_ref()`) +- Best testability — mock `RoomProtocol` or `UserProtocol` directly without an actor system +- Zero cross-actor dependencies — both Room and User depend only on `protocols.rs` +- Best registry story: one registration per protocol, full API, no concrete type needed +- Crate-ready from day one — `protocols.rs` maps directly to a shared crate + +**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/approach-a`](https://github.com/lambdaclass/spawned/tree/feat/approach-a) | main | **Approach A** — Pure Recipient\ + actor_api! pattern (all examples rewritten) | +| [`feat/approach-b`](https://github.com/lambdaclass/spawned/tree/feat/approach-b) | main | **Approach B** — Protocol traits + `#[protocol]`/`#[actor(protocol = X)]` macros + Context::actor_ref() (all examples rewritten) | +| [`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 | Original protocol traits exploration + [`docs/ALTERNATIVE_APPROACHES.md`](https://github.com/lambdaclass/spawned/blob/b0e5afb2c69e1f5b6ab8ee82b59582348877c819/docs/ALTERNATIVE_APPROACHES.md) | +| [`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\ early implementation | +| [`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`](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. diff --git a/docs/design/API_REDESIGN.md b/docs/design/API_REDESIGN.md new file mode 100644 index 0000000..86f1842 --- /dev/null +++ b/docs/design/API_REDESIGN.md @@ -0,0 +1,498 @@ +# Plan: API Redesign for v0.5 - Issues #144, #145, and Phase 3 + +## Decisions Made + +| Issue | Decision | Rationale | +|-------|----------|-----------| +| **#145** (Circular deps) | Recipient\ pattern | Type-safe, no exposed Pid | +| **#144** (Type safety) | Per-message types (Handler\) | Leverage Rust's type system, clean API | +| **Breaking changes** | Accepted | v0.5 is the right time for API improvements | +| **Pattern support** | Per-message only | Clean break, one way to do things | +| **Actor identity** | Internal ID (hidden) | Links/monitors work via traits, no public Pid | +| **Supervision** | Trait-based (`Supervisable`) | Type-safe child management | + +## Overview + +This is a significant API redesign that: +1. Adds Handler pattern for per-message type safety (#144) +2. Adds Recipient for type-erased message sending (#145) +3. Uses internal identity (not exposed as Pid) for links/monitors +4. Uses traits for supervision (Supervisable, Linkable) + +--- + +## Issue #145: Circular Dependency with Bidirectional Actors + +### The Problem + +```rust +// actor_a.rs +use crate::actor_b::ActorB; +struct ActorA { peer: ActorRef } // Needs ActorB type + +// actor_b.rs +use crate::actor_a::ActorA; +struct ActorB { peer: ActorRef } // CIRCULAR! +``` + +### Solution: Recipient\ + +```rust +/// Trait for anything that can receive messages of type M. +/// Object-safe: all methods return concrete types (no async/impl Future). +/// Async waiting happens outside the trait via oneshot channels (Actix pattern). +trait Receiver: Send + Sync { + fn send(&self, msg: M) -> Result<(), ActorError>; + fn request(&self, msg: M) -> Result, ActorError>; +} + +// ActorRef implements Receiver for all M where A: Handler +// Type-erased handle (ergonomic alias) +type Recipient = Arc>; + +// Ergonomic async wrapper on the concrete Recipient type (not on the trait) +impl Recipient { + pub async fn send_request(&self, msg: M) -> Result { + let rx = self.request(msg)?; + rx.await.map_err(|_| ActorError::ActorStopped) + } +} + +// Usage - no circular dependency! +struct ActorA { peer: Recipient } +struct ActorB { peer: Recipient } +``` + +--- + +## Issue #144: Type Safety for Request/Reply + +### The Problem + +```rust +enum Reply { Name(String), Age(u32), NotFound } + +// GetName can only return Name or NotFound, but must match Age too +match actor.request(Request::GetName).await? { + Reply::Name(n) => println!("{}", n), + Reply::NotFound => println!("not found"), + Reply::Age(_) => unreachable!(), // Required but impossible +} +``` + +### Solution: Per-Message Types with Handler\ + +```rust +struct GetName(String); +impl Message for GetName { + type Result = Option; +} + +impl Handler for NameServer { + fn handle(&mut self, msg: GetName) -> Option { ... } +} + +// Clean caller code - exact type! +let name: Option = actor.request(GetName("joe")).await?; +``` + +--- + +# Implementation Plan + +## Phase 3.1: Receiver\ Trait and Recipient\ Alias + +**New file:** `concurrency/src/recipient.rs` + +```rust +/// Trait for anything that can receive messages of type M. +/// +/// Object-safe by design: all methods return concrete types, no async/impl Future. +/// This follows the Actix pattern where async waiting happens outside the trait +/// boundary via oneshot channels, keeping the trait compatible with `dyn`. +/// +/// This is implemented by ActorRef for all message types the actor handles. +/// Use `Recipient` for type-erased storage. +pub trait Receiver: Send + Sync { + /// Fire-and-forget send (enqueue message, don't wait for reply) + fn send(&self, msg: M) -> Result<(), ActorError>; + + /// Enqueue message and return a oneshot channel to await the reply. + /// This is synchronous — it only does channel plumbing. + /// The async waiting happens on the returned receiver. + fn request(&self, msg: M) -> Result, ActorError>; +} + +/// Type-erased handle (ergonomic alias). Object-safe because Receiver is. +pub type Recipient = Arc>; + +/// Ergonomic async wrapper — lives on the concrete type, not the trait. +impl Recipient { + pub async fn send_request(&self, msg: M) -> Result { + let rx = Receiver::request(&**self, msg)?; + rx.await.map_err(|_| ActorError::ActorStopped) + } +} + +// ActorRef implements Receiver for all M where A: Handler +impl Receiver for ActorRef +where + A: Actor + Handler, + M: Message, +{ + fn send(&self, msg: M) -> Result<(), ActorError> { + // Pack message into envelope, push to actor's mailbox channel + ... + } + + fn request(&self, msg: M) -> Result, ActorError> { + // Create oneshot channel, pack (msg, tx) into envelope, + // push to actor's mailbox, return rx + ... + } +} + +// Convert ActorRef to Recipient +impl ActorRef { + pub fn recipient(&self) -> Recipient + where + A: Handler, + M: Message, + { + Arc::new(self.clone()) + } +} +``` + +## Phase 3.2: Internal Identity (Hidden) + +**New file:** `concurrency/src/identity.rs` + +```rust +/// Internal process identifier (not public API) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) struct ActorId(u64); + +/// Exit reason for actors +pub enum ExitReason { + Normal, + Shutdown, + Error(String), + Linked, // Linked actor died +} +``` + +## Phase 3.3: Traits for Supervision and Links + +**New file:** `concurrency/src/traits.rs` + +```rust +/// Trait for actors that can be supervised. +/// Provides actor_id() for identity comparison with trait objects. +pub trait Supervisable: Send + Sync { + fn actor_id(&self) -> ActorId; + fn stop(&self); + fn is_alive(&self) -> bool; + fn on_exit(&self, callback: Box); +} + +/// Trait for actors that can be linked. +/// Uses actor_id() (from Supervisable) to track links internally. +pub trait Linkable: Supervisable { + fn link(&self, other: &dyn Linkable); + fn unlink(&self, other: &dyn Linkable); +} + +/// Trait for actors that can be monitored +pub trait Monitorable: Supervisable { + fn monitor(&self) -> MonitorRef; + fn demonitor(&self, monitor_ref: MonitorRef); +} + +// ActorRef implements all these traits +impl Supervisable for ActorRef { ... } +impl Linkable for ActorRef { ... } +impl Monitorable for ActorRef { ... } +``` + +## Phase 3.4: Registry (Named Actors) + +**New file:** `concurrency/src/registry.rs` + +```rust +/// Register a recipient under a name +pub fn register(name: &str, recipient: Recipient) -> Result<(), RegistryError>; + +/// Look up a recipient by name (must know message type) +pub fn whereis(name: &str) -> Option>; + +/// Unregister a name +pub fn unregister(name: &str); + +/// List all registered names +pub fn registered() -> Vec; +``` + +## Phase 4: Handler Pattern (#144) + +**Redesigned Actor API:** + +```rust +/// Marker trait for messages +pub trait Message: Send + 'static { + type Result: Send; +} + +/// Handler for a specific message type. +/// Uses RPITIT (Rust 1.75+) — this is fine since Handler is never used as dyn. +/// &mut self is safe: actors process messages sequentially (one at a time), +/// so there is no concurrent access to self. +pub trait Handler: Actor { + fn handle(&mut self, msg: M, ctx: &Context) -> impl Future + Send; +} + +/// Actor context (replaces ActorRef in handlers) +pub struct Context { + // ... internal fields +} + +impl Context { + pub fn stop(&self); + pub fn recipient(&self) -> Recipient where A: Handler; +} + +/// Base actor trait (simplified) +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 {} } +} +``` + +**ActorRef changes:** + +```rust +/// Typed handle to an actor. +/// +/// Internally uses an envelope pattern (like Actix) for the mailbox: +/// messages of different types are packed into `Box>` so +/// the actor's single mpsc channel can carry any message type the actor handles. +pub struct ActorRef { + id: ActorId, // Internal identity (not public) + sender: mpsc::Sender + Send>>, + _marker: PhantomData, +} + +/// Type-erased envelope that the actor loop can dispatch. +/// Each concrete envelope captures the message and an optional oneshot sender. +trait Envelope: Send { + fn handle(self: Box, actor: &mut A, ctx: &Context); +} + +impl ActorRef +where + A: Actor + Handler, + M: Message, +{ + /// Fire-and-forget send (returns error if actor dead) + pub fn send(&self, msg: M) -> Result<(), ActorError>; + + /// Enqueue message and return a oneshot receiver for the reply. + /// Synchronous — only does channel plumbing (Actix pattern). + pub fn request(&self, msg: M) -> Result, ActorError>; + + /// Ergonomic async request: enqueue + await reply. + pub async fn send_request(&self, msg: M) -> Result { + let rx = self.request(msg)?; + rx.await.map_err(|_| ActorError::ActorStopped) + } + + /// Get type-erased Recipient for this message type + pub fn recipient(&self) -> Recipient; +} + +// Implements supervision/linking traits +impl Supervisable for ActorRef { ... } +impl Linkable for ActorRef { ... } +impl Monitorable for ActorRef { ... } +``` + +--- + +## Example: Bank Actor (New API) + +```rust +// messages.rs +pub struct CreateAccount { pub name: String } +pub struct Deposit { pub account: String, pub amount: u64 } +pub struct GetBalance { pub account: String } + +impl Message for CreateAccount { type Result = Result<(), BankError>; } +impl Message for Deposit { type Result = Result; } +impl Message for GetBalance { type Result = Result; } + +// bank.rs +pub struct Bank { + accounts: HashMap, +} + +impl Actor for Bank { + async fn started(&mut self, ctx: &Context) { + tracing::info!("Bank started"); + } +} + +impl Handler for Bank { + async fn handle(&mut self, msg: CreateAccount, _ctx: &Context) -> Result<(), BankError> { + self.accounts.insert(msg.name, 0); + Ok(()) + } +} + +impl Handler for Bank { + async fn handle(&mut self, msg: Deposit, _ctx: &Context) -> Result { + let balance = self.accounts.get_mut(&msg.account).ok_or(BankError::NotFound)?; + *balance += msg.amount; + Ok(*balance) + } +} + +impl Handler for Bank { + async fn handle(&mut self, msg: GetBalance, _ctx: &Context) -> Result { + self.accounts.get(&msg.account).copied().ok_or(BankError::NotFound) + } +} + +// main.rs +let bank: ActorRef = Bank::new().start(); + +// Type-safe request (async convenience wrapper: enqueue + await oneshot) +let balance: Result = bank.send_request(GetBalance { account: "alice".into() }).await?; + +// Fire-and-forget send +bank.send(Deposit { account: "alice".into(), amount: 50 })?; + +// Low-level: get oneshot receiver directly (useful for select!, timeouts, etc.) +let rx = bank.request(GetBalance { account: "alice".into() })?; +let balance = tokio::time::timeout(Duration::from_secs(5), rx).await??; + +// Get type-erased Recipient for storage/passing to other actors +let recipient: Recipient = bank.recipient(); + +// Supervision uses trait objects +let children: Vec> = vec![ + Arc::new(bank.clone()), + Arc::new(logger.clone()), +]; +``` + +--- + +## Example: Solving #145 (Circular Deps) with Recipient + +```rust +// shared_messages.rs - NO circular dependency +pub struct OrderUpdate { pub order_id: u64, pub status: String } +pub struct InventoryReserve { pub item: String, pub quantity: u32, pub reply_to: Recipient } + +impl Message for OrderUpdate { type Result = (); } +impl Message for InventoryReserve { type Result = Result<(), InventoryError>; } + +// order_service.rs - imports InventoryReserve, NOT InventoryService +use crate::shared_messages::{OrderUpdate, InventoryReserve}; + +pub struct OrderService { + inventory: Recipient, // Type-erased, no circular dep! +} + +impl Handler for OrderService { + async fn handle(&mut self, msg: PlaceOrder, ctx: &Context) -> Result<(), OrderError> { + let reply_to: Recipient = ctx.recipient(); + self.inventory.send_request(InventoryReserve { + item: msg.item, + quantity: msg.quantity, + reply_to, + }).await?; + Ok(()) + } +} + +// inventory_service.rs - imports OrderUpdate, NOT OrderService +use crate::shared_messages::{OrderUpdate, InventoryReserve}; + +pub struct InventoryService { ... } + +impl Handler for InventoryService { + async fn handle(&mut self, msg: InventoryReserve, _ctx: &Context) -> Result<(), InventoryError> { + // Reserve inventory... + msg.reply_to.send(OrderUpdate { order_id: 123, status: "reserved".into() })?; + Ok(()) + } +} + +// main.rs - wire them together +let inventory: ActorRef = InventoryService::new().start(); +let inventory_recipient: Recipient = inventory.recipient(); + +let order_service = OrderService::new(inventory_recipient).start(); +``` + +--- + +## Files to Create/Modify + +| File | Action | Description | +|------|--------|-------------| +| `concurrency/src/recipient.rs` | Create | Receiver trait and Recipient alias | +| `concurrency/src/identity.rs` | Create | Internal ActorId (not public) | +| `concurrency/src/traits.rs` | Create | Supervisable, Linkable, Monitorable | +| `concurrency/src/registry.rs` | Create | Named actor registry | +| `concurrency/src/message.rs` | Create | Message and Handler traits | +| `concurrency/src/context.rs` | Create | Context type | +| `concurrency/src/tasks/actor.rs` | Rewrite | New Actor/Handler API | +| `concurrency/src/threads/actor.rs` | Rewrite | Same changes for threads | +| `concurrency/src/lib.rs` | Update | Export new types | +| `examples/*` | Update | Migrate to new API | + +--- + +## Migration Path + +1. **Step 1:** Add Message trait and Handler pattern alongside current API +2. **Step 2:** Add Recipient for type-erased sending +3. **Step 3:** Add Supervisable/Linkable/Monitorable traits +4. **Step 4:** Add Registry with Recipient +5. **Deprecation:** Mark old Request/Reply/Message associated types as deprecated +6. **Removal:** Remove old API in subsequent release + +--- + +## v0.6+ Considerations + +| Feature | Approach | +|---------|----------| +| **Clustering** | Add `RemoteRecipient` that serializes ActorId + message | +| **State machines** | gen_statem equivalent using Handler pattern | +| **Persistence** | Event sourcing via Handler | + +--- + +## Verification + +1. `cargo build --workspace` - All crates compile +2. `cargo test --workspace` - All tests pass +3. Update examples to new API +4. Test bidirectional actor communication without circular deps +5. Test Supervisable/Linkable traits work correctly + +--- + +## Final Decisions + +| Item | Decision | +|------|----------| +| Method naming | `send()` = fire-forget, `request()` = wait for reply | +| Dead actor handling | Returns `Err(ActorStopped)` (type-safe feedback) | +| Pattern support | Per-message types only (no enum fallback) | +| Type erasure | `Recipient` for message-type-safe erasure | +| Actor identity | Internal `ActorId` (not exposed as Pid) | +| Supervision | `Supervisable` / `Linkable` / `Monitorable` traits | diff --git a/docs/design/FRAMEWORK_COMPARISON.md b/docs/design/FRAMEWORK_COMPARISON.md new file mode 100644 index 0000000..0fbe8d7 --- /dev/null +++ b/docs/design/FRAMEWORK_COMPARISON.md @@ -0,0 +1,257 @@ +# Actor Framework Comparison + +Research compiled during the v0.5 API redesign (Feb 2026). Covers Rust actor frameworks in depth and surveys ideas from 12+ frameworks across languages. + +Sources: issue #124, PRs #114, #146. + +--- + +## Rust Frameworks: Spawned vs Actix vs Ractor + +### Feature Matrix + +| Feature | Spawned | Actix | Ractor | +|---------|---------|-------|--------| +| **Handler\ pattern** | v0.5 | Yes | Yes (enum-based) | +| **Type erasure (Recipient)** | v0.5 | Yes | No (single msg type per actor) | +| **Supervision** | Planned | Yes | **Best** (Erlang-style) | +| **Distributed actors** | Future | No | `ractor_cluster` | +| **Dual execution modes** | **Unique** | No | No | +| **Native OS threads** | **Unique** | No | No | +| **No runtime required** | Yes (threads mode) | No (Actix runtime) | No (Tokio required) | +| **Signal handling** | `send_message_on()` | Manual | Signal priority channel | +| **Timers** | Built-in | Yes | `time` module | +| **Named registry** | v0.5 | Yes | Erlang-style | +| **Process groups (pg)** | Not yet | No | Erlang-style | +| **Links/Monitors** | Planned | No | Yes | +| **RPC** | Not yet | No | Built-in | +| **Multiple runtimes** | Tokio + none | Actix only | Tokio + async-std | +| **Pure Rust (no unsafe)** | Yes | Some unsafe | Yes | + +### Supervision Comparison + +| Aspect | Spawned (Planned) | Actix | Ractor | +|--------|-------------------|-------|--------| +| OneForOne | Planned | Yes | Yes | +| OneForAll | Planned | Yes | Yes | +| RestForOne | Planned | No | Yes | +| Meltdown protection | Planned | No | Yes | +| Supervision trees | Planned | Limited | **Full Erlang-style** | +| Dynamic supervisors | Planned | No | Yes | + +### Erlang Alignment + +| Concept | Spawned | Actix | Ractor | +|---------|---------|-------|--------| +| **gen_server model** | Strong | Diverged | **Strongest** | +| **call/cast naming** | `request`/`send` | `send`/`do_send` | `call`/`cast` | +| **Supervision trees** | Planned | Limited | Full OTP-style | +| **Process registry** | v0.5 | Yes | Erlang-style | +| **Process groups (pg)** | No | No | Yes | +| **EPMD-style clustering** | Future | No | `ractor_cluster` | + +### When to Use Each + +| Use Case | Best Choice | Why | +|----------|-------------|-----| +| Erlang/OTP migration | Ractor | Closest to OTP semantics | +| Embedded/no-runtime | Spawned | Only one with native OS thread support | +| Mixed async/sync workloads | Spawned | Dual execution modes | +| Web applications | Actix | actix-web ecosystem | +| Distributed systems | Ractor | `ractor_cluster` ready | +| Raw performance | Actix | Fastest in benchmarks | +| Simple learning curve | Spawned | Cleanest API | +| Production fault-tolerance | Ractor | Most complete supervision | + +### Spawned's Unique Value + +1. **Dual execution modes** — async AND blocking with the same API; no other Rust framework offers this +2. **No runtime lock-in** — threads mode needs zero async runtime +3. **Backend flexibility** — Async, Blocking pool, or dedicated Thread per actor +4. **Simpler mental model** — fewer concepts to learn than Actix or Ractor + +--- + +## Cross-Language Framework Survey + +### Erlang/OTP & Elixir + +The reference model for actor frameworks. + +- **gen_server** — The pattern Spawned follows: `init`, `handle_call`, `handle_cast`, `terminate` +- **Supervision trees** — Hierarchical fault tolerance with restart strategies (one_for_one, one_for_all, rest_for_one) +- **Process registry** — Global name-based lookup +- **Links & monitors** — Bidirectional crash propagation (links) and unidirectional notifications (monitors) +- **Task.Supervisor.async_nolink** (Elixir) — Spawning async work from within an actor without blocking; validates our Task/TaskGroup design +- **Anti-pattern** — Don't use actors purely for code organization; only when you need isolated state, message serialization, or supervision + +### Gleam OTP + +- **Subject-based typed messaging** — Typed channel where only the owning process can receive; validates Spawned's `ActorRef` approach +- **Actor vs Process split** — `gleam_otp/actor` is typed, `gleam_erlang/process` is low-level. Spawned mirrors this: `Actor` trait is typed, `Pid` / registry are lower-level primitives + +### Akka (Scala/Java) + +- **Typed actors** — Akka evolved from untyped to typed actors, validating the move toward per-message type safety +- **Persistence / event sourcing** — Durable actors that replay events on restart +- **Backoff strategies** — Built into supervision; prevents restart storms +- **Clustering** — Location-transparent actor references across nodes + +### Orleans (C# / .NET) + +- **Virtual actors (grains)** — Actors activated on-demand, deactivated when idle; no explicit lifecycle management +- **Single activation guarantee** — Only one instance of a grain exists at a time across the cluster +- **Durable reminders** — Persisted timers that survive restarts (vs in-memory timers) + +**Design decision:** Spawned keeps explicit lifecycle by default (better for Rust's ownership model). Virtual actors could be an opt-in layer in the future. + +### Pony + +- **Reference capabilities** — 6 capability types (`iso`, `val`, `ref`, `box`, `trn`, `tag`) guarantee data-race freedom at compile time +- **`iso` (isolated)** — Single ownership; the only way to send mutable data between actors +- **Causal messaging** — If actor A sends M1 then M2 to actor B, B receives them in that order (stronger than FIFO per-pair) +- **Per-actor GC (ORCA)** — Each actor has its own heap; no stop-the-world pauses + +**Adopted:** Typed message guarantees (via Rust's Send/Sync). **Skipped:** Full reference capability system (would need language-level support). + +### Lunatic (Rust/Wasm) + +- **WASM process isolation** — Each process in its own WASM sandbox; crashes can't corrupt other processes +- **Serialization-based messaging** — All messages serialized for true isolation (enables distribution) +- **Resource limits per process** — Memory, CPU, and file handle limits per process +- **Selective receive** — Pattern matching on message types in mailbox + +**Adopted:** Resource limits concept (mailbox size caps). **Skipped:** WASM isolation (too heavyweight). + +### Bastion (Rust) + +- **Supervision trees** — Hierarchical, configurable restart strategies per level +- **Restart strategies** — OneForOne, OneForAll, RestForOne + ExponentialBackOff +- **Children group redundancy** — Minimum number of children that must be alive for health +- **Runtime-agnostic** — Works with tokio, async-std, or any executor + +**Adopted:** Exponential backoff, children group redundancy concepts. **Skipped:** Runtime-agnostic design (committed to tokio). + +### Proto.Actor (Go / C#) + +- **Virtual actors** — Orleans-style on-demand activation with identity-based addressing +- **Cross-language messaging** — Protobuf-based; Go and C# actors communicate seamlessly +- **Behavior stack** — `become(behavior)` / `unbecome()` to swap message handlers (useful for state machines) +- **Persistence providers** — Pluggable event stores (PostgreSQL, MongoDB) with snapshots + +**Adopted:** Behavior stack concept for future state machine support. **Skipped:** Protobuf requirement. + +### Trio (Python) + +- **Structured concurrency pioneer** — Introduced "nurseries": a scope that owns spawned tasks and ensures they all complete before the scope exits +- **Level-triggered cancellation** — Cancellation is a persistent state, not an edge event. Code checks "am I cancelled?" rather than catching exceptions. More reliable. +- **Shield scopes** — Temporarily protect critical sections from cancellation (essential for cleanup) +- **Checkpoints** — Explicit points where cancellation can occur + +**Adopted:** Level-triggered cancellation model. **Skipped:** Checkpoints (Rust's `.await` points serve this purpose). + +### Project Loom (Java) + +- **StructuredTaskScope** — Parent scope owns child virtual threads +- **Joiner policies** — `ShutdownOnSuccess` (first wins), `ShutdownOnFailure` (fail-fast), custom joiners for quorum/timeout +- **ScopedValue** — Inherited thread-local-like values, immutable in child tasks + +**Adopted:** Joiner policies for TaskGroup. **Skipped:** ScopedValue (Rust ownership handles this better). + +### Ray (Python) + +- **Task + Actor unification** — Stateless tasks and stateful actors share scheduling infrastructure +- **Distributed object store** — Large objects stored once, referenced by ID; avoids copying +- **Placement groups** — Co-locate related actors for communication efficiency +- **Actor pools** — Built-in load balancing across multiple instances + +**Adopted:** Placement hints concept for future distributed mode. **Skipped:** Distributed object store (different problem domain). + +### Swift Distributed Actors + +- **`distributed` keyword** — Language-level support; methods marked `distributed` can be called remotely +- **Explicit distribution surface** — Only `distributed` methods callable across nodes; local-only methods clearly separated +- **SerializationRequirement** — Compiler enforces that distributed actor parameters are `Codable` + +**Adopted:** Explicit marker for remotely-callable methods (`#[distributed]` attribute, future). **Skipped:** Language-level integration (Rust macros suffice). + +### CAF (C++ Actor Framework) + +- **Typed actor interfaces** — Actors declare message types as a type signature; compile-time verification +- **Behavior composition** — Combine multiple behaviors with `or_else` +- **Response promises** — Explicit promise objects for async responses; can delegate response to another actor + +**Adopted:** Typed message interfaces via traits (already implemented). **Skipped:** C++ template complexity. + +### Dapr (Sidecar Architecture) + +- **Timers vs Reminders** — In-memory timers (lost on restart) vs persisted reminders (survive restarts) +- **Reentrancy tracking** — Tracks request chains to allow A→B→A calls without deadlock while preventing true concurrent access +- **Turn-based concurrency** — One message handler at a time per actor; reentrancy creates nested turns + +**Adopted:** Timer vs Reminder distinction (future). **Adopted:** Reentrancy tracking concept for `request()` chains. + +### Go CSP Patterns + +- **Channels** — First-class typed communication; blocking send/receive by default +- **Select** — Wait on multiple channel operations, proceed with first ready +- **Context cancellation** — `context.Context` carries deadlines and cancellation through call chains +- **errgroup** — Structured concurrency for goroutines + +**Adopted:** Context propagation for cancellation/deadlines. **Skipped:** Raw channels (actors already provide this abstraction). + +### Clojure core.async + +- **Buffer strategies** — Fixed (backpressure), Dropping (drop newest), Sliding (drop oldest) +- **Pub/sub with topics** — Publish to topics, subscribers filter by predicate +- **Mult/Mix combinators** — `mult` (one→many broadcast), `mix` (many→one merge) +- **Transducers on channels** — Apply transformations to channel data without intermediate collections + +**Adopted:** Buffer strategies for mailboxes. **Skipped:** Transducers (Rust iterators serve this purpose). + +--- + +## Synthesis: Lessons Applied + +1. **Full visibility** — Like Erlang, track all processes (including short-lived Tasks) +2. **Typed channels** — `ActorRef` provides type-safe message passing like Gleam's Subject +3. **Task for async work** — Task/TaskGroup fills the gap for non-actor concurrent work (Elixir's Task.Supervisor) +4. **Layers not magic** — Explicit lifecycle by default, virtual actors as opt-in layer (Orleans) +5. **Anti-patterns in docs** — Document when NOT to use actors +6. **Buffer strategies** — Mailboxes should support fixed/dropping/sliding (core.async) +7. **Level-triggered cancellation** — Cancellation as persistent state (Trio) +8. **Joiner policies** — TaskGroup needs configurable completion policies (Loom) +9. **Timer vs Reminder** — Ephemeral vs durable scheduled work (Dapr) +10. **Typed message interfaces** — Compile-time verification (CAF, Gleam) +11. **Context propagation** — Cancellation/deadlines flow through call chains (Go) +12. **Reentrancy tracking** — Prevent deadlock in A→B→A call chains (Dapr) + +### What Spawned Adopted for v0.5 + +| Feature | Source | Status | +|---------|--------|--------| +| Handler\ pattern | Actix | Done | +| Recipient\ type erasure | Actix | Done | +| `#[protocol]` + `#[actor]` macros | Original design | Done | +| Named registry | Erlang, Ractor | Done | +| Dual execution modes | Original design | Done | + +### What's Planned + +| Feature | Source | Priority | +|---------|--------|----------| +| Supervision trees | Erlang, Ractor, Bastion | High (required for 1.0) | +| Meltdown protection / backoff | Bastion, Ractor | High | +| Buffer strategies | core.async | Medium | +| Links & monitors | Erlang | Medium | +| Level-triggered cancellation | Trio | Medium | + +### What's Deferred + +| Feature | Source | Rationale | +|---------|--------|-----------| +| Distributed actors | Ractor, Proto.Actor | Significant complexity | +| Virtual actors | Orleans, Proto.Actor | Opt-in layer for future | +| Persistence / event sourcing | Akka | Different problem domain | +| WASM isolation | Lunatic | Too heavyweight | +| Process groups (pg) | Erlang, Ractor | After supervision | diff --git a/docs/design/README.md b/docs/design/README.md new file mode 100644 index 0000000..34098b2 --- /dev/null +++ b/docs/design/README.md @@ -0,0 +1,8 @@ +# Design Documents + +Architectural decision records from the v0.5 API redesign. Approach B was chosen. + +- **[FRAMEWORK_COMPARISON.md](FRAMEWORK_COMPARISON.md)** — Survey of 12+ actor frameworks (Actix, Ractor, Erlang, Akka, Orleans, Pony, Lunatic, Bastion, Proto.Actor, Trio, Loom, Ray, Swift, CAF, Dapr, Go CSP, core.async). Feature matrices, adopt/skip decisions. +- **[API_REDESIGN.md](API_REDESIGN.md)** — Initial plan: Handler, Recipient, object-safe Receiver pattern, supervision sketches. +- **[API_ALTERNATIVES_SUMMARY.md](API_ALTERNATIVES_SUMMARY.md)** — Full comparison of 6 API approaches (A-F) using the same chat room example. Includes analysis tables and comparison matrix. +- **[API_ALTERNATIVES_QUICK_REFERENCE.md](API_ALTERNATIVES_QUICK_REFERENCE.md)** — Condensed version of the above with code-first examples.
+protocols.rs — traits + message structs + blanket impls (all manual) + +```rust +use spawned_concurrency::error::ActorError; +use spawned_concurrency::message::Message; +use spawned_concurrency::tasks::{Actor, ActorRef, Handler, Response}; +use std::sync::Arc; + +// --- Type aliases (manually declared for cross-protocol references) --- + +pub type RoomRef = Arc; +pub type UserRef = Arc; + +// --- RoomProtocol --- + +pub trait RoomProtocol: Send + Sync { + fn say(&self, from: String, text: String) -> Result<(), ActorError>; + fn add_member(&self, name: String, user: UserRef) -> Result<(), ActorError>; + fn members(&self) -> Response>; +} + +pub mod room_protocol { + use super::*; + + pub struct Say { pub from: String, pub text: String } + impl Message for Say { type Result = (); } + + pub struct AddMember { pub name: String, pub user: UserRef } + impl Message for AddMember { type Result = (); } + + pub struct Members; + impl Message for Members { type Result = Vec; } +} + +pub trait ToRoomRef { + fn to_room_ref(&self) -> RoomRef; +} + +impl ToRoomRef for RoomRef { + fn to_room_ref(&self) -> RoomRef { + Arc::clone(self) + } +} + +impl + Handler + Handler> + RoomProtocol for ActorRef +{ + fn say(&self, from: String, text: String) -> Result<(), ActorError> { + self.send(room_protocol::Say { from, text }) + } + + fn add_member(&self, name: String, user: UserRef) -> Result<(), ActorError> { + self.send(room_protocol::AddMember { name, user }) + } + + fn members(&self) -> Response> { + Response::from(self.request_raw(room_protocol::Members)) + } +} + +impl + Handler + Handler> + ToRoomRef for ActorRef +{ + fn to_room_ref(&self) -> RoomRef { + Arc::new(self.clone()) + } +} + +// --- UserProtocol --- + +pub trait UserProtocol: Send + Sync { + fn deliver(&self, from: String, text: String) -> Result<(), ActorError>; + fn say(&self, text: String) -> Result<(), ActorError>; + fn join_room(&self, room: RoomRef) -> Result<(), ActorError>; +} + +pub mod user_protocol { + use super::*; + + pub struct Deliver { pub from: String, pub text: String } + impl Message for Deliver { type Result = (); } + + pub struct Say { pub text: String } + impl Message for Say { type Result = (); } + + pub struct JoinRoom { pub room: RoomRef } + impl Message for JoinRoom { type Result = (); } +} + +pub trait ToUserRef { + fn to_user_ref(&self) -> UserRef; +} + +impl ToUserRef for UserRef { + fn to_user_ref(&self) -> UserRef { + Arc::clone(self) + } +} + +impl + Handler + Handler> + UserProtocol for ActorRef +{ + fn deliver(&self, from: String, text: String) -> Result<(), ActorError> { + self.send(user_protocol::Deliver { from, text }) + } + + fn say(&self, text: String) -> Result<(), ActorError> { + self.send(user_protocol::Say { text }) + } + + fn join_room(&self, room: RoomRef) -> Result<(), ActorError> { + self.send(user_protocol::JoinRoom { room }) + } +} + +impl + Handler + Handler> + ToUserRef for ActorRef +{ + fn to_user_ref(&self) -> UserRef { + Arc::new(self.clone()) + } +} +``` +