Skip to content

Circular dependency problem with bidirectional actor communication #145

@ElFantasma

Description

@ElFantasma

Problem

When two actors need to collaborate bidirectionally (each sending messages to the other), Spawned's strongly-typed ActorRef<T> creates a circular module dependency that Rust rejects.

// actors/actor_a.rs
use crate::actors::actor_b::ActorB;  // ⚠️ imports actor_b

struct ActorA;

enum MsgA {
    SetPeer(ActorRef<ActorB>),  // Needs ActorB type
    FromB(String),
}

impl Actor for ActorA { type Message = MsgA; ... }

// actors/actor_b.rs  
use crate::actors::actor_a::ActorA;  // ⚠️ imports actor_a - CIRCULAR!

struct ActorB;

enum MsgB {
    SetPeer(ActorRef<ActorA>),  // Needs ActorA type
    FromA(String),
}

impl Actor for ActorB { type Message = MsgB; ... }

Rust will reject this - circular module dependencies are not allowed.

Current Workarounds

1. Shared Message Type (what ping_pong example does)

// messages.rs - shared by both
enum Message {
    Ping { from: Sender<Message> },
    Pong,
}

// Both actors use the same Message type
impl Actor for ActorA { type Message = Message; ... }
impl Actor for ActorB { type Message = Message; ... }

Problem: Loses type safety - ActorA can receive messages meant for ActorB.

2. Pass Raw Sender Instead of ActorRef

enum MsgA {
    SetPeer(mpsc::Sender<MsgB>),  // Raw sender, not ActorRef
}

Problem: Loses ActorRef conveniences (cancellation token, join, etc.)

3. Put Both Actors in Same Module

Works but doesn't scale for large systems.

Proposed Solutions (Framework Changes)

Option A: Type-Erased Actor Handle

// A trait object that any ActorRef can become
trait AnyActor: Send {
    fn send_boxed(&self, msg: Box<dyn Any + Send>) -> Result<(), Error>;
}

enum MsgA {
    SetPeer(Arc<dyn AnyActor>),  // No concrete type needed
}

Option B: Generic Sender Trait (like Actix's Recipient)

trait MessageSender<M>: Send + Clone {
    fn send(&self, msg: M) -> Result<(), Error>;
}

// ActorRef<T> implements MessageSender<T::Message>
// Now messages can hold Arc<dyn MessageSender<SomeMsg>>

enum MsgA {
    SetPeer(Arc<dyn MessageSender<SharedMessage>>),
}

This is similar to Actix's Recipient<M> pattern - a type-erased handle that can send a specific message type M to any actor that handles M.

Option C: PID-like Addressing (Erlang style)

// Global registry with string/id-based addressing
enum MsgA {
    SetPeer(Pid),  // Just an ID, resolved at runtime
}

// Sending:
registry.send(pid, message)?;

This aligns with Phase 3 of the roadmap (Process Primitives with Registry).

Option D: Shared Protocol Trait

// Define a shared protocol both actors understand
trait PeerProtocol {
    fn notify(&self, msg: String) -> Result<(), Error>;
}

// Both ActorRef<A> and ActorRef<B> implement PeerProtocol
enum MsgA {
    SetPeer(Arc<dyn PeerProtocol + Send + Sync>),
}

Comparison with Other Frameworks

Framework How It Solves This
Erlang PIDs are untyped, messages are untyped - no compile-time deps
Akka ActorRef[Any] or typed protocols with shared message traits
Actix Addr<A> + Recipient<M> (type-erased for specific message)
Orleans Interface-based grains, no circular deps by design

Actix's Recipient Pattern (Worth Studying)

// Actix allows type-erasing an Addr to just the message it can receive
let recipient: Recipient<MyMessage> = addr.recipient();

// Any actor handling MyMessage can be stored as Recipient<MyMessage>
struct MsgA {
    peer: Recipient<SharedMessage>,  // Works for any actor handling SharedMessage
}

Decision Criteria

  • Preserve type safety where possible
  • Minimize runtime overhead
  • Keep API ergonomic
  • Align with Erlang philosophy (or consciously diverge)

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions