From 7a9209711be772d9391ceb58c48c8f606ed82e54 Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Thu, 19 Feb 2026 22:09:57 -0300 Subject: [PATCH 01/20] docs: add API alternatives comparison doc with updated branch references --- docs/API_ALTERNATIVES_SUMMARY.md | 1201 ++++++++++++++++++++++++++++++ 1 file changed, 1201 insertions(+) create mode 100644 docs/API_ALTERNATIVES_SUMMARY.md diff --git a/docs/API_ALTERNATIVES_SUMMARY.md b/docs/API_ALTERNATIVES_SUMMARY.md new file mode 100644 index 0000000..2499c0f --- /dev/null +++ b/docs/API_ALTERNATIVES_SUMMARY.md @@ -0,0 +1,1201 @@ +# API Redesign: Alternatives Summary + +This document summarizes the different approaches explored for solving two critical API issues in spawned's actor framework. Each approach is illustrated with the **same example** — a chat room with bidirectional communication — so the trade-offs in expressivity, readability, and ease of use can be compared directly. + +## Table of Contents + +- [The Two Problems](#the-two-problems) +- [The Chat Room Example](#the-chat-room-example) +- [Baseline: The Old API](#baseline-the-old-api-whats-on-main-today) +- [Approach A: Handler\ + Recipient\](#approach-a-handlerm--recipientm-actix-style) +- [Approach B: Protocol Traits](#approach-b-protocol-traits-user-defined-contracts) +- [Approach C: Typed Wrappers](#approach-c-typed-wrappers-non-breaking) +- [Approach D: Derive Macro](#approach-d-derive-macro) +- [Approach E: AnyActorRef](#approach-e-anyactorref-fully-type-erased) +- [Approach F: PID Addressing](#approach-f-pid-addressing-erlang-style) +- [Registry & Service Discovery](#registry--service-discovery) +- [Macro Improvement Potential](#macro-improvement-potential) +- [Comparison Matrix](#comparison-matrix) +- [Recommendation](#recommendation) +- [Branch Reference](#branch-reference) + +--- + +## The Two Problems + +### #144: No per-message type safety + +The original API uses a single enum for all request types and another for all reply types. Every `match` must handle variants that are structurally impossible for the message sent: + +```rust +// Old API — every request returns the full Reply enum +match actor.request(Request::GetName).await? { + Reply::Name(n) => println!("{}", n), + Reply::NotFound => println!("not found"), + Reply::Age(_) => unreachable!(), // impossible, but the compiler demands it +} +``` + +### #145: Circular dependencies between actors + +When two actors need bidirectional communication, storing `ActorRef` and `ActorRef` creates a circular module dependency: + +```rust +// room.rs — needs to send Deliver to Users +struct ChatRoom { members: Vec> } // imports User + +// user.rs — needs to send Say to the Room +struct User { room: ActorRef } // imports ChatRoom → circular! +``` + +--- + +## The Chat Room Example + +Every approach below implements the same scenario: + +- **ChatRoom** actor holds a list of members and broadcasts messages +- **User** actor receives messages and can speak to the room +- The room sends `Deliver` to users; users send `Say` to the room → **bidirectional** +- `Members` is a request-reply message that returns the current member list + +This exercises both #144 (typed request-reply) and #145 (circular dependency breaking). + +--- + +## Baseline: The Old API (what's on `main` today) + +Single-enum approach inspired by Erlang's gen_server callbacks: + +```rust +trait Actor: Send + Sized + 'static { + type Request: Clone + Send; // single enum for all call messages + type Message: Clone + Send; // single enum for all cast messages + type Reply: Send; // single enum for all responses + type Error: Debug + Send; + + async fn handle_request(&mut self, msg: Self::Request, ...) -> RequestResponse; + async fn handle_message(&mut self, msg: Self::Message, ...) -> MessageResponse; +} +``` + +**The chat room cannot be built** with the old API as separate modules. There's no type-erasure mechanism, so `ChatRoom` must store `ActorRef` (imports User) while `User` must store `ActorRef` (imports ChatRoom) — circular. You'd have to put everything in a single file or use raw channels. + +Even ignoring #145, the #144 problem means this: + +```rust +// room.rs — all messages in one enum, all replies in another +#[derive(Clone)] +enum RoomRequest { Say { from: String, text: String }, Members } + +#[derive(Clone)] +enum RoomReply { Ack, MemberList(Vec) } + +impl Actor for ChatRoom { + type Request = RoomRequest; + type Reply = RoomReply; + // ... + + async fn handle_request(&mut self, msg: RoomRequest, handle: &ActorRef) -> RequestResponse { + match msg { + RoomRequest::Say { from, text } => { /* broadcast */ RequestResponse::Reply(RoomReply::Ack) } + RoomRequest::Members => RequestResponse::Reply(RoomReply::MemberList(self.member_names())), + } + } +} + +// Caller — must match impossible variants +match room.request(RoomRequest::Members).await? { + RoomReply::MemberList(names) => println!("{:?}", names), + RoomReply::Ack => unreachable!(), // ← impossible but required +} +``` + +**Readability:** The trait signature is self-contained but the enum matching is noisy. New team members must mentally map which reply variants are valid for each request variant — the compiler won't help. + +--- + +## Approach A: Handler\ + Recipient\ (Actix-style) + +**Branches:** [`feat/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`) + +
+messages.rs — shared types, no actor types mentioned + +```rust +use spawned_concurrency::message::Message; +use spawned_concurrency::messages; +use spawned_concurrency::tasks::Recipient; + +pub struct Join { + pub name: String, + pub inbox: Recipient, +} +impl Message for Join { type Result = (); } + +messages! { + Say { from: String, text: String } -> (); + SayToRoom { text: String } -> (); + Deliver { from: String, text: String } -> (); +} +``` +
+ +
+room.rs — knows messages, not User + +```rust +use spawned_concurrency::tasks::{Actor, Context, Handler, Recipient}; +use crate::messages::{Deliver, Join, Say}; + +pub struct ChatRoom { + members: Vec<(String, Recipient)>, +} + +impl Actor for ChatRoom {} + +impl Handler for ChatRoom { + async fn handle(&mut self, msg: Join, _ctx: &Context) { + self.members.push((msg.name, msg.inbox)); + } +} + +impl Handler for ChatRoom { + async fn handle(&mut self, msg: Say, _ctx: &Context) { + for (name, inbox) in &self.members { + if *name != msg.from { + let _ = inbox.send(Deliver { from: msg.from.clone(), text: msg.text.clone() }); + } + } + } +} +``` +
+ +
+user.rs — knows messages, not ChatRoom + +```rust +use spawned_concurrency::tasks::{Actor, Context, Handler, Recipient}; +use crate::messages::{Deliver, Say, SayToRoom}; + +pub struct User { + pub name: String, + pub room: Recipient, +} + +impl Actor for User {} + +impl Handler for User { + async fn handle(&mut self, msg: SayToRoom, _ctx: &Context) { + let _ = self.room.send(Say { from: self.name.clone(), text: msg.text }); + } +} + +impl Handler for User { + async fn handle(&mut self, msg: Deliver, _ctx: &Context) { + tracing::info!("[{}] got: {} says '{}'", self.name, msg.from, msg.text); + } +} +``` +
+ +
+main.rs + +```rust +let room = ChatRoom::new().start(); +let alice = User { name: "Alice".into(), room: room.recipient() }.start(); +let bob = User { name: "Bob".into(), room: room.recipient() }.start(); + +room.send_request(Join { name: "Alice".into(), inbox: alice.recipient::() }).await?; +room.send_request(Join { name: "Bob".into(), inbox: bob.recipient::() }).await?; + +alice.send_request(SayToRoom { text: "Hello everyone!".into() }).await?; +``` +
+ +### With `#[actor]` macro + `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; + +// -- Messages -- + +send_messages! { + Say { from: String, text: String }; + Deliver { from: String, text: String }; + Join { name: String, inbox: Recipient } +} + +request_messages! { + Members -> Vec +} + +// -- API -- + +actor_api! { + pub ChatRoomApi for ActorRef { + send fn say(from: String, text: String) => Say; + send fn add_member(name: String, inbox: Recipient) => Join; + request 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 — 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, Deliver}; + +// -- Messages -- + +send_messages! { + SayToRoom { text: String }; + JoinRoom { room: ActorRef } +} + +// -- API -- + +actor_api! { + pub UserApi for ActorRef { + send fn say(text: String) => SayToRoom; + send fn join_room(room: ActorRef) => JoinRoom; + } +} + +// -- Actor -- + +pub struct User { + pub name: String, + room: Option>, +} + +impl Actor for User {} + +#[actor] +impl User { + pub fn new(name: String) -> Self { + Self { name, room: None } + } + + #[send_handler] + async fn handle_say_to_room(&mut self, msg: SayToRoom, _ctx: &Context) { + if let Some(ref room) = self.room { + let _ = room.say(self.name.clone(), msg.text); + } + } + + #[send_handler] + async fn handle_join_room(&mut self, msg: JoinRoom, ctx: &Context) { + let _ = msg.room.add_member(self.name.clone(), ctx.recipient::()); + self.room = Some(msg.room); + } + + #[send_handler] + async fn handle_deliver(&mut self, msg: Deliver, _ctx: &Context) { + tracing::info!("[{}] got: {} says '{}'", self.name, msg.from, msg.text); + } +} +``` +
+ +
+main.rs — extension traits make it read like plain method calls + +```rust +let room = ChatRoom::new().start(); +let alice = User::new("Alice".into()).start(); +let bob = User::new("Bob".into()).start(); + +alice.join_room(room.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(); +``` +
+ +### Analysis + +| Dimension | Non-macro | With `#[actor]` macro + `actor_api!` | +|-----------|-----------|--------------------------------------| +| **Readability** | Each `impl Handler` block is self-contained. You see the message type and return type in the trait bound. But many small impl blocks can feel scattered. | `#[send_handler]`/`#[request_handler]` attributes inside a single `#[actor] impl` block group all handlers together. `actor_api!` declares the caller-facing API in a compact block. Files read top-to-bottom: Messages → API → Actor. | +| **API at a glance** | Must scan all `impl Handler` blocks to know what messages an actor handles. | The `actor_api!` block is the "at-a-glance" API surface — each line declares a method, its params, and the underlying message. | +| **Boilerplate** | One `impl Handler` block per message × per actor. Message structs need manual `impl Message`. | `send_messages!`/`request_messages!` macros eliminate `Message` impls. `#[actor]` eliminates `Handler` impls. `actor_api!` reduces the extension trait + impl (~15 lines) to ~5 lines. | +| **main.rs expressivity** | Raw message structs: `room.send_request(Join { ... })` — explicit but verbose. | Extension traits: `alice.join_room(room.clone())` — reads like natural API calls. | +| **Circular dep solution** | `Recipient` — room stores `Recipient`, user stores `Recipient`. Neither knows the other's concrete type. | Same mechanism. The macros don't change how type erasure works. | +| **Discoverability** | Standard Rust patterns. Any Rust developer can read `impl Handler`. | `#[actor]` and `actor_api!` are custom — new developers need to learn what they do, but the patterns are common (Actix uses the same approach). | + +**Key insight:** The non-macro version is already concise for handler code. The `#[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. + +--- + +## Approach B: Protocol Traits (user-defined contracts) + +**Branch:** [`feat/approach-b`](https://github.com/lambdaclass/spawned/tree/feat/approach-b) (protocol_impl! macro + Context::actor_ref()) + +**Status:** Fully implemented on `feat/approach-b`. 34 tests passing. All examples rewritten to protocol traits with `protocol_impl!` macro. + +Uses the same `Handler` and `#[actor]` macro as Approach A for #144. Solves #145 differently: instead of `Recipient`, actors communicate across boundaries via explicit user-defined trait objects. + +**Key improvements over the initial WIP:** Type aliases (`BroadcasterRef`, `ParticipantRef`) replace raw `Arc`, conversion helpers (`.as_broadcaster()`) replace `Arc::new(x.clone())`, and `Response` enables async request-response on protocol traits without breaking object safety. + +### Response\: Envelope's counterpart on the receive side + +The existing codebase uses the **Envelope pattern** to type-erase messages on the send side: `Box>` wraps a message + a oneshot sender, allowing the actor's mailbox to hold heterogeneous messages. `Response` is the structural mirror on the receive side — it wraps a oneshot receiver and implements `Future>`: + +```rust +// Envelope (existing): type-erases on the SEND side +// Box> holds msg + response sender + +// Response (new): concrete awaitable on the RECEIVE side +// wraps oneshot::Receiver, implements Future +pub struct Response(oneshot::Receiver); + +impl Future for Response { + type Output = Result; + fn poll(self: Pin<&mut Self>, cx: &mut std::task::Context) -> Poll { + // delegates to inner receiver + } +} +``` + +This keeps protocol traits **object-safe** — `fn members(&self) -> Response>` returns a concrete type, not `impl Future` (which would require RPITIT and break `dyn Trait`). No `BoxFuture` boxing needed either. + +### Full chat room code + +
+protocols.rs — shared contracts with type aliases + Response<T> + +```rust +use spawned_concurrency::error::ActorError; +use spawned_concurrency::Response; +use std::sync::Arc; + +pub type ParticipantRef = Arc; +pub type BroadcasterRef = Arc; + +pub trait ChatParticipant: Send + Sync { + fn deliver(&self, from: String, text: String) -> Result<(), ActorError>; +} + +pub trait ChatBroadcaster: Send + Sync { + fn say(&self, from: String, text: String) -> Result<(), ActorError>; + fn add_member(&self, name: String, inbox: ParticipantRef) -> Result<(), ActorError>; + fn members(&self) -> Response>; +} +``` +
+ +
+room.rs — Messages → Bridge → Conversion → Actor + +```rust +use spawned_concurrency::messages; +use spawned_concurrency::error::ActorError; +use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; +use spawned_concurrency::Response; +use spawned_macros::actor; +use std::sync::Arc; +use crate::protocols::{BroadcasterRef, ChatBroadcaster, ParticipantRef}; + +// -- Messages -- + +messages! { + Say { from: String, text: String } -> (); + Join { name: String, inbox: ParticipantRef } -> (); + Members -> Vec; +} + +// -- Protocol bridge -- + +impl ChatBroadcaster for ActorRef { + fn say(&self, from: String, text: String) -> Result<(), ActorError> { + self.send(Say { from, text }) + } + fn add_member(&self, name: String, inbox: ParticipantRef) -> Result<(), ActorError> { + self.send(Join { name, inbox }) + } + fn members(&self) -> Response> { + Response::from(self.request_raw(Members)) + } +} + +// -- Conversion helper -- + +impl ActorRef { + pub fn as_broadcaster(&self) -> BroadcasterRef { + Arc::new(self.clone()) + } +} + +// -- Actor -- + +pub struct ChatRoom { + members: Vec<(String, ParticipantRef)>, +} + +impl Actor for ChatRoom {} + +#[actor] +impl ChatRoom { + pub fn new() -> Self { + Self { members: Vec::new() } + } + + #[send_handler] + async fn handle_join(&mut self, msg: Join, _ctx: &Context) { + self.members.push((msg.name, msg.inbox)); + } + + #[send_handler] + async fn handle_say(&mut self, msg: Say, _ctx: &Context) { + for (name, inbox) in &self.members { + if *name != msg.from { + let _ = inbox.deliver(msg.from.clone(), msg.text.clone()); + } + } + } + + #[request_handler] + async fn handle_members(&mut self, _msg: Members, _ctx: &Context) -> Vec { + self.members.iter().map(|(name, _)| name.clone()).collect() + } +} +``` +
+ +
+user.rs — bridge + conversion + actor_api! for direct caller API + +```rust +use spawned_concurrency::actor_api; +use spawned_concurrency::messages; +use spawned_concurrency::error::ActorError; +use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; +use spawned_macros::actor; +use std::sync::Arc; +use crate::protocols::{BroadcasterRef, ChatParticipant, ParticipantRef}; + +// -- Messages -- + +messages! { + SayToRoom { text: String } -> (); + Deliver { from: String, text: String } -> (); +} + +// -- Protocol bridge -- + +impl ChatParticipant for ActorRef { + fn deliver(&self, from: String, text: String) -> Result<(), ActorError> { + self.send(Deliver { from, text }) + } +} + +// -- Conversion helper -- + +impl ActorRef { + pub fn as_participant(&self) -> ParticipantRef { + Arc::new(self.clone()) + } +} + +// -- Direct caller API (for main.rs) -- + +actor_api! { + pub UserApi for ActorRef { + send fn say(text: String) => SayToRoom; + } +} + +// -- Actor -- + +pub struct User { + pub name: String, + pub room: BroadcasterRef, +} + +impl Actor for User {} + +#[actor] +impl User { + pub fn new(name: String, room: BroadcasterRef) -> Self { + Self { name, room } + } + + #[send_handler] + async fn handle_say_to_room(&mut self, msg: SayToRoom, _ctx: &Context) { + let _ = self.room.say(self.name.clone(), msg.text); + } + + #[send_handler] + async fn handle_deliver(&mut self, msg: Deliver, _ctx: &Context) { + tracing::info!("[{}] got: {} says '{}'", self.name, msg.from, msg.text); + } +} +``` +
+ +
+main.rs — clean, comparable to Approach A + +```rust +let room = ChatRoom::new().start(); +let alice = User::new("Alice".into(), room.as_broadcaster()).start(); +let bob = User::new("Bob".into(), room.as_broadcaster()).start(); + +room.add_member("Alice".into(), alice.as_participant()).unwrap(); +room.add_member("Bob".into(), bob.as_participant()).unwrap(); + +let members = room.members().await?; + +alice.say("Hello everyone!".into()).unwrap(); +bob.say("Hi Alice!".into()).unwrap(); +``` +
+ +### Analysis + +| Dimension | Assessment | +|-----------|-----------| +| **Readability** | `protocols.rs` is an excellent summary of what crosses the actor boundary. Type aliases (`BroadcasterRef`, `ParticipantRef`) eliminate raw `Arc` noise. Files read top-to-bottom: Messages → Bridge → Conversion → Actor. | +| **API at a glance** | Protocol traits serve as the natural API contract for cross-actor boundaries. Looking at `ChatBroadcaster` tells you exactly what a room can do, with named methods and signatures — the strongest "at-a-glance" surface of all approaches. `actor_api!` adds a direct caller API for `main.rs` where protocol traits aren't needed. | +| **Boilerplate** | Higher than Approach A per cross-actor boundary: protocol trait + bridge impl + message structs + Handler impls. Mitigated by type aliases, conversion helpers, and potential macro bridge (see [Macro Improvement Potential](#macro-improvement-potential)). | +| **main.rs expressivity** | Now comparable to A: `room.as_broadcaster()` instead of `Arc::new(room.clone())`, `room.members().await?` via `Response`, `alice.say(...)` via `actor_api!`. | +| **Request-response** | `Response` keeps protocol traits object-safe while supporting async request-response. Structural mirror of the Envelope pattern — no RPITIT, no `BoxFuture` boxing. | +| **Circular dep solution** | Actors hold `BroadcasterRef` / `ParticipantRef` instead of `ActorRef`. Each new cross-boundary message requires adding a method to the protocol trait + updating the bridge impl. | +| **Macro compatibility** | `#[actor]` for Handler impls, `actor_api!` for direct caller API. Bridge impls are manual but structurally regular (macro bridge is feasible — see below). | +| **Testability** | Best of all approaches — you can mock `ChatBroadcaster` or `ChatParticipant` directly in unit tests without running an actor system. | + +**Key insight:** Protocol traits define contracts at the actor level (like Erlang behaviours) rather than the message level (like Actix's `Recipient`). The duplication cost (protocol method mirrors message struct) is real but buys three things: (1) testability via trait mocking, (2) a natural "API at a glance" surface, and (3) actor-level granularity for registry and discovery. With `Response`, type aliases, and conversion helpers, B's expressivity now matches A's macro version. + +**Scaling trade-off:** In a system with N actor types and M cross-boundary message types, Approach A needs M message structs. Approach B needs M message structs + P protocol traits + P bridge impls, where P grows with distinct actor-to-actor interaction patterns. The extra cost scales with *interaction patterns*, not messages — and each protocol trait is a natural documentation + testing boundary. + +--- + +## 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 current registry is a global `Any`-based name store (Approach A): + +```rust +// Register: store a Recipient by name +registry::register("service_registry", svc.recipient::()).unwrap(); + +// Discover: retrieve without knowing the concrete actor type +let recipient: Recipient = registry::whereis("service_registry").unwrap(); + +// Use: typed request through the recipient +let addr = request(&*recipient, Lookup { name: "web".into() }, timeout).await?; +``` + +The registry API (`register`, `whereis`, `unregister`, `registered`) stays the same across approaches — it's just `HashMap>` with `RwLock`. What changes is **what you store and what you get back**. + +### How it differs per approach + +| Approach | Stored value | Retrieved as | Type safety | Discovery granularity | +|----------|-------------|-------------|-------------|----------------------| +| **Baseline** | `ActorRef
` | `ActorRef` | Compile-time, but requires knowing actor type | Per actor — defeats the point of discovery | +| **A: Recipient** | `Recipient` | `Recipient` | Compile-time per message type | Per message type — fine-grained | +| **B: Protocol Traits** | `Arc` | `Arc` | Compile-time per protocol | Per protocol — coarser-grained | +| **C: Typed Wrappers** | `ActorRef` or `Recipient` | Mixed | Depends on channel | Unclear — dual-channel split | +| **D: Derive Macro** | `Recipient` | `Recipient` | Same as A | Same as A | +| **E: AnyActorRef** | `AnyActorRef` | `AnyActorRef` | None — runtime only | Per actor, but no type info | +| **F: PID** | `Pid` | `Pid` | None — runtime only | Per actor (Erlang-style `whereis`) | + +**Key differences:** + +- **A and D** register per message type: `registry::register("room_lookup", room.recipient::())`. A consumer discovers a `Recipient` — it can only send `Lookup` messages, nothing else. If the room handles 5 message types, you can register it under 5 names (or one name per message type you want to expose). This is the most granular. + +- **B** registers per protocol: `registry::register("room", room.as_broadcaster())`. A consumer discovers a `BroadcasterRef` (`Arc`) — it can call any method on the protocol (`say`, `add_member`, `members`). This is coarser but more natural: one registration covers all the methods in the protocol. + +- **E** is trivially simple but useless: `registry::register("room", room.any_ref())`. You get back an `AnyActorRef` that accepts `Box`. No compile-time knowledge of what messages the actor handles. + +- **F** is the most natural fit for a registry. The registry maps `name → Pid`, and PID-based dispatch handles the rest. This mirrors Erlang exactly: `register(room, Pid)`, `whereis(room) → Pid`. The registry is simple; the complexity moves to the PID dispatch table. But the same runtime safety concern applies — sending to a Pid that doesn't handle the message type fails at runtime. + +--- + +## Macro Improvement Potential + +Approach A's `actor_api!` macro eliminates extension trait boilerplate by generating a trait + impl from a compact declaration. Could similar macros reduce boilerplate in the other approaches? + +### Approach B: Protocol Traits — YES, significant potential + +B already uses `actor_api!` for direct caller APIs (e.g., `UserApi` in `user.rs`). The remaining boilerplate is bridge impls — structurally identical to what `actor_api!` generates. Each bridge method just wraps `self.send(Msg { fields })` or `Response::from(self.request_raw(Msg))`: + +```rust +// Current bridge boilerplate (~12 lines per actor) +impl ChatBroadcaster for ActorRef { + fn say(&self, from: String, text: String) -> Result<(), ActorError> { + self.send(Say { from, text }) + } + fn add_member(&self, name: String, inbox: ParticipantRef) -> Result<(), ActorError> { + self.send(Join { name, inbox }) + } + fn members(&self) -> Response> { + Response::from(self.request_raw(Members)) + } +} +``` + +A variant of `actor_api!` could generate bridge impls for an existing trait: + +```rust +// Potential: impl-only mode for existing protocol traits +actor_api! { + impl ChatBroadcaster for ActorRef { + send fn say(from: String, text: String) => Say; + send fn add_member(name: String, inbox: ParticipantRef) => Join; + request fn members() -> Vec => Members; + } +} +``` + +This would use the same syntax but `impl Trait for Type` (no `pub`, no new trait) signals that we're implementing an existing trait. The protocol trait itself remains user-defined — it IS the contract, so it should stay hand-written. + +Conversion helpers (`as_broadcaster()`, `as_participant()`) could also be generated: + +```rust +// Potential: auto-generate conversion helpers +actor_api! { + impl ChatBroadcaster for ActorRef { + convert fn as_broadcaster() -> BroadcasterRef; + // ... + } +} +``` + +**Impact:** Bridge boilerplate per actor drops from ~12 lines to ~5 lines. The protocol trait definition stays manual (by design). Combined with `#[actor]` and `actor_api!` for direct caller APIs, the total code for a protocol-based actor is competitive with Approach A. + +### Approach C: Typed Wrappers — NO + +The fundamental problem is the dual-channel architecture, not boilerplate. The `Clone` bound incompatibility between enum messages and `Recipient` creates a structural split that macros can't paper over. Typed wrappers still hide `unreachable!()` branches internally. + +### Approach D: Derive Macro — N/A + +This approach IS a macro. The `#[derive(ActorMessages)]` would generate message structs, `Message` impls, API wrappers, and `Handler` delegation — subsuming what `actor_api!`, `send_messages!`, and `#[actor]` do separately. Adding `actor_api!` on top would be redundant. + +### Approach E: AnyActorRef — NO + +You could wrap `send_any(Box::new(...))` in typed helper methods, but this provides false safety — the runtime dispatch can still fail. The whole point of AnyActorRef is erasing types; adding typed wrappers on top contradicts that. + +### Approach F: PID — PARTIAL + +The registration boilerplate could be automated: + +```rust +// Current: manual registration per message type +room.register::(); +room.register::(); +room.register::(); + +// Potential: derive-style auto-registration +#[actor(register(Say, Join, Members))] +impl ChatRoom { ... } +``` + +And `spawned::send(pid, Msg { ... })` could get ergonomic wrappers similar to `actor_api!`. But since `Pid` carries no type information, these wrappers can only provide ergonomics, not safety — a wrong Pid still causes a runtime error. + +### Summary + +| Approach | Macro potential | What it would eliminate | Worth implementing? | +|----------|----------------|----------------------|---------------------| +| **B: Protocol Traits** | High | Bridge impls + conversion helpers | Yes — `actor_api!` impl-only mode | +| **C: Typed Wrappers** | None | N/A — structural problem | No | +| **D: Derive Macro** | N/A | Already a macro | N/A | +| **E: AnyActorRef** | None | Would add false safety | No | +| **F: PID** | Low-Medium | Registration ceremony | Maybe — ergonomics only | + +**Takeaway:** Approach B already uses `actor_api!` for direct caller APIs and would benefit further from an impl-only mode for bridge impls. The required change is small — reusing the existing macro syntax. With `Response`, type aliases, conversion helpers, and macro bridge impls, Approach B's total code is competitive with Approach A while retaining its testability and Erlang-like actor-level granularity advantages. + +--- + +## Comparison Matrix + +### Functional Dimensions + +| Dimension | A: Recipient | B: Protocol Traits | C: Typed Wrappers | D: Derive Macro | E: AnyActorRef | F: PID | +|-----------|-------------|-------------------|-------------------|-----------------|---------------|--------| +| **Status** | Implemented | WIP | Design only | Design only | Design only | Design only | +| **Breaking** | Yes | Yes | No | No | Yes | Yes | +| **#144 type safety** | Full | Full | Hidden `unreachable!` | Hidden `unreachable!` | Full | Full | +| **#145 type safety** | Compile-time | Compile-time | Compile-time | Compile-time | Runtime only | Runtime only | +| **Macro support** | `#[actor]` + `actor_api!` + message macros | `#[actor]` + `actor_api!` (direct API) + bridge impls | N/A (enum-based) | Derive macro | `#[actor]` | `#[actor]` | +| **Dual-mode (async+threads)** | Works | Works | Complex (dual channel) | Complex | Works | Works | +| **Registry stores** | `Recipient` | `BroadcasterRef` / `Arc` | Mixed | `Recipient` | `AnyActorRef` | `Pid` | +| **Registry type safety** | Compile-time | Compile-time | Depends | Compile-time | Runtime | Runtime | + +### Code Quality Dimensions + +| Dimension | A: Recipient | B: Protocol Traits | C: Typed Wrappers | D: Derive Macro | E: AnyActorRef | F: PID | +|-----------|-------------|-------------------|-------------------|-----------------|---------------|--------| +| **Handler readability** | Clear: one `impl Handler` or `#[send_handler]` per message | Same as A for handlers. Files read Messages → Bridge → Conversion → Actor. Type aliases reduce noise. | Noisy: enum `match` arms + wrapper fns | Opaque: generated from enum annotations | Same as A, but callers use `Box::new` | Same as A, but callers use global `send(pid, msg)` | +| **API at a glance** | `actor_api!` block or scan Handler impls | Protocol traits (best) + `actor_api!` for direct caller API | Typed wrapper functions | Annotated enum (good summary) | Nothing — `AnyActorRef` is opaque | Nothing — `Pid` is opaque | +| **main.rs expressivity** | `alice.say("Hi")` with `actor_api!`; `alice.send(SayToRoom{...})` without | `room.say(...)`, `room.members().await?` via protocol; `alice.say(...)` via `actor_api!` | `ChatRoom::say(&room, ...)` assoc fn | Generated methods: `room.say(...)` | `room.send_any(Box::new(...))` | `spawned::send(pid, ...)` + registration | +| **Boilerplate per message** | Struct + `actor_api!` line | Struct + protocol method + bridge impl (or macro bridge) | Enum variant + wrapper + match arm | Enum variant + annotation | Struct | Struct + registration | +| **Debugging** | Standard Rust — all code visible | Standard Rust — bridge impls visible | Standard Rust | Requires `cargo expand` | Runtime errors (downcast failures) | Runtime errors (unregistered types) | +| **Testability** | Good (mock via Recipient) | Best (mock protocol trait) | Good | Good | Fair (Any-based) | Hard (global state) | + +### Strategic Dimensions + +| Dimension | A: Recipient | B: Protocol Traits | C: Typed Wrappers | D: Derive Macro | E: AnyActorRef | F: PID | +|-----------|-------------|-------------------|-------------------|-----------------|---------------|--------| +| **Framework complexity** | Medium | None (user-space) | High (dual channel) | Very high (proc macro) | High (dispatch) | Medium (registry) | +| **Maintenance burden** | Low — proven Actix pattern | Low — user-maintained | High — two dispatch systems | High — complex macro | Medium | Medium | +| **Clustering readiness** | Needs `RemoteRecipient` | Needs remote bridge impls | Hard | Hard | Possible (serialize Any) | Excellent (Pid is location-transparent) | +| **Learning curve** | Moderate (Handler pattern) | Moderate + bridge pattern | Low (old API preserved) | Low (write enum, macro does rest) | Low concept, high debugging | Low concept, high registration overhead | +| **Erlang alignment** | Actix-like | Actor-level granularity (Erlang behaviours) | Actix-like | Actix-like | Erlang-ish | Most Erlang | +| **Macro improvement potential** | Already done (`actor_api!`) | Medium (bridge impls, conversion helpers) | None (structural) | N/A (is a macro) | None (false safety) | Low (ergonomics only) | + +--- + +## 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 + +**Approach B (Protocol Traits)** is a strong alternative, especially with `Response`: +- With type aliases, conversion helpers, and `Response`, main.rs expressivity now matches Approach A +- `Response` keeps protocol traits object-safe while supporting async request-response — structural mirror of the Envelope pattern (no RPITIT, no `BoxFuture`) +- Protocol traits define contracts at the actor level (like Erlang behaviours), giving actor-level granularity for registry and discovery +- Best testability — protocol traits can be mocked directly without running an actor system +- Can coexist with Recipient\ — use protocol traits where you want explicit contracts and testability, Recipient\ where you want less boilerplate +- Only requires `Response` from the framework; protocol traits and bridge impls are purely user-space + +**Approaches C and D** try to preserve the old enum-based API but introduce significant complexity (dual-channel, or heavy code generation) to work around its limitations. + +**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_impl! macro + 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. From 02c5c0d1bcd3cd25b0de6c82e30a72524fbcb281 Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Fri, 20 Feb 2026 12:45:06 -0300 Subject: [PATCH 02/20] docs: update Approach B examples to match protocol_impl! + aligned caller API --- docs/API_ALTERNATIVES_SUMMARY.md | 240 +++++++++++++++---------------- 1 file changed, 119 insertions(+), 121 deletions(-) diff --git a/docs/API_ALTERNATIVES_SUMMARY.md b/docs/API_ALTERNATIVES_SUMMARY.md index 2499c0f..43a5b85 100644 --- a/docs/API_ALTERNATIVES_SUMMARY.md +++ b/docs/API_ALTERNATIVES_SUMMARY.md @@ -394,7 +394,7 @@ bob.say("Hi Alice!".into()).unwrap(); Uses the same `Handler` and `#[actor]` macro as Approach A for #144. Solves #145 differently: instead of `Recipient`, actors communicate across boundaries via explicit user-defined trait objects. -**Key improvements over the initial WIP:** Type aliases (`BroadcasterRef`, `ParticipantRef`) replace raw `Arc`, conversion helpers (`.as_broadcaster()`) replace `Arc::new(x.clone())`, and `Response` enables async request-response on protocol traits without breaking object safety. +**Key improvements over the initial WIP:** Type aliases (`BroadcasterRef`, `ParticipantRef`) replace raw `Arc`, conversion traits (`AsBroadcaster`, `AsParticipant`) replace `Arc::new(x.clone())`, `Response` enables async request-response on protocol traits without breaking object safety, `protocol_impl!` generates bridge impls from a compact declaration, and `Context::actor_ref()` lets actors obtain their own `ActorRef` for self-registration. ### Response\: Envelope's counterpart on the receive side @@ -421,66 +421,71 @@ This keeps protocol traits **object-safe** — `fn members(&self) -> Response -protocols.rs — shared contracts with type aliases + Response<T> +protocols.rs — shared contracts with type aliases, Response<T>, and conversion traits ```rust use spawned_concurrency::error::ActorError; -use spawned_concurrency::Response; +use spawned_concurrency::tasks::Response; use std::sync::Arc; -pub type ParticipantRef = Arc; pub type BroadcasterRef = Arc; +pub type ParticipantRef = Arc; + +pub trait ChatBroadcaster: Send + Sync { + fn say(&self, from: String, text: String) -> Result<(), ActorError>; + fn add_member(&self, name: String, participant: ParticipantRef) -> Result<(), ActorError>; + fn members(&self) -> Response>; +} pub trait ChatParticipant: Send + Sync { fn deliver(&self, from: String, text: String) -> Result<(), ActorError>; } -pub trait ChatBroadcaster: Send + Sync { - fn say(&self, from: String, text: String) -> Result<(), ActorError>; - fn add_member(&self, name: String, inbox: ParticipantRef) -> Result<(), ActorError>; - fn members(&self) -> Response>; +pub trait AsBroadcaster { + fn as_broadcaster(&self) -> BroadcasterRef; +} + +pub trait AsParticipant { + fn as_participant(&self) -> ParticipantRef; } ```
-room.rs — Messages → Bridge → Conversion → Actor +room.rs — Messages → protocol_impl! → Conversion → Actor ```rust -use spawned_concurrency::messages; -use spawned_concurrency::error::ActorError; +use spawned_concurrency::protocol_impl; +use spawned_concurrency::request_messages; +use spawned_concurrency::send_messages; use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; -use spawned_concurrency::Response; use spawned_macros::actor; use std::sync::Arc; -use crate::protocols::{BroadcasterRef, ChatBroadcaster, ParticipantRef}; +use crate::protocols::{AsBroadcaster, BroadcasterRef, ChatBroadcaster, ParticipantRef}; -// -- Messages -- +// -- Internal messages -- -messages! { - Say { from: String, text: String } -> (); - Join { name: String, inbox: ParticipantRef } -> (); - Members -> Vec; +send_messages! { + Say { from: String, text: String }; + Join { name: String, participant: ParticipantRef } +} + +request_messages! { + Members -> Vec } // -- Protocol bridge -- -impl ChatBroadcaster for ActorRef { - fn say(&self, from: String, text: String) -> Result<(), ActorError> { - self.send(Say { from, text }) - } - fn add_member(&self, name: String, inbox: ParticipantRef) -> Result<(), ActorError> { - self.send(Join { name, inbox }) - } - fn members(&self) -> Response> { - Response::from(self.request_raw(Members)) +protocol_impl! { + ChatBroadcaster for ActorRef { + send fn say(from: String, text: String) => Say; + send fn add_member(name: String, participant: ParticipantRef) => Join; + request fn members() -> Vec => Members; } } -// -- Conversion helper -- - -impl ActorRef { - pub fn as_broadcaster(&self) -> BroadcasterRef { +impl AsBroadcaster for ActorRef { + fn as_broadcaster(&self) -> BroadcasterRef { Arc::new(self.clone()) } } @@ -501,14 +506,16 @@ impl ChatRoom { #[send_handler] async fn handle_join(&mut self, msg: Join, _ctx: &Context) { - self.members.push((msg.name, msg.inbox)); + tracing::info!("[room] {} joined", msg.name); + self.members.push((msg.name, msg.participant)); } #[send_handler] async fn handle_say(&mut self, msg: Say, _ctx: &Context) { - for (name, inbox) in &self.members { + tracing::info!("[room] {} says: {}", msg.from, msg.text); + for (name, participant) in &self.members { if *name != msg.from { - let _ = inbox.deliver(msg.from.clone(), msg.text.clone()); + let _ = participant.deliver(msg.from.clone(), msg.text.clone()); } } } @@ -522,66 +529,82 @@ impl ChatRoom {
-user.rs — bridge + conversion + actor_api! for direct caller API +user.rs — protocol_impl! bridge + UserActions trait + actor ```rust -use spawned_concurrency::actor_api; -use spawned_concurrency::messages; use spawned_concurrency::error::ActorError; +use spawned_concurrency::protocol_impl; +use spawned_concurrency::send_messages; use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; use spawned_macros::actor; use std::sync::Arc; -use crate::protocols::{BroadcasterRef, ChatParticipant, ParticipantRef}; +use crate::protocols::{AsBroadcaster, AsParticipant, BroadcasterRef, ChatParticipant, ParticipantRef}; -// -- Messages -- +// -- Internal messages -- -messages! { - SayToRoom { text: String } -> (); - Deliver { from: String, text: String } -> (); +send_messages! { + Deliver { from: String, text: String }; + SayToRoom { text: String }; + JoinRoom { room: BroadcasterRef } } -// -- Protocol bridge -- +// -- Protocol bridge (ChatParticipant) -- -impl ChatParticipant for ActorRef { - fn deliver(&self, from: String, text: String) -> Result<(), ActorError> { - self.send(Deliver { from, text }) +protocol_impl! { + ChatParticipant for ActorRef { + send fn deliver(from: String, text: String) => Deliver; } } -// -- Conversion helper -- - -impl ActorRef { - pub fn as_participant(&self) -> ParticipantRef { +impl AsParticipant for ActorRef { + fn as_participant(&self) -> ParticipantRef { Arc::new(self.clone()) } } -// -- Direct caller API (for main.rs) -- +// -- Caller API -- -actor_api! { - pub UserApi for ActorRef { - send fn say(text: String) => SayToRoom; +pub trait UserActions { + fn say(&self, text: String) -> Result<(), ActorError>; + fn join_room(&self, room: impl AsBroadcaster) -> Result<(), ActorError>; +} + +impl UserActions for ActorRef { + fn say(&self, text: String) -> Result<(), ActorError> { + self.send(SayToRoom { text }) + } + + fn join_room(&self, room: impl AsBroadcaster) -> Result<(), ActorError> { + self.send(JoinRoom { room: room.as_broadcaster() }) } } // -- Actor -- pub struct User { - pub name: String, - pub room: BroadcasterRef, + name: String, + room: Option, } impl Actor for User {} #[actor] impl User { - pub fn new(name: String, room: BroadcasterRef) -> Self { - Self { name, room } + pub fn new(name: String) -> Self { + Self { name, room: None } + } + + #[send_handler] + async fn handle_join_room(&mut self, msg: JoinRoom, ctx: &Context) { + let _ = msg.room.add_member(self.name.clone(), ctx.actor_ref().as_participant()); + self.room = Some(msg.room); } #[send_handler] async fn handle_say_to_room(&mut self, msg: SayToRoom, _ctx: &Context) { - let _ = self.room.say(self.name.clone(), msg.text); + if let Some(ref room) = self.room { + let _ = room.say(self.name.clone(), msg.text); + } } #[send_handler] @@ -593,20 +616,20 @@ impl User {
-main.rs — clean, comparable to Approach A +main.rs — identical body to Approach A ```rust let room = ChatRoom::new().start(); -let alice = User::new("Alice".into(), room.as_broadcaster()).start(); -let bob = User::new("Bob".into(), room.as_broadcaster()).start(); +let alice = User::new("Alice".into()).start(); +let bob = User::new("Bob".into()).start(); -room.add_member("Alice".into(), alice.as_participant()).unwrap(); -room.add_member("Bob".into(), bob.as_participant()).unwrap(); +alice.join_room(room.clone()).unwrap(); +bob.join_room(room.clone()).unwrap(); -let members = room.members().await?; +let members = room.members().await.unwrap(); alice.say("Hello everyone!".into()).unwrap(); -bob.say("Hi Alice!".into()).unwrap(); +bob.say("Hey Alice!".into()).unwrap(); ```
@@ -615,15 +638,15 @@ bob.say("Hi Alice!".into()).unwrap(); | Dimension | Assessment | |-----------|-----------| | **Readability** | `protocols.rs` is an excellent summary of what crosses the actor boundary. Type aliases (`BroadcasterRef`, `ParticipantRef`) eliminate raw `Arc` noise. Files read top-to-bottom: Messages → Bridge → Conversion → Actor. | -| **API at a glance** | Protocol traits serve as the natural API contract for cross-actor boundaries. Looking at `ChatBroadcaster` tells you exactly what a room can do, with named methods and signatures — the strongest "at-a-glance" surface of all approaches. `actor_api!` adds a direct caller API for `main.rs` where protocol traits aren't needed. | -| **Boilerplate** | Higher than Approach A per cross-actor boundary: protocol trait + bridge impl + message structs + Handler impls. Mitigated by type aliases, conversion helpers, and potential macro bridge (see [Macro Improvement Potential](#macro-improvement-potential)). | -| **main.rs expressivity** | Now comparable to A: `room.as_broadcaster()` instead of `Arc::new(room.clone())`, `room.members().await?` via `Response`, `alice.say(...)` via `actor_api!`. | +| **API at a glance** | Protocol traits serve as the natural API contract for cross-actor boundaries. Looking at `ChatBroadcaster` tells you exactly what a room can do, with named methods and signatures — the strongest "at-a-glance" surface of all approaches. `UserActions` trait provides the direct caller API. | +| **Boilerplate** | Higher than Approach A per cross-actor boundary: protocol trait + `protocol_impl!` bridge + message structs + Handler impls. Mitigated by type aliases, conversion traits, and `protocol_impl!` macro. | +| **main.rs expressivity** | Identical body to A: `alice.join_room(room.clone())`, `room.members().await.unwrap()`, `alice.say(...)`. `join_room` accepts `impl AsBroadcaster` so callers pass `ActorRef` directly. | | **Request-response** | `Response` keeps protocol traits object-safe while supporting async request-response. Structural mirror of the Envelope pattern — no RPITIT, no `BoxFuture` boxing. | -| **Circular dep solution** | Actors hold `BroadcasterRef` / `ParticipantRef` instead of `ActorRef`. Each new cross-boundary message requires adding a method to the protocol trait + updating the bridge impl. | -| **Macro compatibility** | `#[actor]` for Handler impls, `actor_api!` for direct caller API. Bridge impls are manual but structurally regular (macro bridge is feasible — see below). | +| **Circular dep solution** | Actors hold `BroadcasterRef` / `ParticipantRef` instead of `ActorRef`. Each new cross-boundary message requires adding a method to the protocol trait + updating the `protocol_impl!` bridge. | +| **Macro compatibility** | `#[actor]` for Handler impls, `protocol_impl!` for bridge impls. Direct caller APIs use manual trait impls when generic params are needed (e.g., `join_room(impl AsBroadcaster)`). | | **Testability** | Best of all approaches — you can mock `ChatBroadcaster` or `ChatParticipant` directly in unit tests without running an actor system. | -**Key insight:** Protocol traits define contracts at the actor level (like Erlang behaviours) rather than the message level (like Actix's `Recipient`). The duplication cost (protocol method mirrors message struct) is real but buys three things: (1) testability via trait mocking, (2) a natural "API at a glance" surface, and (3) actor-level granularity for registry and discovery. With `Response`, type aliases, and conversion helpers, B's expressivity now matches A's macro version. +**Key insight:** Protocol traits define contracts at the actor level (like Erlang behaviours) rather than the message level (like Actix's `Recipient`). The duplication cost (protocol method mirrors message struct) is real but buys three things: (1) testability via trait mocking, (2) a natural "API at a glance" surface, and (3) actor-level granularity for registry and discovery. With `Response`, type aliases, conversion traits, and `protocol_impl!`, B's main.rs body is identical to A's. **Scaling trade-off:** In a system with N actor types and M cross-boundary message types, Approach A needs M message structs. Approach B needs M message structs + P protocol traits + P bridge impls, where P grows with distinct actor-to-actor interaction patterns. The extra cost scales with *interaction patterns*, not messages — and each protocol trait is a natural documentation + testing boundary. @@ -1026,53 +1049,26 @@ The registry API (`register`, `whereis`, `unregister`, `registered`) stays the s Approach A's `actor_api!` macro eliminates extension trait boilerplate by generating a trait + impl from a compact declaration. Could similar macros reduce boilerplate in the other approaches? -### Approach B: Protocol Traits — YES, significant potential - -B already uses `actor_api!` for direct caller APIs (e.g., `UserApi` in `user.rs`). The remaining boilerplate is bridge impls — structurally identical to what `actor_api!` generates. Each bridge method just wraps `self.send(Msg { fields })` or `Response::from(self.request_raw(Msg))`: - -```rust -// Current bridge boilerplate (~12 lines per actor) -impl ChatBroadcaster for ActorRef { - fn say(&self, from: String, text: String) -> Result<(), ActorError> { - self.send(Say { from, text }) - } - fn add_member(&self, name: String, inbox: ParticipantRef) -> Result<(), ActorError> { - self.send(Join { name, inbox }) - } - fn members(&self) -> Response> { - Response::from(self.request_raw(Members)) - } -} -``` +### Approach B: Protocol Traits — DONE (`protocol_impl!`) -A variant of `actor_api!` could generate bridge impls for an existing trait: +B now uses `protocol_impl!` to generate bridge impls from a compact declaration. What was ~12 lines of manual bridge boilerplate per actor is now ~5 lines: ```rust -// Potential: impl-only mode for existing protocol traits -actor_api! { - impl ChatBroadcaster for ActorRef { +// protocol_impl! generates the full impl block +protocol_impl! { + ChatBroadcaster for ActorRef { send fn say(from: String, text: String) => Say; - send fn add_member(name: String, inbox: ParticipantRef) => Join; + send fn add_member(name: String, participant: ParticipantRef) => Join; request fn members() -> Vec => Members; } } ``` -This would use the same syntax but `impl Trait for Type` (no `pub`, no new trait) signals that we're implementing an existing trait. The protocol trait itself remains user-defined — it IS the contract, so it should stay hand-written. - -Conversion helpers (`as_broadcaster()`, `as_participant()`) could also be generated: +Each `send fn` generates `fn method(&self, ...) -> Result<(), ActorError> { self.send(Msg { ... }) }`. Each `request fn` generates `fn method(&self, ...) -> Response { Response::from(self.request_raw(Msg)) }`. The protocol trait itself remains user-defined — it IS the contract, so it should stay hand-written. -```rust -// Potential: auto-generate conversion helpers -actor_api! { - impl ChatBroadcaster for ActorRef { - convert fn as_broadcaster() -> BroadcasterRef; - // ... - } -} -``` +Conversion traits (`AsBroadcaster`, `AsParticipant`) are still manual (~4 lines each) but are structurally trivial. For direct caller APIs where generic params are needed (e.g., `join_room(impl AsBroadcaster)`), manual trait impls are used instead of `protocol_impl!`. -**Impact:** Bridge boilerplate per actor drops from ~12 lines to ~5 lines. The protocol trait definition stays manual (by design). Combined with `#[actor]` and `actor_api!` for direct caller APIs, the total code for a protocol-based actor is competitive with Approach A. +**Impact:** Combined with `#[actor]`, `protocol_impl!`, and conversion traits, Approach B's total code is competitive with Approach A while retaining its testability and Erlang-like actor-level granularity advantages. ### Approach C: Typed Wrappers — NO @@ -1080,7 +1076,7 @@ The fundamental problem is the dual-channel architecture, not boilerplate. The ` ### Approach D: Derive Macro — N/A -This approach IS a macro. The `#[derive(ActorMessages)]` would generate message structs, `Message` impls, API wrappers, and `Handler` delegation — subsuming what `actor_api!`, `send_messages!`, and `#[actor]` do separately. Adding `actor_api!` on top would be redundant. +This approach IS a macro. The `#[derive(ActorMessages)]` would generate message structs, `Message` impls, API wrappers, and `Handler` delegation — subsuming what `actor_api!`/`protocol_impl!`, `send_messages!`, and `#[actor]` do separately. ### Approach E: AnyActorRef — NO @@ -1107,13 +1103,13 @@ And `spawned::send(pid, Msg { ... })` could get ergonomic wrappers similar to `a | Approach | Macro potential | What it would eliminate | Worth implementing? | |----------|----------------|----------------------|---------------------| -| **B: Protocol Traits** | High | Bridge impls + conversion helpers | Yes — `actor_api!` impl-only mode | +| **B: Protocol Traits** | High | Bridge impls + conversion traits | Done — `protocol_impl!` macro | | **C: Typed Wrappers** | None | N/A — structural problem | No | | **D: Derive Macro** | N/A | Already a macro | N/A | | **E: AnyActorRef** | None | Would add false safety | No | | **F: PID** | Low-Medium | Registration ceremony | Maybe — ergonomics only | -**Takeaway:** Approach B already uses `actor_api!` for direct caller APIs and would benefit further from an impl-only mode for bridge impls. The required change is small — reusing the existing macro syntax. With `Response`, type aliases, conversion helpers, and macro bridge impls, Approach B's total code is competitive with Approach A while retaining its testability and Erlang-like actor-level granularity advantages. +**Takeaway:** Approach B now uses `protocol_impl!` for bridge impls, achieving competitive code volume with Approach A. With `Response`, type aliases, conversion traits, and `protocol_impl!`, main.rs bodies are identical between A and B — the approaches differ only in internal wiring and dependency structure. --- @@ -1123,11 +1119,11 @@ And `spawned::send(pid, Msg { ... })` could get ergonomic wrappers similar to `a | Dimension | A: Recipient | B: Protocol Traits | C: Typed Wrappers | D: Derive Macro | E: AnyActorRef | F: PID | |-----------|-------------|-------------------|-------------------|-----------------|---------------|--------| -| **Status** | Implemented | WIP | Design only | Design only | Design only | Design only | +| **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 | `#[actor]` + `actor_api!` (direct API) + bridge impls | N/A (enum-based) | Derive macro | `#[actor]` | `#[actor]` | +| **Macro support** | `#[actor]` + `actor_api!` + message macros | `#[actor]` + `protocol_impl!` + message macros | N/A (enum-based) | Derive macro | `#[actor]` | `#[actor]` | | **Dual-mode (async+threads)** | Works | Works | Complex (dual channel) | Complex | Works | Works | | **Registry stores** | `Recipient` | `BroadcasterRef` / `Arc` | Mixed | `Recipient` | `AnyActorRef` | `Pid` | | **Registry type safety** | Compile-time | Compile-time | Depends | Compile-time | Runtime | Runtime | @@ -1137,9 +1133,9 @@ And `spawned::send(pid, Msg { ... })` could get ergonomic wrappers similar to `a | 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. Files read Messages → Bridge → Conversion → Actor. Type aliases reduce noise. | Noisy: enum `match` arms + wrapper fns | Opaque: generated from enum annotations | Same as A, but callers use `Box::new` | Same as A, but callers use global `send(pid, msg)` | -| **API at a glance** | `actor_api!` block or scan Handler impls | Protocol traits (best) + `actor_api!` for direct caller API | Typed wrapper functions | Annotated enum (good summary) | Nothing — `AnyActorRef` is opaque | Nothing — `Pid` is opaque | -| **main.rs expressivity** | `alice.say("Hi")` with `actor_api!`; `alice.send(SayToRoom{...})` without | `room.say(...)`, `room.members().await?` via protocol; `alice.say(...)` via `actor_api!` | `ChatRoom::say(&room, ...)` assoc fn | Generated methods: `room.say(...)` | `room.send_any(Box::new(...))` | `spawned::send(pid, ...)` + registration | -| **Boilerplate per message** | Struct + `actor_api!` line | Struct + protocol method + bridge impl (or macro bridge) | Enum variant + wrapper + match arm | Enum variant + annotation | Struct | Struct + registration | +| **API at a glance** | `actor_api!` block or scan Handler impls | Protocol traits (best) + `UserActions` trait for direct caller API | Typed wrapper functions | Annotated enum (good summary) | Nothing — `AnyActorRef` is opaque | Nothing — `Pid` is opaque | +| **main.rs expressivity** | `alice.say("Hi")` with `actor_api!`; `alice.send(SayToRoom{...})` without | Identical body to A: `alice.join_room(room.clone())`, `room.members().await`, `alice.say(...)` | `ChatRoom::say(&room, ...)` assoc fn | Generated methods: `room.say(...)` | `room.send_any(Box::new(...))` | `spawned::send(pid, ...)` + registration | +| **Boilerplate per message** | Struct + `actor_api!` line | Struct + protocol method + `protocol_impl!` line | Enum variant + wrapper + match arm | Enum variant + annotation | Struct | Struct + registration | | **Debugging** | Standard Rust — all code visible | Standard Rust — bridge impls visible | Standard Rust | Requires `cargo expand` | Runtime errors (downcast failures) | Runtime errors (unregistered types) | | **Testability** | Good (mock via Recipient) | Best (mock protocol trait) | Good | Good | Fair (Any-based) | Hard (global state) | @@ -1152,7 +1148,7 @@ And `spawned::send(pid, Msg { ... })` could get ergonomic wrappers similar to `a | **Clustering readiness** | Needs `RemoteRecipient` | Needs remote bridge impls | Hard | Hard | Possible (serialize Any) | Excellent (Pid is location-transparent) | | **Learning curve** | Moderate (Handler pattern) | Moderate + bridge pattern | Low (old API preserved) | Low (write enum, macro does rest) | Low concept, high debugging | Low concept, high registration overhead | | **Erlang alignment** | Actix-like | Actor-level granularity (Erlang behaviours) | Actix-like | Actix-like | Erlang-ish | Most Erlang | -| **Macro improvement potential** | Already done (`actor_api!`) | Medium (bridge impls, conversion helpers) | None (structural) | N/A (is a macro) | None (false safety) | Low (ergonomics only) | +| **Macro improvement potential** | Already done (`actor_api!`) | Done (`protocol_impl!`) | None (structural) | N/A (is a macro) | None (false safety) | Low (ergonomics only) | --- @@ -1166,13 +1162,15 @@ And `spawned::send(pid, Msg { ... })` could get ergonomic wrappers similar to `a - Proven pattern (Actix uses the same architecture) - Non-macro version is already clean — the macros are additive, not essential -**Approach B (Protocol Traits)** is a strong alternative, especially with `Response`: -- With type aliases, conversion helpers, and `Response`, main.rs expressivity now matches Approach A +**Approach B (Protocol Traits)** is a strong alternative with identical caller ergonomics: +- main.rs body is identical to A: `alice.join_room(room.clone())`, `room.members().await.unwrap()`, `alice.say(...)` +- `protocol_impl!` macro generates bridge impls from compact declarations, competitive boilerplate with `actor_api!` - `Response` keeps protocol traits object-safe while supporting async request-response — structural mirror of the Envelope pattern (no RPITIT, no `BoxFuture`) +- `Context::actor_ref()` lets actors self-register with protocol traits (e.g., `ctx.actor_ref().as_participant()`) - Protocol traits define contracts at the actor level (like Erlang behaviours), giving actor-level granularity for registry and discovery - Best testability — protocol traits can be mocked directly without running an actor system -- Can coexist with Recipient\ — use protocol traits where you want explicit contracts and testability, Recipient\ where you want less boilerplate -- Only requires `Response` from the framework; protocol traits and bridge impls are purely user-space +- Zero cross-actor dependencies — both Room and User depend only on `protocols.rs` +- Only requires `Response` and `Context::actor_ref()` from the framework; protocol traits and bridge impls are purely user-space **Approaches C and D** try to preserve the old enum-based API but introduce significant complexity (dual-channel, or heavy code generation) to work around its limitations. From 3ef90e4547ce66246e8259e45908c230444ddb87 Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Fri, 20 Feb 2026 13:06:23 -0300 Subject: [PATCH 03/20] docs: move Deliver to user.rs in Approach A examples to match actual code --- docs/API_ALTERNATIVES_SUMMARY.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/API_ALTERNATIVES_SUMMARY.md b/docs/API_ALTERNATIVES_SUMMARY.md index 43a5b85..312ac50 100644 --- a/docs/API_ALTERNATIVES_SUMMARY.md +++ b/docs/API_ALTERNATIVES_SUMMARY.md @@ -232,12 +232,12 @@ 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 }; - Deliver { from: String, text: String }; Join { name: String, inbox: Recipient } } @@ -292,18 +292,19 @@ impl ChatRoom {
-user.rs — macro version +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, Deliver}; +use crate::room::{ChatRoom, ChatRoomApi}; // -- Messages -- send_messages! { + Deliver { from: String, text: String }; SayToRoom { text: String }; JoinRoom { room: ActorRef } } From e5bc50d8c4c61e5e33e66c758509411bfde3abfb Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Fri, 20 Feb 2026 13:10:27 -0300 Subject: [PATCH 04/20] docs: add cross-crate scaling insight (A's circular dep vs B's clean separation) --- docs/API_ALTERNATIVES_SUMMARY.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/API_ALTERNATIVES_SUMMARY.md b/docs/API_ALTERNATIVES_SUMMARY.md index 312ac50..e1e69b2 100644 --- a/docs/API_ALTERNATIVES_SUMMARY.md +++ b/docs/API_ALTERNATIVES_SUMMARY.md @@ -651,6 +651,8 @@ bob.say("Hey Alice!".into()).unwrap(); **Scaling trade-off:** In a system with N actor types and M cross-boundary message types, Approach A needs M message structs. Approach B needs M message structs + P protocol traits + P bridge impls, where P grows with distinct actor-to-actor interaction patterns. The extra cost scales with *interaction patterns*, not messages — and each protocol trait is a natural documentation + testing boundary. +**Cross-crate scaling:** In Approach A, the bidirectional module dependency (room imports `Deliver` from user, user imports `ChatRoomApi` from room) works because they're sibling modules in the same crate. If actors lived in separate crates, this would be a circular crate dependency — which Rust forbids. The fix is extracting shared types (`Deliver`, `ChatRoomApi`) into a third crate, at which point you've essentially reinvented `protocols.rs`. Approach B's structure maps directly to separate crates with zero restructuring: `protocols` becomes a shared crate, and each actor crate depends only on it, never on each other. + --- ## Approach C: Typed Wrappers (non-breaking) From 56351e9bd99d4bcb8c71545fe7e549a223e4ff9e Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Fri, 20 Feb 2026 13:14:27 -0300 Subject: [PATCH 05/20] docs: add cross-crate limitation note to Approach A section --- docs/API_ALTERNATIVES_SUMMARY.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/API_ALTERNATIVES_SUMMARY.md b/docs/API_ALTERNATIVES_SUMMARY.md index e1e69b2..037256e 100644 --- a/docs/API_ALTERNATIVES_SUMMARY.md +++ b/docs/API_ALTERNATIVES_SUMMARY.md @@ -385,6 +385,8 @@ bob.say("Hi Alice!".into()).unwrap(); **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 (user-defined contracts) From 550b6beaa353f440ccadb790c375da38d10b3fb4 Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Fri, 20 Feb 2026 14:16:10 -0300 Subject: [PATCH 06/20] docs: move non-macro Approach A messages into their handler modules --- docs/API_ALTERNATIVES_SUMMARY.md | 40 ++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/docs/API_ALTERNATIVES_SUMMARY.md b/docs/API_ALTERNATIVES_SUMMARY.md index 037256e..18c7f50 100644 --- a/docs/API_ALTERNATIVES_SUMMARY.md +++ b/docs/API_ALTERNATIVES_SUMMARY.md @@ -126,33 +126,27 @@ Each message is its own struct with an associated `Result` type. Actors implemen ### Without macro (manual `impl Handler`)
-messages.rs — shared types, no actor types mentioned +room.rs — defines Room's messages, imports Deliver from user ```rust use spawned_concurrency::message::Message; use spawned_concurrency::messages; -use spawned_concurrency::tasks::Recipient; +use spawned_concurrency::tasks::{Actor, Context, Handler, Recipient}; +use crate::user::Deliver; -pub struct Join { - pub name: String, - pub inbox: Recipient, -} -impl Message for Join { type Result = (); } +// -- Messages (Room handles these) -- messages! { Say { from: String, text: String } -> (); - SayToRoom { text: String } -> (); - Deliver { from: String, text: String } -> (); } -``` -
-
-room.rs — knows messages, not User +pub struct Join { + pub name: String, + pub inbox: Recipient, +} +impl Message for Join { type Result = (); } -```rust -use spawned_concurrency::tasks::{Actor, Context, Handler, Recipient}; -use crate::messages::{Deliver, Join, Say}; +// -- Actor -- pub struct ChatRoom { members: Vec<(String, Recipient)>, @@ -179,11 +173,21 @@ impl Handler for ChatRoom {
-user.rs — knows messages, not ChatRoom +user.rs — defines User's messages (including Deliver), imports Say from room ```rust +use spawned_concurrency::messages; use spawned_concurrency::tasks::{Actor, Context, Handler, Recipient}; -use crate::messages::{Deliver, Say, SayToRoom}; +use crate::room::Say; + +// -- Messages (User handles these) -- + +messages! { + Deliver { from: String, text: String } -> (); + SayToRoom { text: String } -> (); +} + +// -- Actor -- pub struct User { pub name: String, From b1db6116faa1440acb882703d77f1a711de8db90 Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Fri, 20 Feb 2026 14:16:59 -0300 Subject: [PATCH 07/20] docs: update Approach A analysis table to reflect no shared messages.rs --- docs/API_ALTERNATIVES_SUMMARY.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/API_ALTERNATIVES_SUMMARY.md b/docs/API_ALTERNATIVES_SUMMARY.md index 18c7f50..10eaa19 100644 --- a/docs/API_ALTERNATIVES_SUMMARY.md +++ b/docs/API_ALTERNATIVES_SUMMARY.md @@ -380,11 +380,11 @@ bob.say("Hi Alice!".into()).unwrap(); | Dimension | Non-macro | With `#[actor]` macro + `actor_api!` | |-----------|-----------|--------------------------------------| -| **Readability** | Each `impl Handler` block is self-contained. You see the message type and return type in the trait bound. But many small impl blocks can feel scattered. | `#[send_handler]`/`#[request_handler]` attributes inside a single `#[actor] impl` block group all handlers together. `actor_api!` declares the caller-facing API in a compact block. Files read top-to-bottom: Messages → API → Actor. | +| **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`. Neither knows the other's concrete type. | Same mechanism. The macros don't change how type erasure works. | +| **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. | | **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. From 25709f01201898826e42cf2204de91259301e27d Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Fri, 20 Feb 2026 17:08:10 -0300 Subject: [PATCH 08/20] docs: add registry as third problem in API comparison --- docs/API_ALTERNATIVES_SUMMARY.md | 89 +++++++++++++++++++++++++------- 1 file changed, 69 insertions(+), 20 deletions(-) diff --git a/docs/API_ALTERNATIVES_SUMMARY.md b/docs/API_ALTERNATIVES_SUMMARY.md index 10eaa19..19f547b 100644 --- a/docs/API_ALTERNATIVES_SUMMARY.md +++ b/docs/API_ALTERNATIVES_SUMMARY.md @@ -1,10 +1,10 @@ # API Redesign: Alternatives Summary -This document summarizes the different approaches explored for solving two critical API issues in spawned's actor framework. Each approach is illustrated with the **same example** — a chat room with bidirectional communication — so the trade-offs in expressivity, readability, and ease of use can be compared directly. +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 Two Problems](#the-two-problems) +- [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) @@ -21,7 +21,7 @@ This document summarizes the different approaches explored for solving two criti --- -## The Two Problems +## The Three Problems ### #144: No per-message type safety @@ -48,6 +48,22 @@ struct ChatRoom { members: Vec> } // imports User 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: BroadcasterRef (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 @@ -58,8 +74,9 @@ Every approach below implements the same scenario: - **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 both #144 (typed request-reply) and #145 (circular dependency breaking). +This exercises all three problems: #144 (typed request-reply), #145 (circular dependency breaking), and service discovery (registry lookup without direct references). --- @@ -366,6 +383,9 @@ 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(); @@ -373,6 +393,11 @@ 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(); ```
@@ -385,6 +410,7 @@ bob.say("Hi Alice!".into()).unwrap(); | **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. @@ -452,6 +478,15 @@ pub trait AsBroadcaster { fn as_broadcaster(&self) -> BroadcasterRef; } +// Identity conversion — enables registry discovery: +// let room: BroadcasterRef = registry::whereis("general").unwrap(); +// charlie.join_room(room).unwrap(); // works because BroadcasterRef: AsBroadcaster +impl AsBroadcaster for BroadcasterRef { + fn as_broadcaster(&self) -> BroadcasterRef { + Arc::clone(self) + } +} + pub trait AsParticipant { fn as_participant(&self) -> ParticipantRef; } @@ -623,13 +658,16 @@ impl User {
-main.rs — identical body to Approach A +main.rs — identical body to Approach A, protocol-level 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.as_broadcaster()).unwrap(); + alice.join_room(room.clone()).unwrap(); bob.join_room(room.clone()).unwrap(); @@ -637,6 +675,11 @@ 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: BroadcasterRef = registry::whereis("general").unwrap(); +charlie.join_room(discovered).unwrap(); ```
@@ -650,6 +693,7 @@ bob.say("Hey Alice!".into()).unwrap(); | **main.rs expressivity** | Identical body to A: `alice.join_room(room.clone())`, `room.members().await.unwrap()`, `alice.say(...)`. `join_room` accepts `impl AsBroadcaster` so callers pass `ActorRef` directly. | | **Request-response** | `Response` keeps protocol traits object-safe while supporting async request-response. Structural mirror of the Envelope pattern — no RPITIT, no `BoxFuture` boxing. | | **Circular dep solution** | Actors hold `BroadcasterRef` / `ParticipantRef` instead of `ActorRef`. Each new cross-boundary message requires adding a method to the protocol trait + updating the `protocol_impl!` bridge. | +| **Registry** | Register `BroadcasterRef` — one registration gives the discoverer the full protocol API (`say`, `add_member`, `members`). No concrete actor type needed. Identity `AsBroadcaster` impl on `BroadcasterRef` means discovered refs pass directly to `join_room`. | | **Macro compatibility** | `#[actor]` for Handler impls, `protocol_impl!` for bridge impls. Direct caller APIs use manual trait impls when generic params are needed (e.g., `join_room(impl AsBroadcaster)`). | | **Testability** | Best of all approaches — you can mock `ChatBroadcaster` or `ChatParticipant` directly in unit tests without running an actor system. | @@ -1015,28 +1059,30 @@ let members: Vec = spawned::request(room.pid(), Members).await?; ## Registry & Service Discovery -The current registry is a global `Any`-based name store (Approach A): - -```rust -// Register: store a Recipient by name -registry::register("service_registry", svc.recipient::()).unwrap(); +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. -// Discover: retrieve without knowing the concrete actor type -let recipient: Recipient = registry::whereis("service_registry").unwrap(); +The chat room examples above demonstrate this. Both register the room as `"general"` and a late joiner (Charlie) discovers it: -// Use: typed request through the recipient -let addr = request(&*recipient, Lookup { name: "web".into() }, timeout).await?; +```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.as_broadcaster()).unwrap(); // BroadcasterRef +let discovered: BroadcasterRef = registry::whereis("general").unwrap(); // caller only needs the protocol +charlie.join_room(discovered).unwrap(); // works via AsBroadcaster ``` -The registry API (`register`, `whereis`, `unregister`, `registered`) stays the same across approaches — it's just `HashMap>` with `RwLock`. What changes is **what you store and what you get back**. +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 `BroadcasterRef` from `protocols.rs` — it knows *what the actor can do* without knowing *what actor it is*. Any actor implementing `ChatBroadcaster` 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** | `Recipient` | `Recipient` | Compile-time per message type | Per message type — fine-grained | -| **B: Protocol Traits** | `Arc` | `Arc` | Compile-time per protocol | Per protocol — coarser-grained | +| **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 | @@ -1044,9 +1090,9 @@ The registry API (`register`, `whereis`, `unregister`, `registered`) stays the s **Key differences:** -- **A and D** register per message type: `registry::register("room_lookup", room.recipient::())`. A consumer discovers a `Recipient` — it can only send `Lookup` messages, nothing else. If the room handles 5 message types, you can register it under 5 names (or one name per message type you want to expose). This is the most granular. +- **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("room", room.as_broadcaster())`. A consumer discovers a `BroadcasterRef` (`Arc`) — it can call any method on the protocol (`say`, `add_member`, `members`). This is coarser but more natural: one registration covers all the methods in the protocol. +- **B** registers per protocol: `registry::register("general", room.as_broadcaster())`. A consumer discovers a `BroadcasterRef` (`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. @@ -1134,8 +1180,9 @@ And `spawned::send(pid, Msg { ... })` could get ergonomic wrappers similar to `a | **#145 type safety** | Compile-time | Compile-time | Compile-time | Compile-time | Runtime only | Runtime only | | **Macro support** | `#[actor]` + `actor_api!` + message macros | `#[actor]` + `protocol_impl!` + message macros | N/A (enum-based) | Derive macro | `#[actor]` | `#[actor]` | | **Dual-mode (async+threads)** | Works | Works | Complex (dual channel) | Complex | Works | Works | -| **Registry stores** | `Recipient` | `BroadcasterRef` / `Arc` | Mixed | `Recipient` | `AnyActorRef` | `Pid` | +| **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 @@ -1170,6 +1217,7 @@ And `spawned::send(pid, Msg { ... })` could get ergonomic wrappers similar to `a - `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 identical caller ergonomics: - main.rs body is identical to A: `alice.join_room(room.clone())`, `room.members().await.unwrap()`, `alice.say(...)` @@ -1180,6 +1228,7 @@ And `spawned::send(pid, Msg { ... })` could get ergonomic wrappers similar to `a - Best testability — protocol traits can be mocked directly without running an actor system - Zero cross-actor dependencies — both Room and User depend only on `protocols.rs` - Only requires `Response` and `Context::actor_ref()` from the framework; protocol traits and bridge impls are purely user-space +- Best registry story: one registration per protocol, full API, no concrete type needed — discoverer depends only on the protocol trait **Approaches C and D** try to preserve the old enum-based API but introduce significant complexity (dual-channel, or heavy code generation) to work around its limitations. From a1ddbc4fefc6fbf406644f8a9401334ffbf0933e Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Mon, 23 Feb 2026 11:10:25 -0300 Subject: [PATCH 09/20] docs: replace protocol_impl! with #[protocol] and #[bridge] macros in Approach B --- docs/API_ALTERNATIVES_SUMMARY.md | 156 +++++++++++++------------------ 1 file changed, 67 insertions(+), 89 deletions(-) diff --git a/docs/API_ALTERNATIVES_SUMMARY.md b/docs/API_ALTERNATIVES_SUMMARY.md index 19f547b..441d0eb 100644 --- a/docs/API_ALTERNATIVES_SUMMARY.md +++ b/docs/API_ALTERNATIVES_SUMMARY.md @@ -421,13 +421,13 @@ charlie.join_room(discovered).unwrap(); ## Approach B: Protocol Traits (user-defined contracts) -**Branch:** [`feat/approach-b`](https://github.com/lambdaclass/spawned/tree/feat/approach-b) (protocol_impl! macro + Context::actor_ref()) +**Branch:** [`feat/approach-b`](https://github.com/lambdaclass/spawned/tree/feat/approach-b) (`#[protocol]` + `#[bridge]` macros + Context::actor_ref()) -**Status:** Fully implemented on `feat/approach-b`. 34 tests passing. All examples rewritten to protocol traits with `protocol_impl!` macro. +**Status:** Fully implemented on `feat/approach-b`. 34 tests passing. All examples rewritten to protocol traits with `#[protocol]` and `#[bridge]` macros. Uses the same `Handler` and `#[actor]` macro as Approach A for #144. Solves #145 differently: instead of `Recipient`, actors communicate across boundaries via explicit user-defined trait objects. -**Key improvements over the initial WIP:** Type aliases (`BroadcasterRef`, `ParticipantRef`) replace raw `Arc`, conversion traits (`AsBroadcaster`, `AsParticipant`) replace `Arc::new(x.clone())`, `Response` enables async request-response on protocol traits without breaking object safety, `protocol_impl!` generates bridge impls from a compact declaration, and `Context::actor_ref()` lets actors obtain their own `ActorRef` for self-registration. +**Key improvements over the initial WIP:** `#[protocol]` attribute on trait definitions generates type aliases, conversion traits, and identity impls from a single annotation. `#[bridge]` attribute on `#[actor]` impls generates both the protocol bridge impl and the conversion trait impl — no separate macro needed. `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 @@ -454,56 +454,41 @@ This keeps protocol traits **object-safe** — `fn members(&self) -> Response -protocols.rs — shared contracts with type aliases, Response<T>, and conversion traits +protocols.rs#[protocol] generates type alias + conversion trait + identity impl ```rust use spawned_concurrency::error::ActorError; use spawned_concurrency::tasks::Response; -use std::sync::Arc; +use spawned_macros::protocol; -pub type BroadcasterRef = Arc; -pub type ParticipantRef = Arc; - -pub trait ChatBroadcaster: Send + Sync { - fn say(&self, from: String, text: String) -> Result<(), ActorError>; - fn add_member(&self, name: String, participant: ParticipantRef) -> Result<(), ActorError>; - fn members(&self) -> Response>; -} +// #[protocol] generates for each trait: +// pub type ParticipantRef = Arc; +// pub trait AsParticipant { fn as_participant(&self) -> ParticipantRef; } +// impl AsParticipant for ParticipantRef { ... } (identity, enables registry discovery) +#[protocol(ref = ParticipantRef, converter = AsParticipant)] pub trait ChatParticipant: Send + Sync { fn deliver(&self, from: String, text: String) -> Result<(), ActorError>; } -pub trait AsBroadcaster { - fn as_broadcaster(&self) -> BroadcasterRef; -} - -// Identity conversion — enables registry discovery: -// let room: BroadcasterRef = registry::whereis("general").unwrap(); -// charlie.join_room(room).unwrap(); // works because BroadcasterRef: AsBroadcaster -impl AsBroadcaster for BroadcasterRef { - fn as_broadcaster(&self) -> BroadcasterRef { - Arc::clone(self) - } -} - -pub trait AsParticipant { - fn as_participant(&self) -> ParticipantRef; +#[protocol(ref = BroadcasterRef, converter = AsBroadcaster)] +pub trait ChatBroadcaster: Send + Sync { + fn say(&self, from: String, text: String) -> Result<(), ActorError>; + fn add_member(&self, name: String, participant: ParticipantRef) -> Result<(), ActorError>; + fn members(&self) -> Response>; } ```
-room.rs — Messages → protocol_impl! → Conversion → Actor +room.rs — Messages → Actor with #[bridge] ```rust -use spawned_concurrency::protocol_impl; use spawned_concurrency::request_messages; use spawned_concurrency::send_messages; use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; use spawned_macros::actor; -use std::sync::Arc; -use crate::protocols::{AsBroadcaster, BroadcasterRef, ChatBroadcaster, ParticipantRef}; +use crate::protocols::{ChatBroadcaster, AsBroadcaster, BroadcasterRef, ParticipantRef}; // -- Internal messages -- @@ -516,22 +501,6 @@ request_messages! { Members -> Vec } -// -- Protocol bridge -- - -protocol_impl! { - ChatBroadcaster for ActorRef { - send fn say(from: String, text: String) => Say; - send fn add_member(name: String, participant: ParticipantRef) => Join; - request fn members() -> Vec => Members; - } -} - -impl AsBroadcaster for ActorRef { - fn as_broadcaster(&self) -> BroadcasterRef { - Arc::new(self.clone()) - } -} - // -- Actor -- pub struct ChatRoom { @@ -540,7 +509,15 @@ pub struct ChatRoom { impl Actor for ChatRoom {} +// #[bridge] generates: +// impl ChatBroadcaster for ActorRef { ... } (protocol bridge) +// impl AsBroadcaster for ActorRef { ... } (conversion trait) #[actor] +#[bridge(ChatBroadcaster as AsBroadcaster -> BroadcasterRef { + send fn say(from: String, text: String) => Say; + send fn add_member(name: String, participant: ParticipantRef) => Join; + request fn members() -> Vec => Members; +})] impl ChatRoom { pub fn new() -> Self { Self { members: Vec::new() } @@ -571,15 +548,13 @@ impl ChatRoom {
-user.rs — protocol_impl! bridge + UserActions trait + actor +user.rs#[bridge] for ChatParticipant + manual UserActions trait ```rust use spawned_concurrency::error::ActorError; -use spawned_concurrency::protocol_impl; use spawned_concurrency::send_messages; use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; use spawned_macros::actor; -use std::sync::Arc; use crate::protocols::{AsBroadcaster, AsParticipant, BroadcasterRef, ChatParticipant, ParticipantRef}; // -- Internal messages -- @@ -590,21 +565,7 @@ send_messages! { JoinRoom { room: BroadcasterRef } } -// -- Protocol bridge (ChatParticipant) -- - -protocol_impl! { - ChatParticipant for ActorRef { - send fn deliver(from: String, text: String) => Deliver; - } -} - -impl AsParticipant for ActorRef { - fn as_participant(&self) -> ParticipantRef { - Arc::new(self.clone()) - } -} - -// -- Caller API -- +// -- Caller API (manual — join_room takes impl AsBroadcaster) -- pub trait UserActions { fn say(&self, text: String) -> Result<(), ActorError>; @@ -631,6 +592,9 @@ pub struct User { impl Actor for User {} #[actor] +#[bridge(ChatParticipant as AsParticipant -> ParticipantRef { + send fn deliver(from: String, text: String) => Deliver; +})] impl User { pub fn new(name: String) -> Self { Self { name, room: None } @@ -689,15 +653,15 @@ charlie.join_room(discovered).unwrap(); |-----------|-----------| | **Readability** | `protocols.rs` is an excellent summary of what crosses the actor boundary. Type aliases (`BroadcasterRef`, `ParticipantRef`) eliminate raw `Arc` noise. Files read top-to-bottom: Messages → Bridge → Conversion → Actor. | | **API at a glance** | Protocol traits serve as the natural API contract for cross-actor boundaries. Looking at `ChatBroadcaster` tells you exactly what a room can do, with named methods and signatures — the strongest "at-a-glance" surface of all approaches. `UserActions` trait provides the direct caller API. | -| **Boilerplate** | Higher than Approach A per cross-actor boundary: protocol trait + `protocol_impl!` bridge + message structs + Handler impls. Mitigated by type aliases, conversion traits, and `protocol_impl!` macro. | +| **Boilerplate** | Higher than Approach A per cross-actor boundary: protocol trait + bridge + message structs + Handler impls. Mitigated by `#[protocol]` (generates scaffolding) and `#[bridge]` (generates bridge + conversion from `#[actor]` impl). | | **main.rs expressivity** | Identical body to A: `alice.join_room(room.clone())`, `room.members().await.unwrap()`, `alice.say(...)`. `join_room` accepts `impl AsBroadcaster` so callers pass `ActorRef` directly. | | **Request-response** | `Response` keeps protocol traits object-safe while supporting async request-response. Structural mirror of the Envelope pattern — no RPITIT, no `BoxFuture` boxing. | -| **Circular dep solution** | Actors hold `BroadcasterRef` / `ParticipantRef` instead of `ActorRef`. Each new cross-boundary message requires adding a method to the protocol trait + updating the `protocol_impl!` bridge. | +| **Circular dep solution** | Actors hold `BroadcasterRef` / `ParticipantRef` instead of `ActorRef`. Each new cross-boundary message requires adding a method to the protocol trait + updating the `#[bridge]` declaration. | | **Registry** | Register `BroadcasterRef` — one registration gives the discoverer the full protocol API (`say`, `add_member`, `members`). No concrete actor type needed. Identity `AsBroadcaster` impl on `BroadcasterRef` means discovered refs pass directly to `join_room`. | -| **Macro compatibility** | `#[actor]` for Handler impls, `protocol_impl!` for bridge impls. Direct caller APIs use manual trait impls when generic params are needed (e.g., `join_room(impl AsBroadcaster)`). | +| **Macro compatibility** | `#[actor]` + `#[bridge]` generates both Handler impls and protocol bridge impls in one block. Direct caller APIs use manual trait impls when generic params are needed (e.g., `join_room(impl AsBroadcaster)`). | | **Testability** | Best of all approaches — you can mock `ChatBroadcaster` or `ChatParticipant` directly in unit tests without running an actor system. | -**Key insight:** Protocol traits define contracts at the actor level (like Erlang behaviours) rather than the message level (like Actix's `Recipient`). The duplication cost (protocol method mirrors message struct) is real but buys three things: (1) testability via trait mocking, (2) a natural "API at a glance" surface, and (3) actor-level granularity for registry and discovery. With `Response`, type aliases, conversion traits, and `protocol_impl!`, B's main.rs body is identical to A's. +**Key insight:** Protocol traits define contracts at the actor level (like Erlang behaviours) rather than the message level (like Actix's `Recipient`). The duplication cost (protocol method mirrors message struct) is real but buys three things: (1) testability via trait mocking, (2) a natural "API at a glance" surface, and (3) actor-level granularity for registry and discovery. With `#[protocol]`, `#[bridge]`, `Response`, and `Context::actor_ref()`, B's main.rs body is identical to A's. **Scaling trade-off:** In a system with N actor types and M cross-boundary message types, Approach A needs M message structs. Approach B needs M message structs + P protocol traits + P bridge impls, where P grows with distinct actor-to-actor interaction patterns. The extra cost scales with *interaction patterns*, not messages — and each protocol trait is a natural documentation + testing boundary. @@ -1104,26 +1068,40 @@ In A, the discoverer must import `ChatRoom` and `ChatRoomApi` to use the retriev 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_impl!`) +### Approach B: Protocol Traits — DONE (`#[protocol]` + `#[bridge]`) + +Two proc macro attributes eliminate all protocol scaffolding: -B now uses `protocol_impl!` to generate bridge impls from a compact declaration. What was ~12 lines of manual bridge boilerplate per actor is now ~5 lines: +**`#[protocol]`** on the trait definition generates the type alias, conversion trait, and identity impl: ```rust -// protocol_impl! generates the full impl block -protocol_impl! { - ChatBroadcaster for ActorRef { - send fn say(from: String, text: String) => Say; - send fn add_member(name: String, participant: ParticipantRef) => Join; - request fn members() -> Vec => Members; - } +#[protocol(ref = BroadcasterRef, converter = AsBroadcaster)] +pub trait ChatBroadcaster: Send + Sync { + fn say(&self, from: String, text: String) -> Result<(), ActorError>; + fn add_member(&self, name: String, participant: ParticipantRef) -> Result<(), ActorError>; + fn members(&self) -> Response>; } +// Generates: type BroadcasterRef, trait AsBroadcaster, impl AsBroadcaster for BroadcasterRef +``` + +**`#[bridge]`** on the `#[actor]` impl generates both the protocol bridge and conversion trait impl: + +```rust +#[actor] +#[bridge(ChatBroadcaster as AsBroadcaster -> BroadcasterRef { + send fn say(from: String, text: String) => Say; + send fn add_member(name: String, participant: ParticipantRef) => Join; + request fn members() -> Vec => Members; +})] +impl ChatRoom { ... } +// Generates: impl ChatBroadcaster for ActorRef, impl AsBroadcaster for ActorRef ``` Each `send fn` generates `fn method(&self, ...) -> Result<(), ActorError> { self.send(Msg { ... }) }`. Each `request fn` generates `fn method(&self, ...) -> Response { Response::from(self.request_raw(Msg)) }`. The protocol trait itself remains user-defined — it IS the contract, so it should stay hand-written. -Conversion traits (`AsBroadcaster`, `AsParticipant`) are still manual (~4 lines each) but are structurally trivial. For direct caller APIs where generic params are needed (e.g., `join_room(impl AsBroadcaster)`), manual trait impls are used instead of `protocol_impl!`. +For direct caller APIs where generic params are needed (e.g., `join_room(impl AsBroadcaster)`), manual trait impls are used instead of `#[bridge]`. -**Impact:** Combined with `#[actor]`, `protocol_impl!`, and conversion traits, Approach B's total code is competitive with Approach A while retaining its testability and Erlang-like actor-level granularity advantages. +**Impact:** Combined with `#[actor]`, `#[protocol]`, and `#[bridge]`, Approach B's total code is competitive with Approach A while retaining its testability and Erlang-like actor-level granularity advantages. No separate `protocol_impl!` macro needed — everything lives in the `#[actor]` impl block. ### Approach C: Typed Wrappers — NO @@ -1131,7 +1109,7 @@ The fundamental problem is the dual-channel architecture, not boilerplate. The ` ### 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!`/`protocol_impl!`, `send_messages!`, and `#[actor]` do separately. +This approach IS a macro. The `#[derive(ActorMessages)]` would generate message structs, `Message` impls, API wrappers, and `Handler` delegation — subsuming what `actor_api!`/`#[bridge]`, `send_messages!`, and `#[actor]` do separately. ### Approach E: AnyActorRef — NO @@ -1158,13 +1136,13 @@ And `spawned::send(pid, Msg { ... })` could get ergonomic wrappers similar to `a | Approach | Macro potential | What it would eliminate | Worth implementing? | |----------|----------------|----------------------|---------------------| -| **B: Protocol Traits** | High | Bridge impls + conversion traits | Done — `protocol_impl!` macro | +| **B: Protocol Traits** | High | Bridge impls + conversion traits + scaffolding | Done — `#[protocol]` + `#[bridge]` | | **C: Typed Wrappers** | None | N/A — structural problem | No | | **D: Derive Macro** | N/A | Already a macro | N/A | | **E: AnyActorRef** | None | Would add false safety | No | | **F: PID** | Low-Medium | Registration ceremony | Maybe — ergonomics only | -**Takeaway:** Approach B now uses `protocol_impl!` for bridge impls, achieving competitive code volume with Approach A. With `Response`, type aliases, conversion traits, and `protocol_impl!`, main.rs bodies are identical between A and B — the approaches differ only in internal wiring and dependency structure. +**Takeaway:** Approach B now uses `#[protocol]` and `#[bridge]` to eliminate all scaffolding, achieving competitive code volume with Approach A. With `Response`, `#[protocol]`, `#[bridge]`, and `Context::actor_ref()`, main.rs bodies are identical between A and B — the approaches differ only in internal wiring and dependency structure. --- @@ -1178,7 +1156,7 @@ And `spawned::send(pid, Msg { ... })` could get ergonomic wrappers similar to `a | **Breaking** | Yes | Yes | No | No | Yes | Yes | | **#144 type safety** | Full | Full | Hidden `unreachable!` | Hidden `unreachable!` | Full | Full | | **#145 type safety** | Compile-time | Compile-time | Compile-time | Compile-time | Runtime only | Runtime only | -| **Macro support** | `#[actor]` + `actor_api!` + message macros | `#[actor]` + `protocol_impl!` + message macros | N/A (enum-based) | Derive macro | `#[actor]` | `#[actor]` | +| **Macro support** | `#[actor]` + `actor_api!` + message macros | `#[actor]` + `#[bridge]` + `#[protocol]` + message macros | 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 | @@ -1191,7 +1169,7 @@ And `spawned::send(pid, Msg { ... })` could get ergonomic wrappers similar to `a | **Handler readability** | Clear: one `impl Handler` or `#[send_handler]` per message | Same as A for handlers. Files read Messages → Bridge → Conversion → Actor. Type aliases reduce noise. | Noisy: enum `match` arms + wrapper fns | Opaque: generated from enum annotations | Same as A, but callers use `Box::new` | Same as A, but callers use global `send(pid, msg)` | | **API at a glance** | `actor_api!` block or scan Handler impls | Protocol traits (best) + `UserActions` trait for direct caller API | Typed wrapper functions | Annotated enum (good summary) | Nothing — `AnyActorRef` is opaque | Nothing — `Pid` is opaque | | **main.rs expressivity** | `alice.say("Hi")` with `actor_api!`; `alice.send(SayToRoom{...})` without | Identical body to A: `alice.join_room(room.clone())`, `room.members().await`, `alice.say(...)` | `ChatRoom::say(&room, ...)` assoc fn | Generated methods: `room.say(...)` | `room.send_any(Box::new(...))` | `spawned::send(pid, ...)` + registration | -| **Boilerplate per message** | Struct + `actor_api!` line | Struct + protocol method + `protocol_impl!` line | Enum variant + wrapper + match arm | Enum variant + annotation | Struct | Struct + registration | +| **Boilerplate per message** | Struct + `actor_api!` line | Struct + protocol method + `#[bridge]` line | Enum variant + wrapper + match arm | Enum variant + annotation | Struct | Struct + registration | | **Debugging** | Standard Rust — all code visible | Standard Rust — bridge impls visible | Standard Rust | Requires `cargo expand` | Runtime errors (downcast failures) | Runtime errors (unregistered types) | | **Testability** | Good (mock via Recipient) | Best (mock protocol trait) | Good | Good | Fair (Any-based) | Hard (global state) | @@ -1204,7 +1182,7 @@ And `spawned::send(pid, Msg { ... })` could get ergonomic wrappers similar to `a | **Clustering readiness** | Needs `RemoteRecipient` | Needs remote bridge impls | Hard | Hard | Possible (serialize Any) | Excellent (Pid is location-transparent) | | **Learning curve** | Moderate (Handler pattern) | Moderate + bridge pattern | Low (old API preserved) | Low (write enum, macro does rest) | Low concept, high debugging | Low concept, high registration overhead | | **Erlang alignment** | Actix-like | Actor-level granularity (Erlang behaviours) | Actix-like | Actix-like | Erlang-ish | Most Erlang | -| **Macro improvement potential** | Already done (`actor_api!`) | Done (`protocol_impl!`) | None (structural) | N/A (is a macro) | None (false safety) | Low (ergonomics only) | +| **Macro improvement potential** | Already done (`actor_api!`) | Done (`#[protocol]` + `#[bridge]`) | None (structural) | N/A (is a macro) | None (false safety) | Low (ergonomics only) | --- @@ -1221,7 +1199,7 @@ And `spawned::send(pid, Msg { ... })` could get ergonomic wrappers similar to `a **Approach B (Protocol Traits)** is a strong alternative with identical caller ergonomics: - main.rs body is identical to A: `alice.join_room(room.clone())`, `room.members().await.unwrap()`, `alice.say(...)` -- `protocol_impl!` macro generates bridge impls from compact declarations, competitive boilerplate with `actor_api!` +- `#[bridge]` attribute on `#[actor]` generates protocol bridge + conversion from a compact declaration — no separate macro needed - `Response` keeps protocol traits object-safe while supporting async request-response — structural mirror of the Envelope pattern (no RPITIT, no `BoxFuture`) - `Context::actor_ref()` lets actors self-register with protocol traits (e.g., `ctx.actor_ref().as_participant()`) - Protocol traits define contracts at the actor level (like Erlang behaviours), giving actor-level granularity for registry and discovery @@ -1242,7 +1220,7 @@ And `spawned::send(pid, Msg { ... })` could get ergonomic wrappers similar to `a |--------|------|-------------| | `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_impl! macro + Context::actor_ref() (all examples rewritten) | +| [`feat/approach-b`](https://github.com/lambdaclass/spawned/tree/feat/approach-b) | main | **Approach B** — Protocol traits + `#[protocol]`/`#[bridge]` 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)) | From e999e2eb57b3cb39588e29ee819c3eb293e451a3 Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Mon, 23 Feb 2026 11:56:13 -0300 Subject: [PATCH 10/20] docs: simplify UserActions with #[bridge] short form, use as_broadcaster() in main.rs --- docs/API_ALTERNATIVES_SUMMARY.md | 42 ++++++++++++++------------------ 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/docs/API_ALTERNATIVES_SUMMARY.md b/docs/API_ALTERNATIVES_SUMMARY.md index 441d0eb..6f8ebdc 100644 --- a/docs/API_ALTERNATIVES_SUMMARY.md +++ b/docs/API_ALTERNATIVES_SUMMARY.md @@ -548,14 +548,14 @@ impl ChatRoom {
-user.rs#[bridge] for ChatParticipant + manual UserActions trait +user.rs#[bridge] for ChatParticipant + UserActions ```rust use spawned_concurrency::error::ActorError; use spawned_concurrency::send_messages; use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; use spawned_macros::actor; -use crate::protocols::{AsBroadcaster, AsParticipant, BroadcasterRef, ChatParticipant, ParticipantRef}; +use crate::protocols::{AsParticipant, BroadcasterRef, ChatParticipant, ParticipantRef}; // -- Internal messages -- @@ -565,21 +565,11 @@ send_messages! { JoinRoom { room: BroadcasterRef } } -// -- Caller API (manual — join_room takes impl AsBroadcaster) -- +// -- Caller API -- pub trait UserActions { fn say(&self, text: String) -> Result<(), ActorError>; - fn join_room(&self, room: impl AsBroadcaster) -> Result<(), ActorError>; -} - -impl UserActions for ActorRef { - fn say(&self, text: String) -> Result<(), ActorError> { - self.send(SayToRoom { text }) - } - - fn join_room(&self, room: impl AsBroadcaster) -> Result<(), ActorError> { - self.send(JoinRoom { room: room.as_broadcaster() }) - } + fn join_room(&self, room: BroadcasterRef) -> Result<(), ActorError>; } // -- Actor -- @@ -595,6 +585,10 @@ impl Actor for User {} #[bridge(ChatParticipant as AsParticipant -> ParticipantRef { send fn deliver(from: String, text: String) => Deliver; })] +#[bridge(UserActions { + send fn say(text: String) => SayToRoom; + send fn join_room(room: BroadcasterRef) => JoinRoom; +})] impl User { pub fn new(name: String) -> Self { Self { name, room: None } @@ -622,7 +616,7 @@ impl User {
-main.rs — identical body to Approach A, protocol-level discovery +main.rs — protocol-level wiring and discovery ```rust let room = ChatRoom::new().start(); @@ -632,8 +626,8 @@ let bob = User::new("Bob".into()).start(); // Register the room's protocol — not the concrete type registry::register("general", room.as_broadcaster()).unwrap(); -alice.join_room(room.clone()).unwrap(); -bob.join_room(room.clone()).unwrap(); +alice.join_room(room.as_broadcaster()).unwrap(); +bob.join_room(room.as_broadcaster()).unwrap(); let members = room.members().await.unwrap(); @@ -654,14 +648,14 @@ charlie.join_room(discovered).unwrap(); | **Readability** | `protocols.rs` is an excellent summary of what crosses the actor boundary. Type aliases (`BroadcasterRef`, `ParticipantRef`) eliminate raw `Arc` noise. Files read top-to-bottom: Messages → Bridge → Conversion → Actor. | | **API at a glance** | Protocol traits serve as the natural API contract for cross-actor boundaries. Looking at `ChatBroadcaster` tells you exactly what a room can do, with named methods and signatures — the strongest "at-a-glance" surface of all approaches. `UserActions` trait provides the direct caller API. | | **Boilerplate** | Higher than Approach A per cross-actor boundary: protocol trait + bridge + message structs + Handler impls. Mitigated by `#[protocol]` (generates scaffolding) and `#[bridge]` (generates bridge + conversion from `#[actor]` impl). | -| **main.rs expressivity** | Identical body to A: `alice.join_room(room.clone())`, `room.members().await.unwrap()`, `alice.say(...)`. `join_room` accepts `impl AsBroadcaster` so callers pass `ActorRef` directly. | +| **main.rs expressivity** | Similar simplicity to A: `alice.join_room(room.as_broadcaster())`, `room.members().await.unwrap()`, `alice.say(...)`. `join_room` takes `BroadcasterRef`, making the protocol boundary explicit at the call site. | | **Request-response** | `Response` keeps protocol traits object-safe while supporting async request-response. Structural mirror of the Envelope pattern — no RPITIT, no `BoxFuture` boxing. | | **Circular dep solution** | Actors hold `BroadcasterRef` / `ParticipantRef` instead of `ActorRef`. Each new cross-boundary message requires adding a method to the protocol trait + updating the `#[bridge]` declaration. | | **Registry** | Register `BroadcasterRef` — one registration gives the discoverer the full protocol API (`say`, `add_member`, `members`). No concrete actor type needed. Identity `AsBroadcaster` impl on `BroadcasterRef` means discovered refs pass directly to `join_room`. | -| **Macro compatibility** | `#[actor]` + `#[bridge]` generates both Handler impls and protocol bridge impls in one block. Direct caller APIs use manual trait impls when generic params are needed (e.g., `join_room(impl AsBroadcaster)`). | +| **Macro compatibility** | `#[actor]` + `#[bridge]` generates both Handler impls and protocol bridge impls in one block. `#[bridge]` works for both cross-actor protocols (with conversion trait) and direct caller APIs (without). | | **Testability** | Best of all approaches — you can mock `ChatBroadcaster` or `ChatParticipant` directly in unit tests without running an actor system. | -**Key insight:** Protocol traits define contracts at the actor level (like Erlang behaviours) rather than the message level (like Actix's `Recipient`). The duplication cost (protocol method mirrors message struct) is real but buys three things: (1) testability via trait mocking, (2) a natural "API at a glance" surface, and (3) actor-level granularity for registry and discovery. With `#[protocol]`, `#[bridge]`, `Response`, and `Context::actor_ref()`, B's main.rs body is identical to A's. +**Key insight:** Protocol traits define contracts at the actor level (like Erlang behaviours) rather than the message level (like Actix's `Recipient`). The duplication cost (protocol method mirrors message struct) is real but buys three things: (1) testability via trait mocking, (2) a natural "API at a glance" surface, and (3) actor-level granularity for registry and discovery. With `#[protocol]`, `#[bridge]`, `Response`, and `Context::actor_ref()`, B's main.rs is similarly simple to A's — the only difference is `room.as_broadcaster()` instead of `room.clone()`, making the protocol boundary explicit. **Scaling trade-off:** In a system with N actor types and M cross-boundary message types, Approach A needs M message structs. Approach B needs M message structs + P protocol traits + P bridge impls, where P grows with distinct actor-to-actor interaction patterns. The extra cost scales with *interaction patterns*, not messages — and each protocol trait is a natural documentation + testing boundary. @@ -1099,7 +1093,7 @@ impl ChatRoom { ... } Each `send fn` generates `fn method(&self, ...) -> Result<(), ActorError> { self.send(Msg { ... }) }`. Each `request fn` generates `fn method(&self, ...) -> Response { Response::from(self.request_raw(Msg)) }`. The protocol trait itself remains user-defined — it IS the contract, so it should stay hand-written. -For direct caller APIs where generic params are needed (e.g., `join_room(impl AsBroadcaster)`), manual trait impls are used instead of `#[bridge]`. +`#[bridge]` also works for direct caller APIs (like `UserActions`) — use the short form without conversion: `#[bridge(UserActions { ... })]`. **Impact:** Combined with `#[actor]`, `#[protocol]`, and `#[bridge]`, Approach B's total code is competitive with Approach A while retaining its testability and Erlang-like actor-level granularity advantages. No separate `protocol_impl!` macro needed — everything lives in the `#[actor]` impl block. @@ -1168,7 +1162,7 @@ And `spawned::send(pid, Msg { ... })` could get ergonomic wrappers similar to `a |-----------|-------------|-------------------|-------------------|-----------------|---------------|--------| | **Handler readability** | Clear: one `impl Handler` or `#[send_handler]` per message | Same as A for handlers. Files read Messages → Bridge → Conversion → Actor. Type aliases reduce noise. | Noisy: enum `match` arms + wrapper fns | Opaque: generated from enum annotations | Same as A, but callers use `Box::new` | Same as A, but callers use global `send(pid, msg)` | | **API at a glance** | `actor_api!` block or scan Handler impls | Protocol traits (best) + `UserActions` trait for direct caller API | Typed wrapper functions | Annotated enum (good summary) | Nothing — `AnyActorRef` is opaque | Nothing — `Pid` is opaque | -| **main.rs expressivity** | `alice.say("Hi")` with `actor_api!`; `alice.send(SayToRoom{...})` without | Identical body to A: `alice.join_room(room.clone())`, `room.members().await`, `alice.say(...)` | `ChatRoom::say(&room, ...)` assoc fn | Generated methods: `room.say(...)` | `room.send_any(Box::new(...))` | `spawned::send(pid, ...)` + registration | +| **main.rs expressivity** | `alice.say("Hi")` with `actor_api!`; `alice.send(SayToRoom{...})` without | Similar to A: `alice.join_room(room.as_broadcaster())`, `room.members().await`, `alice.say(...)` | `ChatRoom::say(&room, ...)` assoc fn | Generated methods: `room.say(...)` | `room.send_any(Box::new(...))` | `spawned::send(pid, ...)` + registration | | **Boilerplate per message** | Struct + `actor_api!` line | Struct + protocol method + `#[bridge]` line | Enum variant + wrapper + match arm | Enum variant + annotation | Struct | Struct + registration | | **Debugging** | Standard Rust — all code visible | Standard Rust — bridge impls visible | Standard Rust | Requires `cargo expand` | Runtime errors (downcast failures) | Runtime errors (unregistered types) | | **Testability** | Good (mock via Recipient) | Best (mock protocol trait) | Good | Good | Fair (Any-based) | Hard (global state) | @@ -1197,8 +1191,8 @@ And `spawned::send(pid, Msg { ... })` could get ergonomic wrappers similar to `a - 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 identical caller ergonomics: -- main.rs body is identical to A: `alice.join_room(room.clone())`, `room.members().await.unwrap()`, `alice.say(...)` +**Approach B (Protocol Traits)** is a strong alternative with similar caller simplicity: +- main.rs reads naturally: `alice.join_room(room.as_broadcaster())`, `room.members().await.unwrap()`, `alice.say(...)` — the `.as_broadcaster()` makes the protocol boundary explicit - `#[bridge]` attribute on `#[actor]` generates protocol bridge + conversion from a compact declaration — no separate macro needed - `Response` keeps protocol traits object-safe while supporting async request-response — structural mirror of the Envelope pattern (no RPITIT, no `BoxFuture`) - `Context::actor_ref()` lets actors self-register with protocol traits (e.g., `ctx.actor_ref().as_participant()`) From 87b1398523a344df8e5e0f1e53d48cf2523dc271 Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Mon, 23 Feb 2026 12:33:20 -0300 Subject: [PATCH 11/20] docs: fix stale "identical" wording in macro improvement takeaway --- docs/API_ALTERNATIVES_SUMMARY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/API_ALTERNATIVES_SUMMARY.md b/docs/API_ALTERNATIVES_SUMMARY.md index 6f8ebdc..5fa70c5 100644 --- a/docs/API_ALTERNATIVES_SUMMARY.md +++ b/docs/API_ALTERNATIVES_SUMMARY.md @@ -1136,7 +1136,7 @@ And `spawned::send(pid, Msg { ... })` could get ergonomic wrappers similar to `a | **E: AnyActorRef** | None | Would add false safety | No | | **F: PID** | Low-Medium | Registration ceremony | Maybe — ergonomics only | -**Takeaway:** Approach B now uses `#[protocol]` and `#[bridge]` to eliminate all scaffolding, achieving competitive code volume with Approach A. With `Response`, `#[protocol]`, `#[bridge]`, and `Context::actor_ref()`, main.rs bodies are identical between A and B — the approaches differ only in internal wiring and dependency structure. +**Takeaway:** Approach B now uses `#[protocol]` and `#[bridge]` to eliminate all scaffolding, achieving competitive code volume with Approach A. With `Response`, `#[protocol]`, `#[bridge]`, and `Context::actor_ref()`, main.rs bodies are similarly simple — the only visible difference is `room.as_broadcaster()` vs `room.clone()`, making B's protocol boundary explicit at the call site. --- From f8d4a787bba0d6e816c2e916afb6933002d2b5df Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Mon, 23 Feb 2026 16:50:50 -0300 Subject: [PATCH 12/20] docs: redesign Approach B as one-protocol-per-actor (gen_server model) --- docs/API_ALTERNATIVES_SUMMARY.md | 252 +++++++++++++------------------ 1 file changed, 107 insertions(+), 145 deletions(-) diff --git a/docs/API_ALTERNATIVES_SUMMARY.md b/docs/API_ALTERNATIVES_SUMMARY.md index 5fa70c5..52ea5de 100644 --- a/docs/API_ALTERNATIVES_SUMMARY.md +++ b/docs/API_ALTERNATIVES_SUMMARY.md @@ -8,7 +8,7 @@ This document summarizes the different approaches explored for solving three cri - [The Chat Room Example](#the-chat-room-example) - [Baseline: The Old API](#baseline-the-old-api-whats-on-main-today) - [Approach A: Handler\ + Recipient\](#approach-a-handlerm--recipientm-actix-style) -- [Approach B: Protocol Traits](#approach-b-protocol-traits-user-defined-contracts) +- [Approach 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) @@ -59,7 +59,7 @@ The registry is a global name store (`HashMap>`) that maps let room = registry::whereis::("general").unwrap(); // A: ActorRef — requires knowing the concrete actor type -// B: BroadcasterRef (Arc) — requires only the protocol +// 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. @@ -419,15 +419,15 @@ charlie.join_room(discovered).unwrap(); --- -## Approach B: Protocol Traits (user-defined contracts) +## Approach B: Protocol Traits (one protocol per actor) **Branch:** [`feat/approach-b`](https://github.com/lambdaclass/spawned/tree/feat/approach-b) (`#[protocol]` + `#[bridge]` macros + Context::actor_ref()) **Status:** Fully implemented on `feat/approach-b`. 34 tests passing. All examples rewritten to protocol traits with `#[protocol]` and `#[bridge]` macros. -Uses the same `Handler` and `#[actor]` macro as Approach A for #144. Solves #145 differently: instead of `Recipient`, actors communicate across boundaries via explicit user-defined trait objects. +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, one bridge per actor. -**Key improvements over the initial WIP:** `#[protocol]` attribute on trait definitions generates type aliases, conversion traits, and identity impls from a single annotation. `#[bridge]` attribute on `#[actor]` impls generates both the protocol bridge impl and the conversion trait impl — no separate macro needed. `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. +**Key design:** `#[protocol]` on a trait definition generates everything — message structs from method signatures, type alias (`Arc`), conversion trait, and a bridge helper macro. `#[bridge(TraitName)]` on an `#[actor]` impl is a one-liner that connects the actor to its protocol. Names are derived by convention (`RoomProtocol` → `RoomRef`, `AsRoom`), so no explicit parameters are needed. `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 @@ -454,7 +454,7 @@ This keeps protocol traits **object-safe** — `fn members(&self) -> Response -protocols.rs#[protocol] generates type alias + conversion trait + identity impl +protocols.rs — one protocol per actor, #[protocol] generates everything ```rust use spawned_concurrency::error::ActorError; @@ -462,83 +462,69 @@ use spawned_concurrency::tasks::Response; use spawned_macros::protocol; // #[protocol] generates for each trait: -// pub type ParticipantRef = Arc; -// pub trait AsParticipant { fn as_participant(&self) -> ParticipantRef; } -// impl AsParticipant for ParticipantRef { ... } (identity, enables registry discovery) - -#[protocol(ref = ParticipantRef, converter = AsParticipant)] -pub trait ChatParticipant: Send + Sync { - fn deliver(&self, from: String, text: String) -> Result<(), ActorError>; -} - -#[protocol(ref = BroadcasterRef, converter = AsBroadcaster)] -pub trait ChatBroadcaster: Send + Sync { +// Type alias: pub type RoomRef = Arc; +// Conversion trait: pub trait AsRoom { fn as_room(&self) -> RoomRef; } +// Identity impl: impl AsRoom for RoomRef { ... } +// Message structs: room_protocol::{Say, AddMember, Members} with Message impls +// Bridge helper: hidden macro for #[bridge] to use +// +// Names derived by convention: RoomProtocol → RoomRef, AsRoom +// Override with: #[protocol(ref = MyRef, converter = ToMyRef)] + +#[protocol] +pub trait RoomProtocol: Send + Sync { fn say(&self, from: String, text: String) -> Result<(), ActorError>; - fn add_member(&self, name: String, participant: ParticipantRef) -> Result<(), ActorError>; + fn add_member(&self, name: String, user: UserRef) -> Result<(), ActorError>; fn members(&self) -> Response>; } + +#[protocol] +pub trait UserProtocol: Send + Sync { + fn deliver(&self, from: String, text: String) -> Result<(), ActorError>; + fn speak(&self, text: String) -> Result<(), ActorError>; + fn join_room(&self, room: RoomRef) -> Result<(), ActorError>; +} ```
-room.rs — Messages → Actor with #[bridge] +room.rs#[bridge(RoomProtocol)], message structs from protocol ```rust -use spawned_concurrency::request_messages; -use spawned_concurrency::send_messages; -use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; +use spawned_concurrency::tasks::{Actor, Context}; use spawned_macros::actor; -use crate::protocols::{ChatBroadcaster, AsBroadcaster, BroadcasterRef, ParticipantRef}; - -// -- Internal messages -- - -send_messages! { - Say { from: String, text: String }; - Join { name: String, participant: ParticipantRef } -} - -request_messages! { - Members -> Vec -} - -// -- Actor -- +use crate::protocols::{RoomProtocol, UserRef}; +use crate::protocols::room_protocol::{Say, AddMember, Members}; pub struct ChatRoom { - members: Vec<(String, ParticipantRef)>, + members: Vec<(String, UserRef)>, } impl Actor for ChatRoom {} -// #[bridge] generates: -// impl ChatBroadcaster for ActorRef { ... } (protocol bridge) -// impl AsBroadcaster for ActorRef { ... } (conversion trait) #[actor] -#[bridge(ChatBroadcaster as AsBroadcaster -> BroadcasterRef { - send fn say(from: String, text: String) => Say; - send fn add_member(name: String, participant: ParticipantRef) => Join; - request fn members() -> Vec => Members; -})] +#[bridge(RoomProtocol)] impl ChatRoom { pub fn new() -> Self { Self { members: Vec::new() } } - #[send_handler] - async fn handle_join(&mut self, msg: Join, _ctx: &Context) { - tracing::info!("[room] {} joined", msg.name); - self.members.push((msg.name, msg.participant)); - } - #[send_handler] async fn handle_say(&mut self, msg: Say, _ctx: &Context) { tracing::info!("[room] {} says: {}", msg.from, msg.text); - for (name, participant) in &self.members { + for (name, user) in &self.members { if *name != msg.from { - let _ = participant.deliver(msg.from.clone(), msg.text.clone()); + let _ = user.deliver(msg.from.clone(), msg.text.clone()); } } } + #[send_handler] + async fn handle_add_member(&mut self, msg: AddMember, _ctx: &Context) { + tracing::info!("[room] {} joined", msg.name); + self.members.push((msg.name, msg.user)); + } + #[request_handler] async fn handle_members(&mut self, _msg: Members, _ctx: &Context) -> Vec { self.members.iter().map(|(name, _)| name.clone()).collect() @@ -548,47 +534,23 @@ impl ChatRoom {
-user.rs#[bridge] for ChatParticipant + UserActions +user.rs#[bridge(UserProtocol)], symmetric with room.rs ```rust -use spawned_concurrency::error::ActorError; -use spawned_concurrency::send_messages; -use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; +use spawned_concurrency::tasks::{Actor, Context}; use spawned_macros::actor; -use crate::protocols::{AsParticipant, BroadcasterRef, ChatParticipant, ParticipantRef}; - -// -- Internal messages -- - -send_messages! { - Deliver { from: String, text: String }; - SayToRoom { text: String }; - JoinRoom { room: BroadcasterRef } -} - -// -- Caller API -- - -pub trait UserActions { - fn say(&self, text: String) -> Result<(), ActorError>; - fn join_room(&self, room: BroadcasterRef) -> Result<(), ActorError>; -} - -// -- Actor -- +use crate::protocols::{UserProtocol, RoomRef}; +use crate::protocols::user_protocol::{Deliver, Speak, JoinRoom}; pub struct User { name: String, - room: Option, + room: Option, } impl Actor for User {} #[actor] -#[bridge(ChatParticipant as AsParticipant -> ParticipantRef { - send fn deliver(from: String, text: String) => Deliver; -})] -#[bridge(UserActions { - send fn say(text: String) => SayToRoom; - send fn join_room(room: BroadcasterRef) => JoinRoom; -})] +#[bridge(UserProtocol)] impl User { pub fn new(name: String) -> Self { Self { name, room: None } @@ -596,12 +558,12 @@ impl User { #[send_handler] async fn handle_join_room(&mut self, msg: JoinRoom, ctx: &Context) { - let _ = msg.room.add_member(self.name.clone(), ctx.actor_ref().as_participant()); + let _ = msg.room.add_member(self.name.clone(), ctx.actor_ref().as_user()); self.room = Some(msg.room); } #[send_handler] - async fn handle_say_to_room(&mut self, msg: SayToRoom, _ctx: &Context) { + async fn handle_speak(&mut self, msg: Speak, _ctx: &Context) { if let Some(ref room) = self.room { let _ = room.say(self.name.clone(), msg.text); } @@ -616,7 +578,7 @@ impl User {
-main.rs — protocol-level wiring and discovery +main.rs — protocol refs for wiring and discovery ```rust let room = ChatRoom::new().start(); @@ -624,19 +586,19 @@ 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.as_broadcaster()).unwrap(); +registry::register("general", room.as_room()).unwrap(); -alice.join_room(room.as_broadcaster()).unwrap(); -bob.join_room(room.as_broadcaster()).unwrap(); +alice.join_room(room.as_room()).unwrap(); +bob.join_room(room.as_room()).unwrap(); let members = room.members().await.unwrap(); -alice.say("Hello everyone!".into()).unwrap(); -bob.say("Hey Alice!".into()).unwrap(); +alice.speak("Hello everyone!".into()).unwrap(); +bob.speak("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: BroadcasterRef = registry::whereis("general").unwrap(); +let discovered: RoomRef = registry::whereis("general").unwrap(); charlie.join_room(discovered).unwrap(); ```
@@ -645,21 +607,21 @@ charlie.join_room(discovered).unwrap(); | Dimension | Assessment | |-----------|-----------| -| **Readability** | `protocols.rs` is an excellent summary of what crosses the actor boundary. Type aliases (`BroadcasterRef`, `ParticipantRef`) eliminate raw `Arc` noise. Files read top-to-bottom: Messages → Bridge → Conversion → Actor. | -| **API at a glance** | Protocol traits serve as the natural API contract for cross-actor boundaries. Looking at `ChatBroadcaster` tells you exactly what a room can do, with named methods and signatures — the strongest "at-a-glance" surface of all approaches. `UserActions` trait provides the direct caller API. | -| **Boilerplate** | Higher than Approach A per cross-actor boundary: protocol trait + bridge + message structs + Handler impls. Mitigated by `#[protocol]` (generates scaffolding) and `#[bridge]` (generates bridge + conversion from `#[actor]` impl). | -| **main.rs expressivity** | Similar simplicity to A: `alice.join_room(room.as_broadcaster())`, `room.members().await.unwrap()`, `alice.say(...)`. `join_room` takes `BroadcasterRef`, making the protocol boundary explicit at the call site. | +| **Readability** | `protocols.rs` is a complete index of every actor's public API. Each protocol reads like an Erlang module's export list. Actor files are pure implementation: imports → struct → `#[bridge]` → handlers. | +| **API at a glance** | Protocol traits ARE the API. Looking at `RoomProtocol` tells you exactly what a room can do; `UserProtocol` tells you what a user can do. No separate `actor_api!` or `UserActions` — the protocol is the single source of truth. | +| **Boilerplate** | Competitive with Approach A: protocol method + handler per message. No `send_messages!`/`request_messages!` needed — `#[protocol]` generates message structs. No method listing in `#[bridge]` — it's a one-liner. | +| **main.rs expressivity** | `alice.speak("Hello")`, `room.members().await`, `alice.join_room(room.as_room())`. Protocol methods are called directly on `ActorRef` (via bridge impl). The `.as_room()` conversion makes the protocol boundary explicit. | | **Request-response** | `Response` keeps protocol traits object-safe while supporting async request-response. Structural mirror of the Envelope pattern — no RPITIT, no `BoxFuture` boxing. | -| **Circular dep solution** | Actors hold `BroadcasterRef` / `ParticipantRef` instead of `ActorRef`. Each new cross-boundary message requires adding a method to the protocol trait + updating the `#[bridge]` declaration. | -| **Registry** | Register `BroadcasterRef` — one registration gives the discoverer the full protocol API (`say`, `add_member`, `members`). No concrete actor type needed. Identity `AsBroadcaster` impl on `BroadcasterRef` means discovered refs pass directly to `join_room`. | -| **Macro compatibility** | `#[actor]` + `#[bridge]` generates both Handler impls and protocol bridge impls in one block. `#[bridge]` works for both cross-actor protocols (with conversion trait) and direct caller APIs (without). | -| **Testability** | Best of all approaches — you can mock `ChatBroadcaster` or `ChatParticipant` directly in unit tests without running an actor system. | +| **Circular dep solution** | Actors hold `RoomRef` / `UserRef` — protocol-level refs to each other's full API. No `ActorRef`, no bidirectional module imports. Both depend only on `protocols.rs`. | +| **Registry** | Register `RoomRef` — one registration gives the discoverer the full actor API (`say`, `add_member`, `members`). No concrete actor type needed. Identity `AsRoom` impl on `RoomRef` means discovered refs work directly. | +| **Symmetry** | Room and User have identical structure: protocol → `#[bridge]` one-liner → handlers. No asymmetry between "protocol actors" and "non-protocol actors" — every actor is a protocol actor. | +| **Testability** | Best of all approaches — mock `RoomProtocol` or `UserProtocol` directly in unit tests without running an actor system. | -**Key insight:** Protocol traits define contracts at the actor level (like Erlang behaviours) rather than the message level (like Actix's `Recipient`). The duplication cost (protocol method mirrors message struct) is real but buys three things: (1) testability via trait mocking, (2) a natural "API at a glance" surface, and (3) actor-level granularity for registry and discovery. With `#[protocol]`, `#[bridge]`, `Response`, and `Context::actor_ref()`, B's main.rs is similarly simple to A's — the only difference is `room.as_broadcaster()` instead of `room.clone()`, making the protocol boundary explicit. +**Key insight:** Each actor's protocol IS its public API — the Erlang gen_server model where the module's exports define the interface. `#[protocol]` is the single source of truth: it defines the API, generates message structs, and provides the bridge helper. The actor file is pure implementation. No duplication between protocol and bridge, no separate caller API traits, no message struct declarations. Two macros (`#[protocol]` + `#[bridge]`) and the user writes only what matters: the contract and the handlers. -**Scaling trade-off:** In a system with N actor types and M cross-boundary message types, Approach A needs M message structs. Approach B needs M message structs + P protocol traits + P bridge impls, where P grows with distinct actor-to-actor interaction patterns. The extra cost scales with *interaction patterns*, not messages — and each protocol trait is a natural documentation + testing boundary. +**Scaling:** Each new actor needs one protocol trait + one `#[bridge]` + 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:** In Approach A, the bidirectional module dependency (room imports `Deliver` from user, user imports `ChatRoomApi` from room) works because they're sibling modules in the same crate. If actors lived in separate crates, this would be a circular crate dependency — which Rust forbids. The fix is extracting shared types (`Deliver`, `ChatRoomApi`) into a third crate, at which point you've essentially reinvented `protocols.rs`. Approach B's structure maps directly to separate crates with zero restructuring: `protocols` becomes a shared crate, and each actor crate depends only on it, never on each other. +**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. --- @@ -1027,12 +989,12 @@ registry::register("general", room.clone()).unwrap(); // A let discovered: ActorRef = registry::whereis("general").unwrap(); // caller must know ChatRoom // Approach B — stores and retrieves the protocol -registry::register("general", room.as_broadcaster()).unwrap(); // BroadcasterRef -let discovered: BroadcasterRef = registry::whereis("general").unwrap(); // caller only needs the protocol -charlie.join_room(discovered).unwrap(); // works via AsBroadcaster +registry::register("general", room.as_room()).unwrap(); // RoomRef +let discovered: RoomRef = registry::whereis("general").unwrap(); // caller only needs the protocol +charlie.join_room(discovered).unwrap(); // works via AsRoom identity impl ``` -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 `BroadcasterRef` from `protocols.rs` — it knows *what the actor can do* without knowing *what actor it is*. Any actor implementing `ChatBroadcaster` could be behind that reference. +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 @@ -1050,7 +1012,7 @@ In A, the discoverer must import `ChatRoom` and `ChatRoomApi` to use the retriev - **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.as_broadcaster())`. A consumer discovers a `BroadcasterRef` (`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. +- **B** registers per protocol: `registry::register("general", room.as_room())`. 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. @@ -1064,38 +1026,36 @@ Approach A's `actor_api!` macro eliminates extension trait boilerplate by genera ### Approach B: Protocol Traits — DONE (`#[protocol]` + `#[bridge]`) -Two proc macro attributes eliminate all protocol scaffolding: +Two proc macro attributes — `#[protocol]` and `#[bridge]` — eliminate all scaffolding. The protocol trait is the single source of truth: -**`#[protocol]`** on the trait definition generates the type alias, conversion trait, and identity impl: +**`#[protocol]`** on the trait definition generates everything: type alias, conversion trait, identity impl, message structs, and a hidden bridge helper macro. Names are derived by convention (`RoomProtocol` → `RoomRef`, `AsRoom`): ```rust -#[protocol(ref = BroadcasterRef, converter = AsBroadcaster)] -pub trait ChatBroadcaster: Send + Sync { +#[protocol] +pub trait RoomProtocol: Send + Sync { fn say(&self, from: String, text: String) -> Result<(), ActorError>; - fn add_member(&self, name: String, participant: ParticipantRef) -> Result<(), ActorError>; + fn add_member(&self, name: String, user: UserRef) -> Result<(), ActorError>; fn members(&self) -> Response>; } -// Generates: type BroadcasterRef, trait AsBroadcaster, impl AsBroadcaster for BroadcasterRef +// Generates: +// type RoomRef = Arc +// trait AsRoom, impl AsRoom for RoomRef (identity) +// room_protocol::{Say, AddMember, Members} with Message impls +// hidden bridge helper macro ``` -**`#[bridge]`** on the `#[actor]` impl generates both the protocol bridge and conversion trait impl: +**`#[bridge]`** on the `#[actor]` impl is a one-liner — no method listing, no params: ```rust #[actor] -#[bridge(ChatBroadcaster as AsBroadcaster -> BroadcasterRef { - send fn say(from: String, text: String) => Say; - send fn add_member(name: String, participant: ParticipantRef) => Join; - request fn members() -> Vec => Members; -})] +#[bridge(RoomProtocol)] impl ChatRoom { ... } -// Generates: impl ChatBroadcaster for ActorRef, impl AsBroadcaster for ActorRef +// Generates: impl RoomProtocol for ActorRef, impl AsRoom for ActorRef ``` -Each `send fn` generates `fn method(&self, ...) -> Result<(), ActorError> { self.send(Msg { ... }) }`. Each `request fn` generates `fn method(&self, ...) -> Response { Response::from(self.request_raw(Msg)) }`. The protocol trait itself remains user-defined — it IS the contract, so it should stay hand-written. - -`#[bridge]` also works for direct caller APIs (like `UserActions`) — use the short form without conversion: `#[bridge(UserActions { ... })]`. +The bridge helper (generated by `#[protocol]`) encodes all method→struct mappings. Each send method generates `fn method(&self, ...) -> Result<(), ActorError> { self.send(Msg { ... }) }`. Each request method generates `fn method(&self, ...) -> Response { Response::from(self.request_raw(Msg)) }`. -**Impact:** Combined with `#[actor]`, `#[protocol]`, and `#[bridge]`, Approach B's total code is competitive with Approach A while retaining its testability and Erlang-like actor-level granularity advantages. No separate `protocol_impl!` macro needed — everything lives in the `#[actor]` impl block. +**Impact:** With the "one protocol per actor" model, every actor has the same structure: protocol trait → `#[bridge]` one-liner → handlers. No `send_messages!`/`request_messages!`, no separate caller API traits, no method duplication. The only macros needed: `#[protocol]` and `#[actor]` (with `#[bridge]`). 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 @@ -1130,13 +1090,13 @@ And `spawned::send(pid, Msg { ... })` could get ergonomic wrappers similar to `a | Approach | Macro potential | What it would eliminate | Worth implementing? | |----------|----------------|----------------------|---------------------| -| **B: Protocol Traits** | High | Bridge impls + conversion traits + scaffolding | Done — `#[protocol]` + `#[bridge]` | +| **B: Protocol Traits** | High | Message structs + bridge impls + conversion traits + caller APIs | Done — `#[protocol]` + `#[bridge]` | | **C: Typed Wrappers** | None | N/A — structural problem | No | | **D: Derive Macro** | N/A | Already a macro | N/A | | **E: AnyActorRef** | None | Would add false safety | No | | **F: PID** | Low-Medium | Registration ceremony | Maybe — ergonomics only | -**Takeaway:** Approach B now uses `#[protocol]` and `#[bridge]` to eliminate all scaffolding, achieving competitive code volume with Approach A. With `Response`, `#[protocol]`, `#[bridge]`, and `Context::actor_ref()`, main.rs bodies are similarly simple — the only visible difference is `room.as_broadcaster()` vs `room.clone()`, making B's protocol boundary explicit at the call site. +**Takeaway:** With the "one protocol per actor" model, `#[protocol]` becomes the single source of truth — generating message structs, type aliases, conversion traits, and bridge helpers from method signatures. `#[bridge]` is a one-liner. 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. --- @@ -1150,7 +1110,7 @@ And `spawned::send(pid, Msg { ... })` could get ergonomic wrappers similar to `a | **Breaking** | Yes | Yes | No | No | Yes | Yes | | **#144 type safety** | Full | Full | Hidden `unreachable!` | Hidden `unreachable!` | Full | Full | | **#145 type safety** | Compile-time | Compile-time | Compile-time | Compile-time | Runtime only | Runtime only | -| **Macro support** | `#[actor]` + `actor_api!` + message macros | `#[actor]` + `#[bridge]` + `#[protocol]` + message macros | N/A (enum-based) | Derive macro | `#[actor]` | `#[actor]` | +| **Macro support** | `#[actor]` + `actor_api!` + message macros | `#[protocol]` + `#[actor]`/`#[bridge]` (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 | @@ -1160,10 +1120,10 @@ And `spawned::send(pid, Msg { ... })` could get ergonomic wrappers similar to `a | 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. Files read Messages → Bridge → Conversion → Actor. Type aliases reduce noise. | Noisy: enum `match` arms + wrapper fns | Opaque: generated from enum annotations | Same as A, but callers use `Box::new` | Same as A, but callers use global `send(pid, msg)` | -| **API at a glance** | `actor_api!` block or scan Handler impls | Protocol traits (best) + `UserActions` trait for direct caller API | Typed wrapper functions | Annotated enum (good summary) | Nothing — `AnyActorRef` is opaque | Nothing — `Pid` is opaque | -| **main.rs expressivity** | `alice.say("Hi")` with `actor_api!`; `alice.send(SayToRoom{...})` without | Similar to A: `alice.join_room(room.as_broadcaster())`, `room.members().await`, `alice.say(...)` | `ChatRoom::say(&room, ...)` assoc fn | Generated methods: `room.say(...)` | `room.send_any(Box::new(...))` | `spawned::send(pid, ...)` + registration | -| **Boilerplate per message** | Struct + `actor_api!` line | Struct + protocol method + `#[bridge]` line | Enum variant + wrapper + match arm | Enum variant + annotation | Struct | Struct + registration | +| **Handler readability** | Clear: one `impl Handler` or `#[send_handler]` per message | Same as A for handlers. Actor files are pure implementation — imports → struct → `#[bridge]` → 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.speak("Hi")`, `room.members().await`, `alice.join_room(room.as_room())` | `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 — bridge impls visible | Standard Rust | Requires `cargo expand` | Runtime errors (downcast failures) | Runtime errors (unregistered types) | | **Testability** | Good (mock via Recipient) | Best (mock protocol trait) | Good | Good | Fair (Any-based) | Hard (global state) | @@ -1171,12 +1131,12 @@ And `spawned::send(pid, Msg { ... })` could get ergonomic wrappers similar to `a | Dimension | A: Recipient | B: Protocol Traits | C: Typed Wrappers | D: Derive Macro | E: AnyActorRef | F: PID | |-----------|-------------|-------------------|-------------------|-----------------|---------------|--------| -| **Framework complexity** | Medium | None (user-space) | High (dual channel) | Very high (proc macro) | High (dispatch) | Medium (registry) | -| **Maintenance burden** | Low — proven Actix pattern | Low — user-maintained | High — two dispatch systems | High — complex macro | Medium | Medium | +| **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) | Moderate + bridge pattern | Low (old API preserved) | Low (write enum, macro does rest) | Low concept, high debugging | Low concept, high registration overhead | -| **Erlang alignment** | Actix-like | Actor-level granularity (Erlang behaviours) | Actix-like | Actix-like | Erlang-ish | Most Erlang | -| **Macro improvement potential** | Already done (`actor_api!`) | Done (`#[protocol]` + `#[bridge]`) | None (structural) | N/A (is a macro) | None (false safety) | Low (ergonomics only) | +| **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]` generates everything, `#[bridge]` is one-liner | None (structural) | N/A (is a macro) | None (false safety) | Low (ergonomics only) | --- @@ -1191,16 +1151,18 @@ And `spawned::send(pid, Msg { ... })` could get ergonomic wrappers similar to `a - 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 similar caller simplicity: -- main.rs reads naturally: `alice.join_room(room.as_broadcaster())`, `room.members().await.unwrap()`, `alice.say(...)` — the `.as_broadcaster()` makes the protocol boundary explicit -- `#[bridge]` attribute on `#[actor]` generates protocol bridge + conversion from a compact declaration — no separate macro needed -- `Response` keeps protocol traits object-safe while supporting async request-response — structural mirror of the Envelope pattern (no RPITIT, no `BoxFuture`) -- `Context::actor_ref()` lets actors self-register with protocol traits (e.g., `ctx.actor_ref().as_participant()`) -- Protocol traits define contracts at the actor level (like Erlang behaviours), giving actor-level granularity for registry and discovery -- Best testability — protocol traits can be mocked directly without running an actor system +**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, type alias, conversion trait, bridge helper. `#[bridge(TraitName)]` is a one-liner +- 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 `#[bridge]` +- Perfect symmetry — every actor has the same structure: protocol → bridge → handlers +- `Response` keeps protocol traits object-safe (structural mirror of the Envelope pattern) +- `Context::actor_ref()` lets actors self-register (e.g., `ctx.actor_ref().as_user()`) +- Best testability — mock `RoomProtocol` or `UserProtocol` directly without an actor system - Zero cross-actor dependencies — both Room and User depend only on `protocols.rs` -- Only requires `Response` and `Context::actor_ref()` from the framework; protocol traits and bridge impls are purely user-space -- Best registry story: one registration per protocol, full API, no concrete type needed — discoverer depends only on the protocol trait +- 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. From f30dd0dc60ad1c67db3449aa94af52f3b8abd884 Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Tue, 24 Feb 2026 15:44:06 -0300 Subject: [PATCH 13/20] docs: clean up coding examples in Approaches A and B --- docs/API_ALTERNATIVES_SUMMARY.md | 315 ++++++++++++++++++++++++++++--- 1 file changed, 288 insertions(+), 27 deletions(-) diff --git a/docs/API_ALTERNATIVES_SUMMARY.md b/docs/API_ALTERNATIVES_SUMMARY.md index 52ea5de..fd4438a 100644 --- a/docs/API_ALTERNATIVES_SUMMARY.md +++ b/docs/API_ALTERNATIVES_SUMMARY.md @@ -147,22 +147,20 @@ Each message is its own struct with an associated `Result` type. Actors implemen ```rust use spawned_concurrency::message::Message; -use spawned_concurrency::messages; use spawned_concurrency::tasks::{Actor, Context, Handler, Recipient}; use crate::user::Deliver; // -- Messages (Room handles these) -- -messages! { - Say { from: String, text: String } -> (); -} +pub struct Say { pub from: String, pub text: String } +impl Message for Say { type Result = (); } -pub struct Join { - pub name: String, - pub inbox: Recipient, -} +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 { @@ -186,6 +184,12 @@ impl Handler for ChatRoom { } } } + +impl Handler for ChatRoom { + async fn handle(&mut self, _msg: Members, _ctx: &Context) -> Vec { + self.members.iter().map(|(name, _)| name.clone()).collect() + } +} ``` @@ -193,16 +197,17 @@ impl Handler for ChatRoom { user.rs — defines User's messages (including Deliver), imports Say from room ```rust -use spawned_concurrency::messages; +use spawned_concurrency::message::Message; use spawned_concurrency::tasks::{Actor, Context, Handler, Recipient}; use crate::room::Say; // -- Messages (User handles these) -- -messages! { - Deliver { from: String, text: String } -> (); - SayToRoom { text: String } -> (); -} +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 -- @@ -238,6 +243,8 @@ 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?; ``` @@ -451,7 +458,261 @@ impl Future for Response { This keeps protocol traits **object-safe** — `fn members(&self) -> Response>` returns a concrete type, not `impl Future` (which would require RPITIT and break `dyn Trait`). No `BoxFuture` boxing needed either. -### Full chat room code +### Without macro (expanded reference) + +This section shows what `#[protocol]` and `#[bridge]` generate under the hood. The macro versions follow below. + +
+protocols.rs — traits + message structs + converter traits (all manual) + +```rust +use spawned_concurrency::error::ActorError; +use spawned_concurrency::message::Message; +use spawned_concurrency::tasks::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 AsRoom { + fn as_room(&self) -> RoomRef; +} + +impl AsRoom for RoomRef { + fn as_room(&self) -> RoomRef { + Arc::clone(self) + } +} + +// --- UserProtocol --- + +pub trait UserProtocol: Send + Sync { + fn deliver(&self, from: String, text: String) -> Result<(), ActorError>; + fn speak(&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 Speak { pub text: String } + impl Message for Speak { type Result = (); } + + pub struct JoinRoom { pub room: RoomRef } + impl Message for JoinRoom { type Result = (); } +} + +pub trait AsUser { + fn as_user(&self) -> UserRef; +} + +impl AsUser for UserRef { + fn as_user(&self) -> UserRef { + Arc::clone(self) + } +} +``` +
+ +
+room.rs — manual Actor + Handler impls + bridge impl + +```rust +use spawned_concurrency::error::ActorError; +use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler, Response}; +use std::sync::Arc; + +use crate::protocols::{AsRoom, RoomProtocol, RoomRef, UserRef}; +use crate::protocols::room_protocol::{AddMember, Members, Say}; + +pub struct ChatRoom { + members: Vec<(String, UserRef)>, +} + +impl ChatRoom { + pub fn new() -> Self { + Self { members: Vec::new() } + } +} + +impl Actor for ChatRoom {} + +// --- Handler impls (what #[actor] with #[send_handler]/#[request_handler] generates) --- + +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() + } +} + +// --- Bridge impl (what #[bridge(RoomProtocol)] generates) --- + +impl RoomProtocol for ActorRef { + fn say(&self, from: String, text: String) -> Result<(), ActorError> { + self.send(Say { from, text }) + } + + fn add_member(&self, name: String, user: UserRef) -> Result<(), ActorError> { + self.send(AddMember { name, user }) + } + + fn members(&self) -> Response> { + Response::from(self.request_raw(Members)) + } +} + +impl AsRoom for ActorRef { + fn as_room(&self) -> RoomRef { + Arc::new(self.clone()) + } +} +``` +
+ +
+user.rs — same pattern, manual Handler + bridge impls + +```rust +use spawned_concurrency::error::ActorError; +use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; +use std::sync::Arc; + +use crate::protocols::{AsUser, RoomRef, UserProtocol, UserRef}; +use crate::protocols::user_protocol::{Deliver, JoinRoom, Speak}; + +pub struct User { + name: String, + room: Option, +} + +impl User { + pub fn new(name: String) -> Self { + Self { name, room: None } + } +} + +impl Actor for User {} + +// --- Handler impls --- + +impl Handler for User { + async fn handle(&mut self, msg: JoinRoom, ctx: &Context) { + let _ = msg.room.add_member(self.name.clone(), ctx.actor_ref().as_user()); + self.room = Some(msg.room); + } +} + +impl Handler for User { + async fn handle(&mut self, msg: Speak, _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: Deliver, _ctx: &Context) { + tracing::info!("[{}] got: {} says '{}'", self.name, msg.from, msg.text); + } +} + +// --- Bridge impl (what #[bridge(UserProtocol)] generates) --- + +impl UserProtocol for ActorRef { + fn deliver(&self, from: String, text: String) -> Result<(), ActorError> { + self.send(Deliver { from, text }) + } + + fn speak(&self, text: String) -> Result<(), ActorError> { + self.send(Speak { text }) + } + + fn join_room(&self, room: RoomRef) -> Result<(), ActorError> { + self.send(JoinRoom { room }) + } +} + +impl AsUser for ActorRef { + fn as_user(&self) -> UserRef { + Arc::new(self.clone()) + } +} +``` +
+ +
+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.as_room()).unwrap(); + +alice.join_room(room.as_room()).unwrap(); +bob.join_room(room.as_room()).unwrap(); + +let members = room.members().await.unwrap(); + +alice.speak("Hello everyone!".into()).unwrap(); +bob.speak("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`, `speak`, `members`) are called directly on `ActorRef` via the bridge impls. The `.as_room()` conversion makes the protocol boundary explicit. +
+ +### With `#[protocol]` + `#[bridge]` macros
protocols.rs — one protocol per actor, #[protocol] generates everything @@ -605,19 +866,19 @@ charlie.join_room(discovered).unwrap(); ### Analysis -| Dimension | Assessment | -|-----------|-----------| -| **Readability** | `protocols.rs` is a complete index of every actor's public API. Each protocol reads like an Erlang module's export list. Actor files are pure implementation: imports → struct → `#[bridge]` → handlers. | -| **API at a glance** | Protocol traits ARE the API. Looking at `RoomProtocol` tells you exactly what a room can do; `UserProtocol` tells you what a user can do. No separate `actor_api!` or `UserActions` — the protocol is the single source of truth. | -| **Boilerplate** | Competitive with Approach A: protocol method + handler per message. No `send_messages!`/`request_messages!` needed — `#[protocol]` generates message structs. No method listing in `#[bridge]` — it's a one-liner. | -| **main.rs expressivity** | `alice.speak("Hello")`, `room.members().await`, `alice.join_room(room.as_room())`. Protocol methods are called directly on `ActorRef` (via bridge impl). The `.as_room()` conversion makes the protocol boundary explicit. | -| **Request-response** | `Response` keeps protocol traits object-safe while supporting async request-response. Structural mirror of the Envelope pattern — no RPITIT, no `BoxFuture` boxing. | -| **Circular dep solution** | Actors hold `RoomRef` / `UserRef` — protocol-level refs to each other's full API. No `ActorRef`, no bidirectional module imports. Both depend only on `protocols.rs`. | -| **Registry** | Register `RoomRef` — one registration gives the discoverer the full actor API (`say`, `add_member`, `members`). No concrete actor type needed. Identity `AsRoom` impl on `RoomRef` means discovered refs work directly. | -| **Symmetry** | Room and User have identical structure: protocol → `#[bridge]` one-liner → handlers. No asymmetry between "protocol actors" and "non-protocol actors" — every actor is a protocol actor. | -| **Testability** | Best of all approaches — mock `RoomProtocol` or `UserProtocol` directly in unit tests without running an actor system. | - -**Key insight:** Each actor's protocol IS its public API — the Erlang gen_server model where the module's exports define the interface. `#[protocol]` is the single source of truth: it defines the API, generates message structs, and provides the bridge helper. The actor file is pure implementation. No duplication between protocol and bridge, no separate caller API traits, no message struct declarations. Two macros (`#[protocol]` + `#[bridge]`) and the user writes only what matters: the contract and the handlers. +| Dimension | Without macro | With `#[protocol]` + `#[bridge]` macros | +|-----------|---------------|----------------------------------------| +| **Readability** | `protocols.rs` is a complete API index, but message submodules, converter traits, and identity impls add visual noise. Actor files have separate blocks for handlers and bridge impls. | `protocols.rs` is just trait definitions — the clearest API surface of all approaches. Actor files are pure implementation: imports → struct → `#[bridge]` one-liner → handlers. | +| **API at a glance** | Protocol traits define the API, but you must scroll past message submodules 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. Per actor: `impl Actor`, one `impl Handler` per message, full `impl Protocol for ActorRef` bridge with one method per message, `impl AsX for ActorRef`. | Minimal. Per protocol: one `#[protocol]` trait. Per actor: `#[bridge]` one-liner + handler methods. Message structs, converter traits, and bridge impls are all generated. | +| **main.rs expressivity** | Identical — `alice.speak("Hello")`, `room.members().await`, `room.as_room()`. Bridge 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 `AsRoom` 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 ~30 lines of bridge boilerplate. | Room and User have identical structure: protocol → `#[bridge]` one-liner → 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, converter traits, bridge impls on `ActorRef`. It's entirely standard Rust — any developer can read and understand it without knowing the macros. The `#[protocol]` + `#[bridge]` 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 `#[bridge]` + 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. From ab2ccbe8895a2f68ab23097f0090158818ce5502 Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Tue, 24 Feb 2026 16:13:43 -0300 Subject: [PATCH 14/20] docs: update Approach B to match latest feat/approach-b implementation --- docs/API_ALTERNATIVES_SUMMARY.md | 329 ++++++++++++++++--------------- 1 file changed, 167 insertions(+), 162 deletions(-) diff --git a/docs/API_ALTERNATIVES_SUMMARY.md b/docs/API_ALTERNATIVES_SUMMARY.md index fd4438a..8fc83e4 100644 --- a/docs/API_ALTERNATIVES_SUMMARY.md +++ b/docs/API_ALTERNATIVES_SUMMARY.md @@ -428,13 +428,13 @@ charlie.join_room(discovered).unwrap(); ## Approach B: Protocol Traits (one protocol per actor) -**Branch:** [`feat/approach-b`](https://github.com/lambdaclass/spawned/tree/feat/approach-b) (`#[protocol]` + `#[bridge]` macros + Context::actor_ref()) +**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 `#[bridge]` macros. +**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, one bridge per actor. +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, type alias (`Arc`), conversion trait, and a bridge helper macro. `#[bridge(TraitName)]` on an `#[actor]` impl is a one-liner that connects the actor to its protocol. Names are derived by convention (`RoomProtocol` → `RoomRef`, `AsRoom`), so no explicit parameters are needed. `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. +**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 @@ -460,15 +460,15 @@ This keeps protocol traits **object-safe** — `fn members(&self) -> Response -protocols.rs — traits + message structs + converter traits (all manual) +protocols.rs — traits + message structs + blanket impls (all manual) ```rust use spawned_concurrency::error::ActorError; use spawned_concurrency::message::Message; -use spawned_concurrency::tasks::Response; +use spawned_concurrency::tasks::{Actor, ActorRef, Handler, Response}; use std::sync::Arc; // --- Type aliases (manually declared for cross-protocol references) --- @@ -497,21 +497,45 @@ pub mod room_protocol { impl Message for Members { type Result = Vec; } } -pub trait AsRoom { - fn as_room(&self) -> RoomRef; +pub trait ToRoomRef { + fn to_room_ref(&self) -> RoomRef; } -impl AsRoom for RoomRef { - fn as_room(&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 speak(&self, text: String) -> Result<(), ActorError>; + fn say(&self, text: String) -> Result<(), ActorError>; fn join_room(&self, room: RoomRef) -> Result<(), ActorError>; } @@ -521,35 +545,57 @@ pub mod user_protocol { pub struct Deliver { pub from: String, pub text: String } impl Message for Deliver { type Result = (); } - pub struct Speak { pub text: String } - impl Message for Speak { 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 AsUser { - fn as_user(&self) -> UserRef; +pub trait ToUserRef { + fn to_user_ref(&self) -> UserRef; } -impl AsUser for UserRef { - fn as_user(&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 + bridge impl +room.rs — manual Actor + Handler impls + protocol assertion ```rust -use spawned_concurrency::error::ActorError; -use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler, Response}; -use std::sync::Arc; +use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; -use crate::protocols::{AsRoom, RoomProtocol, RoomRef, UserRef}; use crate::protocols::room_protocol::{AddMember, Members, Say}; +use crate::protocols::{RoomProtocol, UserRef}; pub struct ChatRoom { members: Vec<(String, UserRef)>, @@ -563,8 +609,6 @@ impl ChatRoom { impl Actor for ChatRoom {} -// --- Handler impls (what #[actor] with #[send_handler]/#[request_handler] generates) --- - impl Handler for ChatRoom { async fn handle(&mut self, msg: Say, _ctx: &Context) { tracing::info!("[room] {} says: {}", msg.from, msg.text); @@ -589,40 +633,22 @@ impl Handler for ChatRoom { } } -// --- Bridge impl (what #[bridge(RoomProtocol)] generates) --- - -impl RoomProtocol for ActorRef { - fn say(&self, from: String, text: String) -> Result<(), ActorError> { - self.send(Say { from, text }) - } - - fn add_member(&self, name: String, user: UserRef) -> Result<(), ActorError> { - self.send(AddMember { name, user }) - } - - fn members(&self) -> Response> { - Response::from(self.request_raw(Members)) - } -} - -impl AsRoom for ActorRef { - fn as_room(&self) -> RoomRef { - Arc::new(self.clone()) - } -} +// Compile-time assertion: ActorRef must satisfy RoomProtocol +const _: () = { + fn _assert() {} + fn _check() { _assert::>(); } +}; ```
-user.rs — same pattern, manual Handler + bridge impls +user.rs — same pattern, manual Actor + Handler impls ```rust -use spawned_concurrency::error::ActorError; use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; -use std::sync::Arc; -use crate::protocols::{AsUser, RoomRef, UserProtocol, UserRef}; -use crate::protocols::user_protocol::{Deliver, JoinRoom, Speak}; +use crate::protocols::user_protocol::{Deliver, JoinRoom, Say}; +use crate::protocols::{RoomRef, ToUserRef, UserProtocol}; pub struct User { name: String, @@ -637,50 +663,32 @@ impl User { impl Actor for User {} -// --- Handler impls --- - -impl Handler for User { - async fn handle(&mut self, msg: JoinRoom, ctx: &Context) { - let _ = msg.room.add_member(self.name.clone(), ctx.actor_ref().as_user()); - self.room = Some(msg.room); +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: Speak, _ctx: &Context) { +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: Deliver, _ctx: &Context) { - tracing::info!("[{}] got: {} says '{}'", self.name, msg.from, msg.text); - } -} - -// --- Bridge impl (what #[bridge(UserProtocol)] generates) --- - -impl UserProtocol for ActorRef { - fn deliver(&self, from: String, text: String) -> Result<(), ActorError> { - self.send(Deliver { from, text }) - } - - fn speak(&self, text: String) -> Result<(), ActorError> { - self.send(Speak { text }) - } - - fn join_room(&self, room: RoomRef) -> Result<(), ActorError> { - self.send(JoinRoom { room }) +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); } } -impl AsUser for ActorRef { - fn as_user(&self) -> UserRef { - Arc::new(self.clone()) - } -} +// Compile-time assertion: ActorRef must satisfy UserProtocol +const _: () = { + fn _assert() {} + fn _check() { _assert::>(); } +}; ```
@@ -693,15 +701,15 @@ 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.as_room()).unwrap(); +registry::register("general", room.to_room_ref()).unwrap(); -alice.join_room(room.as_room()).unwrap(); -bob.join_room(room.as_room()).unwrap(); +alice.join_room(room.to_room_ref()).unwrap(); +bob.join_room(room.to_room_ref()).unwrap(); let members = room.members().await.unwrap(); -alice.speak("Hello everyone!".into()).unwrap(); -bob.speak("Hey Alice!".into()).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(); @@ -709,10 +717,10 @@ let discovered: RoomRef = registry::whereis("general").unwrap(); charlie.join_room(discovered).unwrap(); ``` -Protocol methods (`say`, `speak`, `members`) are called directly on `ActorRef` via the bridge impls. The `.as_room()` conversion makes the protocol boundary explicit. +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]` + `#[bridge]` macros +### With `#[protocol]` + `#[actor]` macros
protocols.rs — one protocol per actor, #[protocol] generates everything @@ -721,16 +729,18 @@ Protocol methods (`say`, `speak`, `members`) are called directly on `ActorRef` v 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: -// Type alias: pub type RoomRef = Arc; -// Conversion trait: pub trait AsRoom { fn as_room(&self) -> RoomRef; } -// Identity impl: impl AsRoom for RoomRef { ... } +// 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 -// Bridge helper: hidden macro for #[bridge] to use -// -// Names derived by convention: RoomProtocol → RoomRef, AsRoom -// Override with: #[protocol(ref = MyRef, converter = ToMyRef)] +// Blanket impl: impl + ...> RoomProtocol for ActorRef +// Blanket impl: impl + ...> ToRoomRef for ActorRef #[protocol] pub trait RoomProtocol: Send + Sync { @@ -742,29 +752,27 @@ pub trait RoomProtocol: Send + Sync { #[protocol] pub trait UserProtocol: Send + Sync { fn deliver(&self, from: String, text: String) -> Result<(), ActorError>; - fn speak(&self, text: String) -> Result<(), ActorError>; + fn say(&self, text: String) -> Result<(), ActorError>; fn join_room(&self, room: RoomRef) -> Result<(), ActorError>; } ```
-room.rs#[bridge(RoomProtocol)], message structs from protocol +room.rs#[actor(protocol = RoomProtocol)] generates Actor impl, Handler impls, and assertion ```rust -use spawned_concurrency::tasks::{Actor, Context}; +use spawned_concurrency::tasks::{Actor, Context, Handler}; use spawned_macros::actor; + +use crate::protocols::room_protocol::{AddMember, Members, Say}; use crate::protocols::{RoomProtocol, UserRef}; -use crate::protocols::room_protocol::{Say, AddMember, Members}; pub struct ChatRoom { members: Vec<(String, UserRef)>, } -impl Actor for ChatRoom {} - -#[actor] -#[bridge(RoomProtocol)] +#[actor(protocol = RoomProtocol)] impl ChatRoom { pub fn new() -> Self { Self { members: Vec::new() } @@ -795,44 +803,42 @@ impl ChatRoom {
-user.rs#[bridge(UserProtocol)], symmetric with room.rs +user.rs#[actor(protocol = UserProtocol)], symmetric with room.rs ```rust -use spawned_concurrency::tasks::{Actor, Context}; +use spawned_concurrency::tasks::{Actor, Context, Handler}; use spawned_macros::actor; -use crate::protocols::{UserProtocol, RoomRef}; -use crate::protocols::user_protocol::{Deliver, Speak, JoinRoom}; + +use crate::protocols::user_protocol::{Deliver, JoinRoom, Say}; +use crate::protocols::{RoomRef, ToUserRef, UserProtocol}; pub struct User { name: String, room: Option, } -impl Actor for User {} - -#[actor] -#[bridge(UserProtocol)] +#[actor(protocol = UserProtocol)] impl User { pub fn new(name: String) -> Self { Self { name, room: None } } #[send_handler] - async fn handle_join_room(&mut self, msg: JoinRoom, ctx: &Context) { - let _ = msg.room.add_member(self.name.clone(), ctx.actor_ref().as_user()); - self.room = Some(msg.room); + async fn handle_deliver(&mut self, msg: Deliver, _ctx: &Context) { + tracing::info!("[{}] got: {} says '{}'", self.name, msg.from, msg.text); } #[send_handler] - async fn handle_speak(&mut self, msg: Speak, _ctx: &Context) { + async fn handle_say(&mut self, msg: Say, _ctx: &Context) { if let Some(ref room) = self.room { let _ = room.say(self.name.clone(), msg.text); } } #[send_handler] - async fn handle_deliver(&mut self, msg: Deliver, _ctx: &Context) { - tracing::info!("[{}] got: {} says '{}'", self.name, msg.from, msg.text); + async fn handle_join_room(&mut self, msg: JoinRoom, ctx: &Context) { + let _ = msg.room.add_member(self.name.clone(), ctx.actor_ref().to_user_ref()); + self.room = Some(msg.room); } } ``` @@ -847,15 +853,15 @@ 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.as_room()).unwrap(); +registry::register("general", room.to_room_ref()).unwrap(); -alice.join_room(room.as_room()).unwrap(); -bob.join_room(room.as_room()).unwrap(); +alice.join_room(room.to_room_ref()).unwrap(); +bob.join_room(room.to_room_ref()).unwrap(); let members = room.members().await.unwrap(); -alice.speak("Hello everyone!".into()).unwrap(); -bob.speak("Hey Alice!".into()).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(); @@ -866,21 +872,21 @@ charlie.join_room(discovered).unwrap(); ### Analysis -| Dimension | Without macro | With `#[protocol]` + `#[bridge]` macros | +| Dimension | Without macro | With `#[protocol]` + `#[actor]` macros | |-----------|---------------|----------------------------------------| -| **Readability** | `protocols.rs` is a complete API index, but message submodules, converter traits, and identity impls add visual noise. Actor files have separate blocks for handlers and bridge impls. | `protocols.rs` is just trait definitions — the clearest API surface of all approaches. Actor files are pure implementation: imports → struct → `#[bridge]` one-liner → handlers. | -| **API at a glance** | Protocol traits define the API, but you must scroll past message submodules 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. Per actor: `impl Actor`, one `impl Handler` per message, full `impl Protocol for ActorRef` bridge with one method per message, `impl AsX for ActorRef`. | Minimal. Per protocol: one `#[protocol]` trait. Per actor: `#[bridge]` one-liner + handler methods. Message structs, converter traits, and bridge impls are all generated. | -| **main.rs expressivity** | Identical — `alice.speak("Hello")`, `room.members().await`, `room.as_room()`. Bridge impls provide the same API surface. | Identical — same call syntax. The macros don't change how callers use the protocols. | +| **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 `AsRoom` 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 ~30 lines of bridge boilerplate. | Room and User have identical structure: protocol → `#[bridge]` one-liner → handlers. No asymmetry. | +| **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, converter traits, bridge impls on `ActorRef`. It's entirely standard Rust — any developer can read and understand it without knowing the macros. The `#[protocol]` + `#[bridge]` 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. +**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 `#[bridge]` + 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. +**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. @@ -1250,9 +1256,9 @@ registry::register("general", room.clone()).unwrap(); // A let discovered: ActorRef = registry::whereis("general").unwrap(); // caller must know ChatRoom // Approach B — stores and retrieves the protocol -registry::register("general", room.as_room()).unwrap(); // RoomRef +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 via AsRoom identity impl +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. @@ -1273,7 +1279,7 @@ In A, the discoverer must import `ChatRoom` and `ChatRoomApi` to use the retriev - **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.as_room())`. 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. +- **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. @@ -1285,11 +1291,11 @@ In A, the discoverer must import `ChatRoom` and `ChatRoomApi` to use the retriev 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]` + `#[bridge]`) +### Approach B: Protocol Traits — DONE (`#[protocol]` + `#[actor(protocol = X)]`) -Two proc macro attributes — `#[protocol]` and `#[bridge]` — eliminate all scaffolding. The protocol trait is the single source of truth: +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: type alias, conversion trait, identity impl, message structs, and a hidden bridge helper macro. Names are derived by convention (`RoomProtocol` → `RoomRef`, `AsRoom`): +**`#[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] @@ -1299,24 +1305,23 @@ pub trait RoomProtocol: Send + Sync { fn members(&self) -> Response>; } // Generates: -// type RoomRef = Arc -// trait AsRoom, impl AsRoom for RoomRef (identity) +// trait ToRoomRef, impl ToRoomRef for RoomRef (identity) // room_protocol::{Say, AddMember, Members} with Message impls -// hidden bridge helper macro +// blanket impl> RoomProtocol for ActorRef +// blanket impl> ToRoomRef for ActorRef ``` -**`#[bridge]`** on the `#[actor]` impl is a one-liner — no method listing, no params: +**`#[actor(protocol = X)]`** generates `impl Actor`, `Handler` impls from annotated methods, and a compile-time assertion: ```rust -#[actor] -#[bridge(RoomProtocol)] +#[actor(protocol = RoomProtocol)] impl ChatRoom { ... } -// Generates: impl RoomProtocol for ActorRef, impl AsRoom for ActorRef +// Generates: impl Actor for ChatRoom {}, impl Handler per handler, static assertion ``` -The bridge helper (generated by `#[protocol]`) encodes all method→struct mappings. Each send method generates `fn method(&self, ...) -> Result<(), ActorError> { self.send(Msg { ... }) }`. Each request method generates `fn method(&self, ...) -> Response { Response::from(self.request_raw(Msg)) }`. +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 → `#[bridge]` one-liner → handlers. No `send_messages!`/`request_messages!`, no separate caller API traits, no method duplication. The only macros needed: `#[protocol]` and `#[actor]` (with `#[bridge]`). B's boilerplate per message is now lower than A's (protocol method + handler vs struct + `actor_api!` line + handler). +**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 @@ -1324,7 +1329,7 @@ The fundamental problem is the dual-channel architecture, not boilerplate. The ` ### 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!`/`#[bridge]`, `send_messages!`, and `#[actor]` do separately. +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 @@ -1351,13 +1356,13 @@ And `spawned::send(pid, Msg { ... })` could get ergonomic wrappers similar to `a | Approach | Macro potential | What it would eliminate | Worth implementing? | |----------|----------------|----------------------|---------------------| -| **B: Protocol Traits** | High | Message structs + bridge impls + conversion traits + caller APIs | Done — `#[protocol]` + `#[bridge]` | +| **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, type aliases, conversion traits, and bridge helpers from method signatures. `#[bridge]` is a one-liner. 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. +**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. --- @@ -1371,7 +1376,7 @@ And `spawned::send(pid, Msg { ... })` could get ergonomic wrappers similar to `a | **Breaking** | Yes | Yes | No | No | Yes | Yes | | **#144 type safety** | Full | Full | Hidden `unreachable!` | Hidden `unreachable!` | Full | Full | | **#145 type safety** | Compile-time | Compile-time | Compile-time | Compile-time | Runtime only | Runtime only | -| **Macro support** | `#[actor]` + `actor_api!` + message macros | `#[protocol]` + `#[actor]`/`#[bridge]` (no message macros needed) | N/A (enum-based) | Derive macro | `#[actor]` | `#[actor]` | +| **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 | @@ -1381,11 +1386,11 @@ And `spawned::send(pid, Msg { ... })` could get ergonomic wrappers similar to `a | 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 — imports → struct → `#[bridge]` → 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)` | +| **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.speak("Hi")`, `room.members().await`, `alice.join_room(room.as_room())` | `ChatRoom::say(&room, ...)` assoc fn | Generated methods: `room.say(...)` | `room.send_any(Box::new(...))` | `spawned::send(pid, ...)` + registration | +| **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 — bridge impls visible | Standard Rust | Requires `cargo expand` | Runtime errors (downcast failures) | Runtime errors (unregistered types) | +| **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 @@ -1395,9 +1400,9 @@ And `spawned::send(pid, Msg { ... })` could get ergonomic wrappers similar to `a | **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) | Moderate + bridge pattern | Low (old API preserved) | Low (write enum, macro does rest) | Low concept, high debugging | Low concept, high registration overhead | +| **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]` generates everything, `#[bridge]` is one-liner | None (structural) | N/A (is a macro) | None (false safety) | Low (ergonomics only) | +| **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) | --- @@ -1414,12 +1419,12 @@ And `spawned::send(pid, Msg { ... })` could get ergonomic wrappers similar to `a **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, type alias, conversion trait, bridge helper. `#[bridge(TraitName)]` is a one-liner +- `#[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 `#[bridge]` -- Perfect symmetry — every actor has the same structure: protocol → bridge → handlers +- 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 self-register (e.g., `ctx.actor_ref().as_user()`) +- `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 @@ -1437,7 +1442,7 @@ And `spawned::send(pid, Msg { ... })` could get ergonomic wrappers similar to `a |--------|------|-------------| | `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]`/`#[bridge]` macros + Context::actor_ref() (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)) | From 8fcecd8e563212973291c1f205b958368c9556e5 Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Wed, 25 Feb 2026 10:38:24 -0300 Subject: [PATCH 15/20] docs: add condensed quick reference for API alternatives comparison --- docs/API_ALTERNATIVES_QUICK_REFERENCE.md | 870 +++++++++++++++++++++++ 1 file changed, 870 insertions(+) create mode 100644 docs/API_ALTERNATIVES_QUICK_REFERENCE.md diff --git a/docs/API_ALTERNATIVES_QUICK_REFERENCE.md b/docs/API_ALTERNATIVES_QUICK_REFERENCE.md new file mode 100644 index 0000000..d77457a --- /dev/null +++ b/docs/API_ALTERNATIVES_QUICK_REFERENCE.md @@ -0,0 +1,870 @@ +# 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. **Cannot build the chat room** as separate modules (no type-erasure → circular imports). Even in one file, callers must match impossible enum variants. + +--- + +## 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. From d55e20fd68b7a416c230bc1fab66c7d82d009cf8 Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Wed, 25 Feb 2026 10:41:41 -0300 Subject: [PATCH 16/20] docs: add old API chat room example to quick reference --- docs/API_ALTERNATIVES_QUICK_REFERENCE.md | 44 +++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/docs/API_ALTERNATIVES_QUICK_REFERENCE.md b/docs/API_ALTERNATIVES_QUICK_REFERENCE.md index d77457a..d51adba 100644 --- a/docs/API_ALTERNATIVES_QUICK_REFERENCE.md +++ b/docs/API_ALTERNATIVES_QUICK_REFERENCE.md @@ -45,7 +45,49 @@ Every approach implements: **ChatRoom** ↔ **User** (bidirectional), `Members` ## Baseline: The Old API -Single-enum `Actor` trait with associated types for Request/Message/Reply. **Cannot build the chat room** as separate modules (no type-erasure → circular imports). Even in one file, callers must match impossible enum variants. +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 +} +``` --- From 65ab654902e4cc201748af09e2756c3aa20fe95a Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Thu, 5 Mar 2026 13:25:06 -0300 Subject: [PATCH 17/20] docs: move design docs to docs/design/ --- docs/{ => design}/API_ALTERNATIVES_QUICK_REFERENCE.md | 0 docs/{ => design}/API_ALTERNATIVES_SUMMARY.md | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename docs/{ => design}/API_ALTERNATIVES_QUICK_REFERENCE.md (100%) rename docs/{ => design}/API_ALTERNATIVES_SUMMARY.md (100%) diff --git a/docs/API_ALTERNATIVES_QUICK_REFERENCE.md b/docs/design/API_ALTERNATIVES_QUICK_REFERENCE.md similarity index 100% rename from docs/API_ALTERNATIVES_QUICK_REFERENCE.md rename to docs/design/API_ALTERNATIVES_QUICK_REFERENCE.md diff --git a/docs/API_ALTERNATIVES_SUMMARY.md b/docs/design/API_ALTERNATIVES_SUMMARY.md similarity index 100% rename from docs/API_ALTERNATIVES_SUMMARY.md rename to docs/design/API_ALTERNATIVES_SUMMARY.md From e6b1475c1c6c5b2eccbf07655951e29078afabf3 Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Thu, 5 Mar 2026 13:36:33 -0300 Subject: [PATCH 18/20] docs: add API redesign plan to docs/design/ (from #147) --- docs/design/API_REDESIGN.md | 498 ++++++++++++++++++++++++++++++++++++ 1 file changed, 498 insertions(+) create mode 100644 docs/design/API_REDESIGN.md 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 | From 1e6fe74b672165b893d8c07c59436e8d0efa6193 Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Thu, 5 Mar 2026 13:41:10 -0300 Subject: [PATCH 19/20] docs: add README to docs/design/ --- docs/design/README.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 docs/design/README.md diff --git a/docs/design/README.md b/docs/design/README.md new file mode 100644 index 0000000..4fa7ae7 --- /dev/null +++ b/docs/design/README.md @@ -0,0 +1,7 @@ +# Design Documents + +Architectural decision records from the v0.5 API redesign. Approach B was chosen. + +- **[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 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. From 32e054e26d3ca3b21688970af1aa5aa7852d0037 Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Thu, 5 Mar 2026 13:49:47 -0300 Subject: [PATCH 20/20] docs: add framework comparison compiled from research (12+ frameworks) --- docs/design/FRAMEWORK_COMPARISON.md | 257 ++++++++++++++++++++++++++++ docs/design/README.md | 3 +- 2 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 docs/design/FRAMEWORK_COMPARISON.md 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 index 4fa7ae7..34098b2 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -2,6 +2,7 @@ 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 approaches (A-F) using the same chat room example. Includes analysis tables and comparison matrix. +- **[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()) + } +} +``` +