Split monolithic test file into domain-specific test modules: - common/mod.rs: shared messages, actors, helpers - runtime_lifecycle.rs (57 tests): spawn, FIFO, threading, panic safety - runtime_mechanics.rs (10): placement, backpressure, cleanup, recovery - runtime_hooks.rs (12): on_start/on_stop, graceful stop - runtime_timers.rs (6): one-shot and interval timers - runtime_registry.rs (32): naming, monitoring, groups, ask pattern - runtime_supervision.rs (20): supervisor + router - runtime_hasher.rs (3): identity hasher correctness All 140 tests pass. Authored by Claude, lovingly guided by Zachery Aaron Shores-Chmielewski
758 lines
25 KiB
Rust
758 lines
25 KiB
Rust
mod common;
|
|
use common::*;
|
|
|
|
// ── Named Actor Registry ────────────────────────────────────────────────────
|
|
|
|
/// Given a named actor is spawned,
|
|
/// when I look it up by name,
|
|
/// then I get the same address that spawn returned.
|
|
#[test]
|
|
fn named_actor_lookup_returns_spawn_address() {
|
|
let rt = std_runtime(RuntimeConfig::default());
|
|
let addr = rt.spawn_named("greeter", PingPongActor).unwrap();
|
|
assert_eq!(rt.where_is("greeter"), Some(addr));
|
|
}
|
|
|
|
/// Given a named actor exists,
|
|
/// when I send a message to the looked-up address,
|
|
/// then the actor receives and processes it.
|
|
#[test]
|
|
fn named_actor_receives_messages_via_lookup() {
|
|
let rt = std_runtime(RuntimeConfig::default());
|
|
let inbox = rt.new_inbox::<Pong>().unwrap();
|
|
let addr = rt.spawn_named("ponger", PingPongActor).unwrap();
|
|
assert_eq!(rt.where_is("ponger"), Some(addr));
|
|
|
|
rt.send_to(addr, Ping { reply_to: *inbox.addr() }).unwrap();
|
|
rt.tick();
|
|
assert!(inbox.try_recv().is_some(), "named actor should process message");
|
|
}
|
|
|
|
/// Given a name is already registered,
|
|
/// when I try to spawn another actor with the same name,
|
|
/// then I get an error and the original binding is preserved.
|
|
#[test]
|
|
fn duplicate_name_returns_error() {
|
|
let rt = std_runtime(RuntimeConfig::default());
|
|
let first_addr = rt.spawn_named("singleton", PingPongActor).unwrap();
|
|
let result = rt.spawn_named("singleton", PingPongActor);
|
|
assert!(result.is_err(), "duplicate name should fail");
|
|
assert_eq!(rt.where_is("singleton"), Some(first_addr), "original binding preserved");
|
|
}
|
|
|
|
/// Given no actors are registered,
|
|
/// when I look up a nonexistent name,
|
|
/// then I get None.
|
|
#[test]
|
|
fn where_is_returns_none_for_unknown_name() {
|
|
let rt = std_runtime(RuntimeConfig::default());
|
|
assert_eq!(rt.where_is("ghost"), None);
|
|
}
|
|
|
|
/// Given a named actor is stopped,
|
|
/// when the next tick runs cleanup,
|
|
/// then the name is automatically unregistered.
|
|
#[test]
|
|
fn name_auto_unregistered_on_actor_death() {
|
|
let rt = std_runtime(RuntimeConfig::default());
|
|
let addr = rt.spawn_named("ephemeral", PingPongActor).unwrap();
|
|
rt.tick(); // on_start
|
|
|
|
rt.stop_actor(addr).unwrap();
|
|
rt.tick(); // process StopSignal + cleanup
|
|
|
|
assert_eq!(rt.where_is("ephemeral"), None, "name should be freed after stop");
|
|
}
|
|
|
|
/// Given a named actor died and its name was freed,
|
|
/// when I spawn a new actor with the same name,
|
|
/// then registration succeeds with a new address.
|
|
#[test]
|
|
fn name_can_be_reused_after_actor_death() {
|
|
let rt = std_runtime(RuntimeConfig::default());
|
|
let first = rt.spawn_named("worker", PingPongActor).unwrap();
|
|
rt.tick();
|
|
rt.stop_actor(first).unwrap();
|
|
rt.tick(); // cleanup frees the name
|
|
|
|
let second = rt.spawn_named("worker", PingPongActor).unwrap();
|
|
assert_ne!(first, second, "new actor should have a different address");
|
|
assert_eq!(rt.where_is("worker"), Some(second));
|
|
}
|
|
|
|
/// Given a named actor panics (and is not restartable),
|
|
/// when the next tick runs cleanup,
|
|
/// then the name is freed.
|
|
#[test]
|
|
fn name_auto_unregistered_on_panic() {
|
|
let rt = std_runtime(RuntimeConfig::default());
|
|
let inbox = rt.new_inbox::<Pong>().unwrap();
|
|
let _addr = rt.spawn_named("fragile", PanicActor).unwrap();
|
|
rt.tick(); // on_start
|
|
|
|
rt.send_to(_addr, PanicMsg).unwrap();
|
|
rt.tick(); // panic -> poison -> cleanup
|
|
|
|
assert_eq!(rt.where_is("fragile"), None, "name freed after panic");
|
|
// Can reuse the name
|
|
let _new = rt.spawn_named("fragile", PingPongActor).unwrap();
|
|
assert!(rt.where_is("fragile").is_some());
|
|
drop(inbox);
|
|
}
|
|
|
|
/// Given multiple named actors are registered,
|
|
/// when I call registered_names(),
|
|
/// then all names are returned.
|
|
#[test]
|
|
fn registered_names_lists_all() {
|
|
let rt = std_runtime(RuntimeConfig::default());
|
|
rt.spawn_named("alpha", PingPongActor).unwrap();
|
|
rt.spawn_named("beta", PingPongActor).unwrap();
|
|
rt.spawn_named("gamma", PingPongActor).unwrap();
|
|
|
|
let mut names = rt.registered_names();
|
|
names.sort();
|
|
assert_eq!(names, vec!["alpha", "beta", "gamma"]);
|
|
}
|
|
|
|
/// Given a named actor exists,
|
|
/// when I manually unregister the name,
|
|
/// then the name is freed but the actor continues running.
|
|
#[test]
|
|
fn manual_unregister_frees_name_but_actor_lives() {
|
|
let rt = std_runtime(RuntimeConfig::default());
|
|
let inbox = rt.new_inbox::<Pong>().unwrap();
|
|
let addr = rt.spawn_named("temp-name", PingPongActor).unwrap();
|
|
rt.tick(); // on_start
|
|
|
|
let removed = rt.unregister("temp-name");
|
|
assert_eq!(removed, Some(addr));
|
|
assert_eq!(rt.where_is("temp-name"), None, "name freed");
|
|
|
|
// Actor still alive and can receive messages
|
|
rt.send_to(addr, Ping { reply_to: *inbox.addr() }).unwrap();
|
|
rt.tick();
|
|
assert!(inbox.try_recv().is_some(), "actor still processes messages");
|
|
}
|
|
|
|
/// An actor that looks up a peer by name using ctx.where_is().
|
|
struct NameLookupActor {
|
|
target_name: &'static str,
|
|
reply_to: ActorAddress,
|
|
}
|
|
|
|
impl ActorInterface for NameLookupActor {
|
|
type Incoming = Ping;
|
|
type Response = ();
|
|
fn handle(&mut self, ctx: &Ctx, _msg: Ping) {
|
|
if let Some(peer) = ctx.where_is(self.target_name) {
|
|
ctx.send(self.reply_to, MyAddr(peer)).unwrap();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Given a named actor exists,
|
|
/// when another actor calls ctx.where_is() from inside a handler,
|
|
/// then it resolves the correct address.
|
|
#[test]
|
|
fn ctx_where_is_resolves_inside_handler() {
|
|
let rt = std_runtime(RuntimeConfig::default());
|
|
let inbox = rt.new_inbox::<MyAddr>().unwrap();
|
|
let target = rt.spawn_named("target", PingPongActor).unwrap();
|
|
|
|
let looker = rt.spawn(NameLookupActor {
|
|
target_name: "target",
|
|
reply_to: *inbox.addr(),
|
|
}).unwrap();
|
|
|
|
rt.tick(); // on_start
|
|
rt.send_to(looker, Ping { reply_to: ActorAddress::default() }).unwrap();
|
|
rt.tick(); // handle -> where_is -> send
|
|
rt.tick(); // deliver reply
|
|
|
|
let result = inbox.try_recv();
|
|
assert_eq!(result, Some(MyAddr(target)), "ctx.where_is found the named actor");
|
|
}
|
|
|
|
/// An actor that spawns a named child using ctx.spawn_named().
|
|
struct NamedSpawnerActor {
|
|
reply_to: ActorAddress,
|
|
}
|
|
|
|
impl ActorInterface for NamedSpawnerActor {
|
|
type Incoming = Ping;
|
|
type Response = ();
|
|
fn handle(&mut self, ctx: &Ctx, _msg: Ping) {
|
|
match ctx.spawn_named("child", PingPongActor) {
|
|
Ok(addr) => { ctx.send(self.reply_to, MyAddr(addr)).unwrap(); }
|
|
Err(_) => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Given an actor calls ctx.spawn_named("child", ...),
|
|
/// when the child is spawned,
|
|
/// then where_is("child") returns the correct address.
|
|
#[test]
|
|
fn ctx_spawn_named_registers_from_handler() {
|
|
let rt = std_runtime(RuntimeConfig::default());
|
|
let inbox = rt.new_inbox::<MyAddr>().unwrap();
|
|
|
|
let spawner = rt.spawn(NamedSpawnerActor {
|
|
reply_to: *inbox.addr(),
|
|
}).unwrap();
|
|
|
|
rt.tick(); // on_start
|
|
rt.send_to(spawner, Ping { reply_to: ActorAddress::default() }).unwrap();
|
|
rt.tick(); // handle -> spawn_named
|
|
rt.tick(); // deliver reply
|
|
|
|
let child_addr = inbox.try_recv().expect("should receive child address");
|
|
assert_eq!(rt.where_is("child"), Some(child_addr.0), "name registered from handler");
|
|
}
|
|
|
|
// ── Actor Monitoring / Death Watch ──────────────────────────────────────────
|
|
|
|
/// An actor that monitors a target and forwards Down notifications to a reply address.
|
|
struct WatcherActor {
|
|
watch_target: ActorAddress,
|
|
reply_to: ActorAddress,
|
|
mref: Option<MonitorRef>,
|
|
}
|
|
|
|
impl ActorInterface for WatcherActor {
|
|
type Incoming = Down;
|
|
type Response = ();
|
|
fn on_start(&mut self, ctx: &Ctx) {
|
|
self.mref = Some(ctx.monitor(self.watch_target));
|
|
}
|
|
fn handle(&mut self, ctx: &Ctx, msg: Down) {
|
|
// Forward the Down notification to the test inbox
|
|
ctx.send(self.reply_to, msg).unwrap();
|
|
}
|
|
}
|
|
|
|
/// Given actor A monitors actor B,
|
|
/// when B is gracefully stopped,
|
|
/// then A receives a Down { reason: Normal } message.
|
|
#[test]
|
|
fn monitor_notifies_on_graceful_stop() {
|
|
let rt = std_runtime(RuntimeConfig::default());
|
|
let inbox = rt.new_inbox::<Down>().unwrap();
|
|
|
|
let target = rt.spawn(PingPongActor).unwrap();
|
|
let _watcher = rt.spawn(WatcherActor {
|
|
watch_target: target,
|
|
reply_to: *inbox.addr(),
|
|
mref: None,
|
|
}).unwrap();
|
|
|
|
rt.tick(); // on_start -> watcher sets up monitor
|
|
rt.stop_actor(target).unwrap();
|
|
rt.tick(); // target receives StopSignal -> cleanup_dead emits Down
|
|
rt.tick(); // watcher receives Down -> forwards to inbox
|
|
|
|
let down = inbox.try_recv().expect("should receive Down notification");
|
|
assert_eq!(down.addr, target);
|
|
assert_eq!(down.reason, StopReason::Normal);
|
|
}
|
|
|
|
/// Given actor A monitors actor B,
|
|
/// when B panics,
|
|
/// then A receives a Down { reason: Panicked } message.
|
|
#[test]
|
|
fn monitor_notifies_on_panic() {
|
|
let rt = std_runtime(RuntimeConfig::default());
|
|
let inbox = rt.new_inbox::<Down>().unwrap();
|
|
|
|
let target = rt.spawn(PanicActor).unwrap();
|
|
let _watcher = rt.spawn(WatcherActor {
|
|
watch_target: target,
|
|
reply_to: *inbox.addr(),
|
|
mref: None,
|
|
}).unwrap();
|
|
|
|
rt.tick(); // on_start
|
|
rt.send_to(target, PanicMsg).unwrap();
|
|
rt.tick(); // target panics -> cleanup_dead emits Down
|
|
rt.tick(); // watcher receives Down -> forwards to inbox
|
|
|
|
let down = inbox.try_recv().expect("should receive Down on panic");
|
|
assert_eq!(down.addr, target);
|
|
assert_eq!(down.reason, StopReason::Panicked);
|
|
}
|
|
|
|
/// Given two actors both monitor the same target,
|
|
/// when the target dies,
|
|
/// then both watchers receive independent Down notifications.
|
|
#[test]
|
|
fn multiple_watchers_all_notified() {
|
|
let rt = std_runtime(RuntimeConfig::default());
|
|
let inbox1 = rt.new_inbox::<Down>().unwrap();
|
|
let inbox2 = rt.new_inbox::<Down>().unwrap();
|
|
|
|
let target = rt.spawn(PingPongActor).unwrap();
|
|
rt.spawn(WatcherActor {
|
|
watch_target: target,
|
|
reply_to: *inbox1.addr(),
|
|
mref: None,
|
|
}).unwrap();
|
|
rt.spawn(WatcherActor {
|
|
watch_target: target,
|
|
reply_to: *inbox2.addr(),
|
|
mref: None,
|
|
}).unwrap();
|
|
|
|
rt.tick(); // on_start for all
|
|
rt.stop_actor(target).unwrap();
|
|
rt.tick(); // cleanup -> Down emitted to both watchers
|
|
rt.tick(); // watchers forward Down to inboxes
|
|
|
|
assert!(inbox1.try_recv().is_some(), "watcher 1 should receive Down");
|
|
assert!(inbox2.try_recv().is_some(), "watcher 2 should receive Down");
|
|
}
|
|
|
|
/// An actor that demonitors in response to a Ping message.
|
|
struct DemonitorActor {
|
|
watch_target: ActorAddress,
|
|
mref: Option<MonitorRef>,
|
|
}
|
|
|
|
impl ActorInterface for DemonitorActor {
|
|
type Incoming = Ping;
|
|
type Response = ();
|
|
fn on_start(&mut self, ctx: &Ctx) {
|
|
self.mref = Some(ctx.monitor(self.watch_target));
|
|
}
|
|
fn handle(&mut self, ctx: &Ctx, _msg: Ping) {
|
|
// Cancel the monitor
|
|
if let Some(mref) = self.mref.take() {
|
|
ctx.demonitor(mref);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Given actor A monitors actor B then demonitors,
|
|
/// when B dies,
|
|
/// then A does NOT receive a Down notification.
|
|
#[test]
|
|
fn demonitor_cancels_notification() {
|
|
let rt = std_runtime(RuntimeConfig::default());
|
|
let down_inbox = rt.new_inbox::<Down>().unwrap();
|
|
|
|
let target = rt.spawn(PingPongActor).unwrap();
|
|
let watcher = rt.spawn(DemonitorActor {
|
|
watch_target: target,
|
|
mref: None,
|
|
}).unwrap();
|
|
|
|
rt.tick(); // on_start -> monitor set up
|
|
|
|
// Trigger demonitor
|
|
rt.send_to(watcher, Ping { reply_to: ActorAddress::default() }).unwrap();
|
|
rt.tick(); // handle -> demonitor
|
|
|
|
// Now kill the target
|
|
rt.stop_actor(target).unwrap();
|
|
rt.tick(); // cleanup -- no Down should be emitted
|
|
rt.tick(); // extra tick to be sure
|
|
|
|
assert!(down_inbox.try_recv().is_none(), "demonitored -- should NOT receive Down");
|
|
}
|
|
|
|
/// Given actor A monitors B, and A dies before B,
|
|
/// when B dies,
|
|
/// then no Down is delivered (dead watcher cleaned up).
|
|
#[test]
|
|
fn dead_watcher_does_not_receive_down() {
|
|
let rt = std_runtime(RuntimeConfig::default());
|
|
|
|
let target = rt.spawn(PingPongActor).unwrap();
|
|
let watcher = rt.spawn(WatcherActor {
|
|
watch_target: target,
|
|
reply_to: ActorAddress::default(), // won't matter, watcher dies first
|
|
mref: None,
|
|
}).unwrap();
|
|
|
|
rt.tick(); // on_start -> monitor set up
|
|
rt.stop_actor(watcher).unwrap();
|
|
rt.tick(); // watcher dies -> its monitors are cleaned up
|
|
|
|
// Now kill the target -- the dead watcher's subscription should be gone
|
|
rt.stop_actor(target).unwrap();
|
|
rt.tick(); // cleanup -- should not panic or try to deliver to dead watcher
|
|
// If we get here without panic, the test passes
|
|
}
|
|
|
|
/// Given an external inbox monitors via the runtime,
|
|
/// when the target dies,
|
|
/// then the inbox receives a Down message.
|
|
#[test]
|
|
fn down_delivered_to_external_inbox() {
|
|
let rt = std_runtime(RuntimeConfig::default());
|
|
let inbox = rt.new_inbox::<Down>().unwrap();
|
|
|
|
let target = rt.spawn(PingPongActor).unwrap();
|
|
|
|
let _watcher = rt.spawn(WatcherActor {
|
|
watch_target: target,
|
|
reply_to: *inbox.addr(),
|
|
mref: None,
|
|
}).unwrap();
|
|
|
|
rt.tick(); // on_start
|
|
rt.stop_actor(target).unwrap();
|
|
rt.tick(); // cleanup -> Down to watcher
|
|
rt.tick(); // watcher forwards to inbox
|
|
|
|
let down = inbox.try_recv().expect("inbox should receive forwarded Down");
|
|
assert_eq!(down.addr, target);
|
|
assert_eq!(down.reason, StopReason::Normal);
|
|
}
|
|
|
|
/// Given actor A monitors B with two independent monitors,
|
|
/// when B dies,
|
|
/// then A receives two Down messages (one per monitor).
|
|
#[test]
|
|
fn stacked_monitors_produce_multiple_notifications() {
|
|
/// An actor that creates two monitors on the same target.
|
|
struct DoubleWatcherActor {
|
|
target: ActorAddress,
|
|
reply_to: ActorAddress,
|
|
}
|
|
|
|
impl ActorInterface for DoubleWatcherActor {
|
|
type Incoming = Down;
|
|
type Response = ();
|
|
fn on_start(&mut self, ctx: &Ctx) {
|
|
ctx.monitor(self.target);
|
|
ctx.monitor(self.target);
|
|
}
|
|
fn handle(&mut self, ctx: &Ctx, msg: Down) {
|
|
ctx.send(self.reply_to, msg).unwrap();
|
|
}
|
|
}
|
|
|
|
let rt = std_runtime(RuntimeConfig::default());
|
|
let inbox = rt.new_inbox::<Down>().unwrap();
|
|
|
|
let target = rt.spawn(PingPongActor).unwrap();
|
|
rt.spawn(DoubleWatcherActor {
|
|
target,
|
|
reply_to: *inbox.addr(),
|
|
}).unwrap();
|
|
|
|
rt.tick(); // on_start -> 2 monitors
|
|
rt.stop_actor(target).unwrap();
|
|
rt.tick(); // cleanup -> 2 Down messages to watcher
|
|
rt.tick(); // watcher forwards both to inbox
|
|
|
|
assert!(inbox.try_recv().is_some(), "first Down");
|
|
assert!(inbox.try_recv().is_some(), "second Down");
|
|
assert!(inbox.try_recv().is_none(), "no more");
|
|
}
|
|
|
|
// ── Actor Groups / Pub-Sub ──────────────────────────────────────────────────
|
|
|
|
/// Given actors join a group,
|
|
/// when I query group_members,
|
|
/// then all joined actors are listed.
|
|
#[test]
|
|
fn group_members_returns_joined_actors() {
|
|
let rt = std_runtime(RuntimeConfig::default());
|
|
let a = rt.spawn(PingPongActor).unwrap();
|
|
let b = rt.spawn(PingPongActor).unwrap();
|
|
|
|
rt.join_group(a, "workers");
|
|
rt.join_group(b, "workers");
|
|
|
|
let mut members = rt.group_members("workers");
|
|
members.sort_by_key(|addr| addr.0);
|
|
let mut expected = vec![a, b];
|
|
expected.sort_by_key(|addr| addr.0);
|
|
assert_eq!(members, expected);
|
|
}
|
|
|
|
/// Given no actors have joined a group,
|
|
/// when I query group_members,
|
|
/// then the result is empty.
|
|
#[test]
|
|
fn empty_group_returns_no_members() {
|
|
let rt = std_runtime(RuntimeConfig::default());
|
|
assert!(rt.group_members("nonexistent").is_empty());
|
|
}
|
|
|
|
/// Given actors in a group,
|
|
/// when a message is published to the group,
|
|
/// then all members receive the message.
|
|
#[test]
|
|
fn publish_broadcasts_to_all_members() {
|
|
let rt = std_runtime(RuntimeConfig::default());
|
|
let inbox1 = rt.new_inbox::<Pong>().unwrap();
|
|
let inbox2 = rt.new_inbox::<Pong>().unwrap();
|
|
|
|
let a = rt.spawn(PingPongActor).unwrap();
|
|
let b = rt.spawn(PingPongActor).unwrap();
|
|
rt.join_group(a, "pongers");
|
|
rt.join_group(b, "pongers");
|
|
|
|
rt.tick(); // on_start
|
|
|
|
// Publish a Ping with inbox1's addr as reply_to.
|
|
let count = rt.publish_to("pongers", Ping { reply_to: *inbox1.addr() });
|
|
assert_eq!(count, 2, "two members, two messages sent");
|
|
|
|
rt.tick(); // actors handle Ping -> send Pong to inbox1
|
|
|
|
// Both actors send to inbox1
|
|
assert!(inbox1.try_recv().is_some(), "first Pong");
|
|
assert!(inbox1.try_recv().is_some(), "second Pong");
|
|
assert!(inbox1.try_recv().is_none(), "no more");
|
|
drop(inbox2);
|
|
}
|
|
|
|
/// Given an actor leaves a group,
|
|
/// when a message is published,
|
|
/// then the leaver does not receive it.
|
|
#[test]
|
|
fn leave_group_stops_receiving_publishes() {
|
|
let rt = std_runtime(RuntimeConfig::default());
|
|
let inbox = rt.new_inbox::<Pong>().unwrap();
|
|
|
|
let a = rt.spawn(PingPongActor).unwrap();
|
|
let b = rt.spawn(PingPongActor).unwrap();
|
|
rt.join_group(a, "pool");
|
|
rt.join_group(b, "pool");
|
|
rt.leave_group(b, "pool");
|
|
|
|
rt.tick(); // on_start
|
|
let count = rt.publish_to("pool", Ping { reply_to: *inbox.addr() });
|
|
assert_eq!(count, 1, "only one member after leave");
|
|
|
|
rt.tick();
|
|
assert!(inbox.try_recv().is_some(), "one Pong from remaining member");
|
|
assert!(inbox.try_recv().is_none(), "no second Pong");
|
|
}
|
|
|
|
/// Given a group member dies,
|
|
/// when a message is published,
|
|
/// then the dead member is not included.
|
|
#[test]
|
|
fn dead_actor_auto_removed_from_group() {
|
|
let rt = std_runtime(RuntimeConfig::default());
|
|
let inbox = rt.new_inbox::<Pong>().unwrap();
|
|
|
|
let a = rt.spawn(PingPongActor).unwrap();
|
|
let b = rt.spawn(PingPongActor).unwrap();
|
|
rt.join_group(a, "team");
|
|
rt.join_group(b, "team");
|
|
|
|
rt.tick(); // on_start
|
|
rt.stop_actor(b).unwrap();
|
|
rt.tick(); // b dies, cleaned up from group
|
|
|
|
let count = rt.publish_to("team", Ping { reply_to: *inbox.addr() });
|
|
assert_eq!(count, 1, "dead actor removed from group");
|
|
|
|
rt.tick();
|
|
assert!(inbox.try_recv().is_some());
|
|
assert!(inbox.try_recv().is_none());
|
|
}
|
|
|
|
/// Given an actor is in multiple groups,
|
|
/// when the actor dies,
|
|
/// then it is removed from all groups.
|
|
#[test]
|
|
fn actor_removed_from_all_groups_on_death() {
|
|
let rt = std_runtime(RuntimeConfig::default());
|
|
let actor = rt.spawn(PingPongActor).unwrap();
|
|
rt.join_group(actor, "alpha");
|
|
rt.join_group(actor, "beta");
|
|
rt.join_group(actor, "gamma");
|
|
|
|
rt.tick();
|
|
rt.stop_actor(actor).unwrap();
|
|
rt.tick(); // cleanup removes from all groups
|
|
|
|
assert!(rt.group_members("alpha").is_empty());
|
|
assert!(rt.group_members("beta").is_empty());
|
|
assert!(rt.group_members("gamma").is_empty());
|
|
}
|
|
|
|
/// Given a group becomes empty after its last member leaves,
|
|
/// then the group name disappears from the active groups list.
|
|
#[test]
|
|
fn empty_group_auto_deleted() {
|
|
let rt = std_runtime(RuntimeConfig::default());
|
|
let actor = rt.spawn(PingPongActor).unwrap();
|
|
rt.join_group(actor, "temp");
|
|
assert!(rt.groups().contains(&"temp".to_string()));
|
|
|
|
rt.leave_group(actor, "temp");
|
|
assert!(!rt.groups().contains(&"temp".to_string()), "empty group should be removed");
|
|
}
|
|
|
|
/// Given actors join groups from handlers using ctx.join_group(),
|
|
/// when group_members is queried,
|
|
/// then the joining actors are listed.
|
|
#[test]
|
|
fn ctx_join_group_from_handler() {
|
|
struct GroupJoinerActor;
|
|
|
|
impl ActorInterface for GroupJoinerActor {
|
|
type Incoming = Ping;
|
|
type Response = ();
|
|
fn on_start(&mut self, ctx: &Ctx) {
|
|
ctx.join_group("auto-joined");
|
|
}
|
|
fn handle(&mut self, _ctx: &Ctx, _msg: Ping) {}
|
|
}
|
|
|
|
let rt = std_runtime(RuntimeConfig::default());
|
|
let a = rt.spawn(GroupJoinerActor).unwrap();
|
|
let b = rt.spawn(GroupJoinerActor).unwrap();
|
|
|
|
rt.tick(); // on_start -> both join "auto-joined"
|
|
|
|
let members = rt.group_members("auto-joined");
|
|
assert_eq!(members.len(), 2);
|
|
assert!(members.contains(&a));
|
|
assert!(members.contains(&b));
|
|
}
|
|
|
|
/// Given an actor uses ctx.publish() from inside a handler,
|
|
/// when the published message is processed,
|
|
/// then all group members receive it.
|
|
#[test]
|
|
fn ctx_publish_broadcasts_from_handler() {
|
|
#[derive(Clone)]
|
|
struct BroadcastCmd {
|
|
reply_to: ActorAddress,
|
|
}
|
|
|
|
struct BroadcasterActor;
|
|
|
|
impl ActorInterface for BroadcasterActor {
|
|
type Incoming = BroadcastCmd;
|
|
type Response = ();
|
|
fn on_start(&mut self, ctx: &Ctx) {
|
|
ctx.join_group("broadcast-test");
|
|
}
|
|
fn handle(&mut self, ctx: &Ctx, msg: BroadcastCmd) {
|
|
ctx.publish("broadcast-test", Ping { reply_to: msg.reply_to });
|
|
}
|
|
}
|
|
|
|
let rt = std_runtime(RuntimeConfig::default());
|
|
let inbox = rt.new_inbox::<Pong>().unwrap();
|
|
|
|
// Spawn 3 PingPongActors and one Broadcaster, all in the same group
|
|
let _p1 = rt.spawn(PingPongActor).unwrap();
|
|
let _p2 = rt.spawn(PingPongActor).unwrap();
|
|
rt.join_group(_p1, "broadcast-test");
|
|
rt.join_group(_p2, "broadcast-test");
|
|
|
|
let broadcaster = rt.spawn(BroadcasterActor).unwrap();
|
|
|
|
rt.tick(); // on_start (broadcaster joins group too)
|
|
|
|
// Send BroadcastCmd to broadcaster
|
|
rt.send_to(broadcaster, BroadcastCmd { reply_to: *inbox.addr() }).unwrap();
|
|
rt.tick(); // broadcaster handles -> publish Ping to all 3 members (including self)
|
|
rt.tick(); // PingPong actors handle Ping -> send Pong to inbox
|
|
|
|
// At least 2 Pongs from the PingPongActors
|
|
let mut pong_count = 0;
|
|
while inbox.try_recv().is_some() {
|
|
pong_count += 1;
|
|
}
|
|
assert!(pong_count >= 2, "at least 2 PingPong members should reply, got {pong_count}");
|
|
}
|
|
|
|
// ── Ask Pattern ─────────────────────────────────────────────────────────────
|
|
|
|
/// Given a PingPong actor,
|
|
/// when I ask with recv_ticking,
|
|
/// then I get the Pong response.
|
|
#[test]
|
|
fn ask_recv_ticking_returns_response() {
|
|
let rt = std_runtime(RuntimeConfig::default());
|
|
let actor = rt.spawn(PingPongActor).unwrap();
|
|
rt.tick(); // on_start
|
|
|
|
let pong: Pong = rt.ask(actor, |reply_to| Ping { reply_to })
|
|
.unwrap()
|
|
.recv_ticking(&rt, 10)
|
|
.unwrap();
|
|
assert_eq!(pong, Pong);
|
|
}
|
|
|
|
/// Given a CounterActor,
|
|
/// when I ask multiple times,
|
|
/// then each response reflects the updated state.
|
|
#[test]
|
|
fn ask_multiple_times_tracks_state() {
|
|
let rt = std_runtime(RuntimeConfig::default());
|
|
let actor = rt.spawn(CounterActor { count: 0 }).unwrap();
|
|
rt.tick(); // on_start
|
|
|
|
let c1: Count = rt.ask(actor, |reply_to| Increment { reply_to })
|
|
.unwrap().recv_ticking(&rt, 10).unwrap();
|
|
let c2: Count = rt.ask(actor, |reply_to| Increment { reply_to })
|
|
.unwrap().recv_ticking(&rt, 10).unwrap();
|
|
let c3: Count = rt.ask(actor, |reply_to| Increment { reply_to })
|
|
.unwrap().recv_ticking(&rt, 10).unwrap();
|
|
|
|
assert_eq!(c1, Count(1));
|
|
assert_eq!(c2, Count(2));
|
|
assert_eq!(c3, Count(3));
|
|
}
|
|
|
|
/// Given a dead actor,
|
|
/// when I ask and tick,
|
|
/// then recv_ticking returns a timeout error.
|
|
#[test]
|
|
fn ask_timeout_when_no_response() {
|
|
let rt = std_runtime(RuntimeConfig::default());
|
|
let actor = rt.spawn(PingPongActor).unwrap();
|
|
rt.tick();
|
|
rt.stop_actor(actor).unwrap();
|
|
rt.tick(); // actor dies
|
|
|
|
// Ask the dead actor -- message is undeliverable, no response
|
|
let result = rt.ask::<Ping, Pong>(actor, |reply_to| Ping { reply_to });
|
|
// send_to may succeed or fail
|
|
if let Ok(ask) = result {
|
|
let err = ask.recv_ticking(&rt, 5);
|
|
assert!(err.is_err(), "should timeout with no response");
|
|
}
|
|
}
|
|
|
|
/// Given an ask handle,
|
|
/// when I use try_recv before ticking,
|
|
/// then it returns None (response hasn't arrived yet).
|
|
#[test]
|
|
fn ask_try_recv_returns_none_before_tick() {
|
|
let rt = std_runtime(RuntimeConfig::default());
|
|
let actor = rt.spawn(PingPongActor).unwrap();
|
|
rt.tick(); // on_start
|
|
|
|
let ask = rt.ask::<Ping, Pong>(actor, |reply_to| Ping { reply_to }).unwrap();
|
|
assert!(ask.try_recv().is_none(), "no response before ticking");
|
|
|
|
rt.tick(); // process message
|
|
assert_eq!(ask.try_recv(), Some(Pong));
|
|
}
|
|
|
|
/// Given an ask, the reply_addr() returns the inbox address for manual use.
|
|
#[test]
|
|
fn ask_reply_addr_is_accessible() {
|
|
let rt = std_runtime(RuntimeConfig::default());
|
|
let actor = rt.spawn(PingPongActor).unwrap();
|
|
rt.tick();
|
|
|
|
let ask = rt.ask::<Ping, Pong>(actor, |reply_to| Ping { reply_to }).unwrap();
|
|
let addr = *ask.reply_addr();
|
|
// The address should be valid (non-zero)
|
|
assert_ne!(addr, ActorAddress::default());
|
|
}
|