feat: consolidate tests
This commit is contained in:
parent
41af749bb4
commit
d9489ccac2
13 changed files with 2398 additions and 4711 deletions
790
tests/actor_lifecycle.rs
Normal file
790
tests/actor_lifecycle.rs
Normal file
|
|
@ -0,0 +1,790 @@
|
|||
//! Actor Lifecycle Tests — birth, life, death of individual actors.
|
||||
//!
|
||||
//! Covers: spawning, on_start, parent-child delegation, graceful stop,
|
||||
//! panic isolation, dead actor cleanup, watching (ActorExited), and
|
||||
//! monitoring (Down notifications).
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
// ── Local actors ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Records lifecycle events to shared counters.
|
||||
struct LifecycleActor {
|
||||
started: Arc<AtomicUsize>,
|
||||
stopped: Arc<AtomicUsize>,
|
||||
handled: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl ActorInterface for LifecycleActor {
|
||||
type Incoming = Ping;
|
||||
type Response = Pong;
|
||||
fn on_start(&mut self, _ctx: &Ctx) {
|
||||
self.started.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
fn on_stop(&mut self, _ctx: &Ctx) {
|
||||
self.stopped.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Ping) {
|
||||
self.handled.fetch_add(1, Ordering::Relaxed);
|
||||
let _ = ctx.send(msg.reply_to, Pong);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops itself after processing `stop_after` messages.
|
||||
struct SelfStopActor {
|
||||
count: usize,
|
||||
stop_after: usize,
|
||||
stopped: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl ActorInterface for SelfStopActor {
|
||||
type Incoming = Forward;
|
||||
type Response = Done;
|
||||
fn on_stop(&mut self, _ctx: &Ctx) {
|
||||
self.stopped.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Forward) {
|
||||
self.count += 1;
|
||||
let _ = ctx.send(msg.reply_to, Done(msg.value));
|
||||
if self.count >= self.stop_after {
|
||||
ctx.stop_self();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends a farewell Pong in on_stop.
|
||||
struct FarewellActor {
|
||||
farewell_to: ActorAddress,
|
||||
}
|
||||
|
||||
impl ActorInterface for FarewellActor {
|
||||
type Incoming = Ping;
|
||||
type Response = Pong;
|
||||
fn on_stop(&mut self, ctx: &Ctx) {
|
||||
let _ = ctx.send(self.farewell_to, Pong);
|
||||
}
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Ping) {
|
||||
let _ = ctx.send(msg.reply_to, Pong);
|
||||
}
|
||||
}
|
||||
|
||||
/// Panics in on_start.
|
||||
struct PanicOnStartActor {
|
||||
handled: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl ActorInterface for PanicOnStartActor {
|
||||
type Incoming = Ping;
|
||||
type Response = Pong;
|
||||
fn on_start(&mut self, _ctx: &Ctx) {
|
||||
panic!("on_start panic");
|
||||
}
|
||||
fn handle(&mut self, _ctx: &Ctx, _msg: Ping) {
|
||||
self.handled.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns a DoubleActor child, sends it work, then panics.
|
||||
struct SpawnThenPanicActor;
|
||||
|
||||
impl ActorInterface for SpawnThenPanicActor {
|
||||
type Incoming = Forward;
|
||||
type Response = ();
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Forward) {
|
||||
let child = ctx.spawn(DoubleActor).unwrap();
|
||||
let _ = ctx.send(child, Forward { value: msg.value, reply_to: msg.reply_to });
|
||||
panic!("intentional panic after spawn+send");
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends a Pong reply, then panics.
|
||||
struct SendThenPanicActor;
|
||||
|
||||
impl ActorInterface for SendThenPanicActor {
|
||||
type Incoming = Ping;
|
||||
type Response = Pong;
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Ping) {
|
||||
let _ = ctx.send(msg.reply_to, Pong);
|
||||
panic!("intentional panic after send");
|
||||
}
|
||||
}
|
||||
|
||||
/// Processes `remaining_good` messages then panics.
|
||||
struct PanicAfterNActor {
|
||||
remaining_good: usize,
|
||||
counter: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl ActorInterface for PanicAfterNActor {
|
||||
type Incoming = Ping;
|
||||
type Response = ();
|
||||
fn handle(&mut self, _ctx: &Ctx, _msg: Ping) {
|
||||
if self.remaining_good == 0 {
|
||||
panic!("intentional delayed panic");
|
||||
}
|
||||
self.remaining_good -= 1;
|
||||
self.counter.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops on a trigger message.
|
||||
struct StopOnTrigger(Arc<AtomicUsize>);
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Trigger(bool);
|
||||
|
||||
impl ActorInterface for StopOnTrigger {
|
||||
type Incoming = Trigger;
|
||||
type Response = ();
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Trigger) {
|
||||
self.0.fetch_add(1, Ordering::Relaxed);
|
||||
if msg.0 {
|
||||
ctx.stop_self();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Watches targets and counts exit notifications via on_actor_exit.
|
||||
struct ExitWatcher {
|
||||
exit_count: Arc<AtomicUsize>,
|
||||
last_reason: Arc<std::sync::Mutex<Option<ExitReason>>>,
|
||||
last_addr: Arc<std::sync::Mutex<Option<ActorAddress>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum WatcherCmd {
|
||||
WatchThis(ActorAddress),
|
||||
UnwatchThis(ActorAddress),
|
||||
}
|
||||
|
||||
impl ActorInterface for ExitWatcher {
|
||||
type Incoming = WatcherCmd;
|
||||
type Response = ();
|
||||
fn handle(&mut self, ctx: &Ctx, msg: WatcherCmd) {
|
||||
match msg {
|
||||
WatcherCmd::WatchThis(target) => ctx.watch(target),
|
||||
WatcherCmd::UnwatchThis(target) => ctx.unwatch(target),
|
||||
}
|
||||
}
|
||||
fn on_actor_exit(&mut self, _ctx: &Ctx, exited: ActorExited) {
|
||||
self.exit_count.fetch_add(1, Ordering::SeqCst);
|
||||
*self.last_reason.lock().unwrap() = Some(exited.reason);
|
||||
*self.last_addr.lock().unwrap() = Some(exited.addr);
|
||||
}
|
||||
}
|
||||
|
||||
struct WatcherState {
|
||||
exit_count: Arc<AtomicUsize>,
|
||||
last_reason: Arc<std::sync::Mutex<Option<ExitReason>>>,
|
||||
}
|
||||
|
||||
impl WatcherState {
|
||||
fn count(&self) -> usize {
|
||||
self.exit_count.load(Ordering::SeqCst)
|
||||
}
|
||||
fn last_reason(&self) -> Option<ExitReason> {
|
||||
self.last_reason.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn new_exit_watcher() -> (ExitWatcher, WatcherState) {
|
||||
let exit_count = Arc::new(AtomicUsize::new(0));
|
||||
let last_reason = Arc::new(std::sync::Mutex::new(None));
|
||||
let last_addr = Arc::new(std::sync::Mutex::new(None));
|
||||
let state = WatcherState {
|
||||
exit_count: exit_count.clone(),
|
||||
last_reason: last_reason.clone(),
|
||||
};
|
||||
(
|
||||
ExitWatcher { exit_count, last_reason, last_addr },
|
||||
state,
|
||||
)
|
||||
}
|
||||
|
||||
/// Monitors a target and forwards Down to a reply address.
|
||||
struct MonitorWatcherActor {
|
||||
watch_target: ActorAddress,
|
||||
reply_to: ActorAddress,
|
||||
mref: Option<MonitorRef>,
|
||||
}
|
||||
|
||||
impl ActorInterface for MonitorWatcherActor {
|
||||
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) {
|
||||
ctx.send(self.reply_to, msg).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Demonitors on Ping.
|
||||
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) {
|
||||
if let Some(mref) = self.mref.take() {
|
||||
ctx.demonitor(mref);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A silent actor that does nothing (target for watching tests).
|
||||
struct Sleeper;
|
||||
#[derive(Clone)]
|
||||
struct Noop;
|
||||
|
||||
impl ActorInterface for Sleeper {
|
||||
type Incoming = Noop;
|
||||
type Response = ();
|
||||
fn handle(&mut self, _ctx: &Ctx, _msg: Noop) {}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Actors are spawned, on_start fires exactly once per instance before any
|
||||
/// message, then state accumulates across messages.
|
||||
#[test]
|
||||
fn actor_from_birth_to_first_message() {
|
||||
let started = Arc::new(AtomicUsize::new(0));
|
||||
let stopped = Arc::new(AtomicUsize::new(0));
|
||||
let handled = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
|
||||
// Spawn one tracked actor + 4 more sharing the same counters
|
||||
let addr = rt.spawn(LifecycleActor {
|
||||
started: started.clone(),
|
||||
stopped: stopped.clone(),
|
||||
handled: handled.clone(),
|
||||
}).unwrap();
|
||||
for _ in 0..4 {
|
||||
rt.spawn(LifecycleActor {
|
||||
started: started.clone(),
|
||||
stopped: stopped.clone(),
|
||||
handled: handled.clone(),
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
// First tick: all 5 on_start fire, no messages processed yet
|
||||
rt.tick();
|
||||
assert_eq!(started.load(Ordering::Relaxed), 5, "on_start per instance");
|
||||
assert_eq!(handled.load(Ordering::Relaxed), 0, "no messages before first send");
|
||||
|
||||
// Send 3 Increments to a CounterActor to verify state accumulation
|
||||
let counter_addr = rt.spawn(CounterActor { count: 0 }).unwrap();
|
||||
let count_inbox = rt.new_inbox::<Count>().unwrap();
|
||||
for _ in 0..3 {
|
||||
rt.send_to(counter_addr, Increment { reply_to: *count_inbox.addr() }).unwrap();
|
||||
}
|
||||
let replies = tick_and_drain(&rt, &count_inbox, 10);
|
||||
assert_eq!(replies, vec![Count(1), Count(2), Count(3)], "state accumulates");
|
||||
|
||||
// on_start must not fire again on subsequent ticks
|
||||
rt.tick();
|
||||
rt.tick();
|
||||
assert_eq!(started.load(Ordering::Relaxed), 5, "on_start not repeated");
|
||||
|
||||
// Verify the first actor still responds normally
|
||||
rt.send_to(addr, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||
let reply = tick_until_recv(&rt, &inbox, 10);
|
||||
assert!(reply.is_some(), "actor handles messages after on_start");
|
||||
}
|
||||
|
||||
/// Delegation chains: parent spawns child, child spawns grandchild, fan-out
|
||||
/// distributes work. Spawn+send interleaving in a single handler works.
|
||||
#[test]
|
||||
fn parent_child_delegation_and_spawn_chains() {
|
||||
let rt = std_runtime(RuntimeConfig {
|
||||
max_actors: 2000,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Act 1: DelegatorActor spawns child, forwards value 7 → Done(14)
|
||||
let delegator = rt.spawn(DelegatorActor).unwrap();
|
||||
let inbox = rt.new_inbox::<Done>().unwrap();
|
||||
rt.send_to(delegator, Forward { value: 7, reply_to: *inbox.addr() }).unwrap();
|
||||
let reply = tick_until_recv(&rt, &inbox, 20);
|
||||
assert_eq!(reply, Some(Done(14)), "delegator child doubles value");
|
||||
|
||||
// Act 2: Chain of depth 20
|
||||
let chain = rt.spawn(ChainActor).unwrap();
|
||||
rt.send_to(chain, ChainMsg { remaining: 20, depth: 0, reply_to: *inbox.addr() }).unwrap();
|
||||
let reply = tick_until_recv(&rt, &inbox, 200);
|
||||
assert_eq!(reply, Some(Done(20)), "chain reaches depth 20");
|
||||
|
||||
// Act 3: Fan-out to 20 children
|
||||
let fan = rt.spawn(FanOutActor).unwrap();
|
||||
rt.send_to(fan, FanOut { count: 20, reply_to: *inbox.addr() }).unwrap();
|
||||
let replies = tick_and_drain(&rt, &inbox, 50);
|
||||
assert_eq!(replies.len(), 20, "all 20 fan-out children reply");
|
||||
}
|
||||
|
||||
/// The full graceful-stop story: self-stop with on_stop, farewell messages,
|
||||
/// external stop ordering vs pending messages, mid-mailbox stop trigger.
|
||||
#[test]
|
||||
fn graceful_stop_lifecycle() {
|
||||
// --- Part A: SelfStopActor ---
|
||||
let stopped = Arc::new(AtomicUsize::new(0));
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Done>().unwrap();
|
||||
|
||||
let addr = rt.spawn(SelfStopActor {
|
||||
count: 0,
|
||||
stop_after: 3,
|
||||
stopped: stopped.clone(),
|
||||
}).unwrap();
|
||||
|
||||
for i in 0..5 {
|
||||
let _ = rt.send_to(addr, Forward { value: i, reply_to: *inbox.addr() });
|
||||
}
|
||||
tick_n(&rt, 10);
|
||||
|
||||
let mut replies = Vec::new();
|
||||
while let Some(Done(v)) = inbox.try_recv() {
|
||||
replies.push(v);
|
||||
}
|
||||
assert_eq!(replies.len(), 3, "only 3 messages processed before self-stop");
|
||||
assert_eq!(stopped.load(Ordering::Relaxed), 1, "on_stop fired");
|
||||
assert!(rt.send_to(addr, Forward { value: 99, reply_to: *inbox.addr() }).is_err(),
|
||||
"send to stopped actor fails");
|
||||
|
||||
// --- Part B: FarewellActor sends farewell in on_stop ---
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
let addr = rt.spawn(FarewellActor { farewell_to: *inbox.addr() }).unwrap();
|
||||
rt.tick();
|
||||
rt.stop_actor(addr).unwrap();
|
||||
tick_n(&rt, 5);
|
||||
assert_eq!(inbox.try_recv(), Some(Pong), "farewell message delivered from on_stop");
|
||||
|
||||
// --- Part C: External stop after pending messages ---
|
||||
let stopped = Arc::new(AtomicUsize::new(0));
|
||||
let handled = Arc::new(AtomicUsize::new(0));
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
let addr = rt.spawn(LifecycleActor {
|
||||
started: Arc::new(AtomicUsize::new(0)),
|
||||
stopped: stopped.clone(),
|
||||
handled: handled.clone(),
|
||||
}).unwrap();
|
||||
for _ in 0..10 {
|
||||
let _ = rt.send_to(addr, Ping { reply_to: *inbox.addr() });
|
||||
}
|
||||
rt.stop_actor(addr).unwrap();
|
||||
tick_n(&rt, 10);
|
||||
assert_eq!(handled.load(Ordering::Relaxed), 10, "all pending messages processed before stop");
|
||||
assert_eq!(stopped.load(Ordering::Relaxed), 1, "on_stop fires after messages");
|
||||
|
||||
// --- Part D: External stop before messages → 0 processed ---
|
||||
let handled = Arc::new(AtomicUsize::new(0));
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
let addr = rt.spawn(LifecycleActor {
|
||||
started: Arc::new(AtomicUsize::new(0)),
|
||||
stopped: Arc::new(AtomicUsize::new(0)),
|
||||
handled: handled.clone(),
|
||||
}).unwrap();
|
||||
rt.tick(); // on_start
|
||||
rt.stop_actor(addr).unwrap();
|
||||
for _ in 0..5 {
|
||||
let _ = rt.send_to(addr, Ping { reply_to: *inbox.addr() });
|
||||
}
|
||||
tick_n(&rt, 10);
|
||||
assert_eq!(handled.load(Ordering::Relaxed), 0, "stop before messages prevents processing");
|
||||
|
||||
// --- Part E: Mid-mailbox stop trigger ---
|
||||
let processed = Arc::new(AtomicUsize::new(0));
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let addr = rt.spawn(StopOnTrigger(processed.clone())).unwrap();
|
||||
rt.tick();
|
||||
rt.send_to(addr, Trigger(false)).unwrap();
|
||||
rt.send_to(addr, Trigger(false)).unwrap();
|
||||
rt.send_to(addr, Trigger(true)).unwrap(); // stop trigger
|
||||
rt.send_to(addr, Trigger(false)).unwrap();
|
||||
rt.send_to(addr, Trigger(false)).unwrap();
|
||||
tick_n(&rt, 5);
|
||||
assert_eq!(processed.load(Ordering::Relaxed), 3,
|
||||
"only messages up to and including stop trigger processed");
|
||||
assert!(rt.send_to(addr, Trigger(false)).is_err());
|
||||
}
|
||||
|
||||
/// Panics are caught: healthy siblings survive, panicked actors are poisoned
|
||||
/// and cleaned from stats/address map, mid-batch panic discards remaining,
|
||||
/// child spawned before parent panic survives, message sent before panic is
|
||||
/// delivered, bulk cleanup, on_start panic also poisons.
|
||||
#[test]
|
||||
fn panic_isolation_and_cleanup() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
let count_inbox = rt.new_inbox::<Count>().unwrap();
|
||||
|
||||
// Spawn a healthy counter, a PanicActor, and a PanicOnStartActor
|
||||
let good = rt.spawn(CounterActor { count: 0 }).unwrap();
|
||||
let bad = rt.spawn(PanicActor).unwrap();
|
||||
let bad_start_handled = Arc::new(AtomicUsize::new(0));
|
||||
let bad_start = rt.spawn(PanicOnStartActor { handled: bad_start_handled.clone() }).unwrap();
|
||||
|
||||
// Trigger panics
|
||||
rt.send_to(bad, PanicMsg).unwrap();
|
||||
let _ = rt.send_to(bad_start, Ping { reply_to: *inbox.addr() });
|
||||
tick_n(&rt, 10);
|
||||
|
||||
// Healthy actor still works
|
||||
rt.send_to(good, Increment { reply_to: *count_inbox.addr() }).unwrap();
|
||||
rt.send_to(good, Increment { reply_to: *count_inbox.addr() }).unwrap();
|
||||
let replies = tick_and_drain(&rt, &count_inbox, 10);
|
||||
assert_eq!(replies, vec![Count(1), Count(2)], "healthy actor unaffected by peer panics");
|
||||
|
||||
// Poisoned actors are cleaned from address map
|
||||
assert!(rt.send_to(bad, PanicMsg).is_err(), "send to cleaned-up actor fails");
|
||||
assert_eq!(bad_start_handled.load(Ordering::Relaxed), 0, "on_start panic prevents messages");
|
||||
|
||||
// Stats track panics vs stops separately
|
||||
let stats = rt.stats();
|
||||
let panics: u64 = stats.workers.iter().map(|w| w.panics).sum();
|
||||
assert!(panics >= 2, "at least 2 panics recorded (PanicActor + PanicOnStartActor)");
|
||||
|
||||
// --- Mid-batch panic discards remaining ---
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let dummy = rt.new_inbox::<Pong>().unwrap();
|
||||
let addr = rt.spawn(PanicAfterNActor { remaining_good: 2, counter: counter.clone() }).unwrap();
|
||||
for _ in 0..5 {
|
||||
rt.send_to(addr, Ping { reply_to: *dummy.addr() }).unwrap();
|
||||
}
|
||||
tick_n(&rt, 20);
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 2, "only messages before panic processed");
|
||||
|
||||
// --- Child spawned before parent panic survives ---
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Done>().unwrap();
|
||||
let parent = rt.spawn(SpawnThenPanicActor).unwrap();
|
||||
rt.send_to(parent, Forward { value: 5, reply_to: *inbox.addr() }).unwrap();
|
||||
let reply = tick_until_recv(&rt, &inbox, 30);
|
||||
assert_eq!(reply, Some(Done(10)), "child survives parent panic");
|
||||
|
||||
// --- Message sent before panic is delivered ---
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
let addr = rt.spawn(SendThenPanicActor).unwrap();
|
||||
rt.send_to(addr, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||
let reply = tick_until_recv(&rt, &inbox, 20);
|
||||
assert!(reply.is_some(), "message sent before panic still delivered");
|
||||
|
||||
// --- Bulk cleanup: 20 panicking actors all cleaned ---
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let mut addrs = Vec::new();
|
||||
for _ in 0..20 {
|
||||
addrs.push(rt.spawn(PanicActor).unwrap());
|
||||
}
|
||||
for &addr in &addrs {
|
||||
let _ = rt.send_to(addr, PanicMsg);
|
||||
}
|
||||
tick_n(&rt, 10);
|
||||
let stats = rt.stats();
|
||||
assert_eq!(stats.workers[0].num_actors, 0, "all poisoned actors cleaned up");
|
||||
}
|
||||
|
||||
/// Watch API contract: watchers are notified on death, unwatch cancels,
|
||||
/// double-watch is idempotent, multiple watchers all notified,
|
||||
/// runtime-level watch works.
|
||||
#[test]
|
||||
fn watch_notification_contract() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
|
||||
// Spawn target + 3 watchers + 1 that unwatches
|
||||
let target = rt.spawn(PanicActor).unwrap();
|
||||
let (w1, s1) = new_exit_watcher();
|
||||
let (w2, s2) = new_exit_watcher();
|
||||
let (w3, s3) = new_exit_watcher();
|
||||
let (w4, s4) = new_exit_watcher(); // will unwatch
|
||||
|
||||
let w1_addr = rt.spawn(w1).unwrap();
|
||||
let w2_addr = rt.spawn(w2).unwrap();
|
||||
let w3_addr = rt.spawn(w3).unwrap();
|
||||
let w4_addr = rt.spawn(w4).unwrap();
|
||||
|
||||
// All watch the target
|
||||
rt.send_to(w1_addr, WatcherCmd::WatchThis(target)).unwrap();
|
||||
rt.send_to(w2_addr, WatcherCmd::WatchThis(target)).unwrap();
|
||||
rt.send_to(w3_addr, WatcherCmd::WatchThis(target)).unwrap();
|
||||
rt.send_to(w4_addr, WatcherCmd::WatchThis(target)).unwrap();
|
||||
tick_n(&rt, 3);
|
||||
|
||||
// w2 double-watches (idempotent test)
|
||||
rt.send_to(w2_addr, WatcherCmd::WatchThis(target)).unwrap();
|
||||
tick_n(&rt, 3);
|
||||
|
||||
// w4 unwatches
|
||||
rt.send_to(w4_addr, WatcherCmd::UnwatchThis(target)).unwrap();
|
||||
tick_n(&rt, 3);
|
||||
|
||||
// Kill target
|
||||
rt.send_to(target, PanicMsg).unwrap();
|
||||
tick_n(&rt, 5);
|
||||
|
||||
assert_eq!(s1.count(), 1, "watcher 1 notified");
|
||||
assert_eq!(s2.count(), 1, "double-watch still only one notification");
|
||||
assert_eq!(s3.count(), 1, "watcher 3 notified");
|
||||
assert_eq!(s4.count(), 0, "unwatched watcher not notified");
|
||||
assert_eq!(s1.last_reason(), Some(ExitReason::Panicked));
|
||||
|
||||
// --- Runtime-level watch ---
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let target = rt.spawn(PanicActor).unwrap();
|
||||
let (w, s) = new_exit_watcher();
|
||||
let w_addr = rt.spawn(w).unwrap();
|
||||
tick_n(&rt, 2);
|
||||
rt.watch(w_addr, target);
|
||||
rt.send_to(target, PanicMsg).unwrap();
|
||||
tick_n(&rt, 5);
|
||||
assert_eq!(s.count(), 1, "runtime-level watch delivers notification");
|
||||
}
|
||||
|
||||
/// Watch edge cases: watcher dies before target (no crash), self-watch (no
|
||||
/// crash), watcher reacts to death by spawning a replacement.
|
||||
#[test]
|
||||
fn watch_edge_cases() {
|
||||
// Watcher dies before target — no crash
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let target = rt.spawn(PanicActor).unwrap();
|
||||
let target2 = rt.spawn(PanicActor).unwrap();
|
||||
rt.watch(target2, target);
|
||||
tick_n(&rt, 3);
|
||||
rt.send_to(target2, PanicMsg).unwrap(); // kill watcher first
|
||||
tick_n(&rt, 5);
|
||||
rt.send_to(target, PanicMsg).unwrap(); // kill target — no crash
|
||||
tick_n(&rt, 5);
|
||||
|
||||
// Self-watch — no crash
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let (w, _s) = new_exit_watcher();
|
||||
let addr = rt.spawn(w).unwrap();
|
||||
rt.send_to(addr, WatcherCmd::WatchThis(addr)).unwrap();
|
||||
tick_n(&rt, 5);
|
||||
|
||||
// Watcher reacts to death by spawning replacement
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let spawned = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
struct SupervisorWatcher {
|
||||
spawned_count: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum SupCmd {
|
||||
WatchThis(ActorAddress),
|
||||
}
|
||||
|
||||
impl ActorInterface for SupervisorWatcher {
|
||||
type Incoming = SupCmd;
|
||||
type Response = ();
|
||||
fn handle(&mut self, ctx: &Ctx, msg: SupCmd) {
|
||||
match msg {
|
||||
SupCmd::WatchThis(target) => ctx.watch(target),
|
||||
}
|
||||
}
|
||||
fn on_actor_exit(&mut self, ctx: &Ctx, _exited: ActorExited) {
|
||||
let _ = ctx.spawn(Sleeper);
|
||||
self.spawned_count.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
let target = rt.spawn(PanicActor).unwrap();
|
||||
let sup = rt.spawn(SupervisorWatcher { spawned_count: spawned.clone() }).unwrap();
|
||||
rt.send_to(sup, SupCmd::WatchThis(target)).unwrap();
|
||||
tick_n(&rt, 3);
|
||||
rt.send_to(target, PanicMsg).unwrap();
|
||||
tick_n(&rt, 5);
|
||||
assert_eq!(spawned.load(Ordering::SeqCst), 1, "watcher spawned replacement");
|
||||
}
|
||||
|
||||
/// Monitor API contract: Down on stop (Normal) and panic (Panicked), multiple
|
||||
/// monitors, demonitor cancels, dead watcher cleanup, stacked monitors,
|
||||
/// external inbox, handle_down dispatch.
|
||||
#[test]
|
||||
fn monitor_death_notification_contract() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
|
||||
// --- Stop → Down(Normal) ---
|
||||
let inbox = rt.new_inbox::<Down>().unwrap();
|
||||
let target = rt.spawn(PingPongActor).unwrap();
|
||||
rt.spawn(MonitorWatcherActor {
|
||||
watch_target: target,
|
||||
reply_to: *inbox.addr(),
|
||||
mref: None,
|
||||
}).unwrap();
|
||||
rt.tick();
|
||||
rt.stop_actor(target).unwrap();
|
||||
tick_n(&rt, 3);
|
||||
let down = inbox.try_recv().expect("Down on graceful stop");
|
||||
assert_eq!(down.addr, target);
|
||||
assert_eq!(down.reason, StopReason::Normal);
|
||||
|
||||
// --- Panic → Down(Panicked) ---
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Down>().unwrap();
|
||||
let target = rt.spawn(PanicActor).unwrap();
|
||||
rt.spawn(MonitorWatcherActor {
|
||||
watch_target: target,
|
||||
reply_to: *inbox.addr(),
|
||||
mref: None,
|
||||
}).unwrap();
|
||||
rt.tick();
|
||||
rt.send_to(target, PanicMsg).unwrap();
|
||||
tick_n(&rt, 3);
|
||||
let down = inbox.try_recv().expect("Down on panic");
|
||||
assert_eq!(down.reason, StopReason::Panicked);
|
||||
|
||||
// --- Multiple monitors ---
|
||||
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(MonitorWatcherActor {
|
||||
watch_target: target, reply_to: *inbox1.addr(), mref: None,
|
||||
}).unwrap();
|
||||
rt.spawn(MonitorWatcherActor {
|
||||
watch_target: target, reply_to: *inbox2.addr(), mref: None,
|
||||
}).unwrap();
|
||||
rt.tick();
|
||||
rt.stop_actor(target).unwrap();
|
||||
tick_n(&rt, 3);
|
||||
assert!(inbox1.try_recv().is_some(), "watcher 1 notified");
|
||||
assert!(inbox2.try_recv().is_some(), "watcher 2 notified");
|
||||
|
||||
// --- Demonitor cancels ---
|
||||
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();
|
||||
rt.send_to(watcher, Ping { reply_to: ActorAddress::default() }).unwrap();
|
||||
rt.tick(); // demonitor
|
||||
rt.stop_actor(target).unwrap();
|
||||
tick_n(&rt, 3);
|
||||
assert!(down_inbox.try_recv().is_none(), "demonitored: no Down delivered");
|
||||
|
||||
// --- Dead watcher cleaned up ---
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let target = rt.spawn(PingPongActor).unwrap();
|
||||
let watcher = rt.spawn(MonitorWatcherActor {
|
||||
watch_target: target,
|
||||
reply_to: ActorAddress::default(),
|
||||
mref: None,
|
||||
}).unwrap();
|
||||
rt.tick();
|
||||
rt.stop_actor(watcher).unwrap();
|
||||
rt.tick(); // watcher dies
|
||||
rt.stop_actor(target).unwrap();
|
||||
tick_n(&rt, 3); // target dies — no crash trying to deliver to dead watcher
|
||||
|
||||
// --- Stacked monitors produce multiple notifications ---
|
||||
struct DoubleMonitor {
|
||||
target: ActorAddress,
|
||||
reply_to: ActorAddress,
|
||||
}
|
||||
impl ActorInterface for DoubleMonitor {
|
||||
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(DoubleMonitor { target, reply_to: *inbox.addr() }).unwrap();
|
||||
rt.tick();
|
||||
rt.stop_actor(target).unwrap();
|
||||
tick_n(&rt, 3);
|
||||
assert!(inbox.try_recv().is_some(), "first Down from stacked monitor");
|
||||
assert!(inbox.try_recv().is_some(), "second Down from stacked monitor");
|
||||
assert!(inbox.try_recv().is_none(), "no more");
|
||||
|
||||
// --- handle_down dispatch ---
|
||||
struct MonitoringTracker {
|
||||
target: ActorAddress,
|
||||
downs: Vec<Down>,
|
||||
inbox: ActorAddress,
|
||||
}
|
||||
impl ActorInterface for MonitoringTracker {
|
||||
type Incoming = Ping;
|
||||
type Response = ();
|
||||
fn on_start(&mut self, ctx: &Ctx) {
|
||||
ctx.monitor(self.target);
|
||||
}
|
||||
fn handle(&mut self, ctx: &Ctx, _msg: Ping) {
|
||||
let _ = ctx.send(self.inbox, Count(self.downs.len()));
|
||||
}
|
||||
fn handle_down(&mut self, _ctx: &Ctx, down: Down) {
|
||||
self.downs.push(down);
|
||||
}
|
||||
}
|
||||
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Count>().unwrap();
|
||||
let target = rt.spawn(PanicActor).unwrap();
|
||||
let tracker = rt.spawn(MonitoringTracker {
|
||||
target,
|
||||
downs: vec![],
|
||||
inbox: *inbox.addr(),
|
||||
}).unwrap();
|
||||
rt.tick();
|
||||
rt.send_to(target, PanicMsg).unwrap();
|
||||
tick_n(&rt, 3);
|
||||
rt.send_to(tracker, Ping { reply_to: ActorAddress::default() }).unwrap();
|
||||
rt.tick();
|
||||
assert_eq!(inbox.try_recv(), Some(Count(1)), "handle_down received exactly one Down");
|
||||
|
||||
// --- When Incoming=Down, handle_down is NOT called ---
|
||||
struct DownAsIncoming {
|
||||
target: ActorAddress,
|
||||
inbox: ActorAddress,
|
||||
}
|
||||
impl ActorInterface for DownAsIncoming {
|
||||
type Incoming = Down;
|
||||
type Response = ();
|
||||
fn on_start(&mut self, ctx: &Ctx) {
|
||||
ctx.monitor(self.target);
|
||||
}
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Down) {
|
||||
let _ = ctx.send(self.inbox, msg);
|
||||
}
|
||||
fn handle_down(&mut self, _ctx: &Ctx, _down: Down) {
|
||||
panic!("handle_down must not be called when Incoming=Down");
|
||||
}
|
||||
}
|
||||
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Down>().unwrap();
|
||||
let target = rt.spawn(PanicActor).unwrap();
|
||||
rt.spawn(DownAsIncoming { target, inbox: *inbox.addr() }).unwrap();
|
||||
rt.tick();
|
||||
rt.send_to(target, PanicMsg).unwrap();
|
||||
tick_n(&rt, 3);
|
||||
let received = inbox.try_recv().expect("Down delivered via handle(), not handle_down");
|
||||
assert_eq!(received.reason, StopReason::Panicked);
|
||||
}
|
||||
|
|
@ -5,11 +5,14 @@
|
|||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use swactor::actor::{ActorAddress, ActorInterface, Down, MonitorRef, StopReason};
|
||||
pub use swactor::actor::{
|
||||
ActorAddress, ActorExited, ActorInterface, Down, ExitReason, MonitorRef, StopReason,
|
||||
};
|
||||
pub use swactor::runtime::{Ctx, Inbox, MailboxOverflow, Runtime, RuntimeConfig};
|
||||
pub use swactor_std::{
|
||||
ChildSpec, CtxGroups, CtxMonitoring, CtxNaming, CtxTimers, RestartPolicy, Router,
|
||||
RoutingStrategy, RuntimeGroups, RuntimeNaming, StdExtension, Supervisor, SupervisorStrategy,
|
||||
ChildSpec, CtxGroups, CtxMonitoring, CtxNaming, CtxTimers, CtxWatching, RestartPolicy, Router,
|
||||
RoutingStrategy, RuntimeGroups, RuntimeNaming, RuntimeWatching, StdExtension, Supervisor,
|
||||
SupervisorStrategy,
|
||||
};
|
||||
|
||||
// ── Messages ────────────────────────────────────────────────────────────────
|
||||
|
|
@ -232,6 +235,13 @@ pub fn tick_until_recv<M: swactor::actor::Message>(
|
|||
None
|
||||
}
|
||||
|
||||
/// Tick exactly `n` times (no inbox polling).
|
||||
pub fn tick_n(rt: &Runtime, n: usize) {
|
||||
for _ in 0..n {
|
||||
rt.tick();
|
||||
}
|
||||
}
|
||||
|
||||
/// Tick `n` times, then drain all messages from the inbox.
|
||||
pub fn tick_and_drain<M: swactor::actor::Message>(
|
||||
rt: &Runtime,
|
||||
|
|
|
|||
489
tests/message_delivery.rs
Normal file
489
tests/message_delivery.rs
Normal file
|
|
@ -0,0 +1,489 @@
|
|||
//! Message Delivery Tests — how data flows through the system.
|
||||
//!
|
||||
//! Covers: FIFO ordering, routing correctness at scale, delivery from within
|
||||
//! handlers, address error handling, fairness/budgets, timers, and mailbox
|
||||
//! backpressure policies.
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
// ── Local actors ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Sends a countdown message to itself, then replies Done(0).
|
||||
struct SelfSendActor;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Countdown {
|
||||
remaining: usize,
|
||||
reply_to: ActorAddress,
|
||||
}
|
||||
|
||||
impl ActorInterface for SelfSendActor {
|
||||
type Incoming = Countdown;
|
||||
type Response = Done;
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Countdown) {
|
||||
if msg.remaining == 0 {
|
||||
let _ = ctx.send(msg.reply_to, Done(0));
|
||||
} else {
|
||||
let _ = ctx.send(
|
||||
ctx.self_addr(),
|
||||
Countdown { remaining: msg.remaining - 1, reply_to: msg.reply_to },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedules a one-shot timer in on_start.
|
||||
struct TimerStartActor {
|
||||
target: ActorAddress,
|
||||
delay_ticks: u64,
|
||||
}
|
||||
|
||||
impl ActorInterface for TimerStartActor {
|
||||
type Incoming = Ping;
|
||||
type Response = Pong;
|
||||
fn on_start(&mut self, ctx: &Ctx) {
|
||||
ctx.send_after_ticks(self.target, Ping { reply_to: ctx.self_addr() }, self.delay_ticks);
|
||||
}
|
||||
fn handle(&mut self, _ctx: &Ctx, _msg: Ping) {}
|
||||
}
|
||||
|
||||
/// Schedules a one-shot timer from a handler.
|
||||
struct DelayPingPongActor;
|
||||
|
||||
impl ActorInterface for DelayPingPongActor {
|
||||
type Incoming = Forward;
|
||||
type Response = Done;
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Forward) {
|
||||
ctx.send_after_ticks(msg.reply_to, Done(msg.value), 3);
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedules an interval timer on start.
|
||||
struct HeartbeatActor {
|
||||
target: ActorAddress,
|
||||
period: u64,
|
||||
}
|
||||
|
||||
impl ActorInterface for HeartbeatActor {
|
||||
type Incoming = Ping;
|
||||
type Response = Pong;
|
||||
fn on_start(&mut self, ctx: &Ctx) {
|
||||
ctx.send_interval_ticks(self.target, Ping { reply_to: ctx.self_addr() }, self.period);
|
||||
}
|
||||
fn handle(&mut self, _ctx: &Ctx, _msg: Ping) {}
|
||||
}
|
||||
|
||||
/// NumberedMsg/Reply for routing correctness tests.
|
||||
#[derive(Clone)]
|
||||
struct NumberedMsg {
|
||||
n: usize,
|
||||
reply_to: ActorAddress,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct NumberedReply {
|
||||
from: ActorAddress,
|
||||
n: usize,
|
||||
}
|
||||
|
||||
struct NumberedActor;
|
||||
|
||||
impl ActorInterface for NumberedActor {
|
||||
type Incoming = NumberedMsg;
|
||||
type Response = ();
|
||||
fn handle(&mut self, ctx: &Ctx, msg: NumberedMsg) {
|
||||
let _ = ctx.send(msg.reply_to, NumberedReply { from: ctx.self_addr(), n: msg.n });
|
||||
}
|
||||
}
|
||||
|
||||
/// Ring node for routing chain test.
|
||||
#[derive(Clone)]
|
||||
struct RingHop {
|
||||
hops_remaining: usize,
|
||||
final_dest: ActorAddress,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct RingDone(usize);
|
||||
|
||||
struct RingNode {
|
||||
next: ActorAddress,
|
||||
}
|
||||
|
||||
impl ActorInterface for RingNode {
|
||||
type Incoming = RingHop;
|
||||
type Response = ();
|
||||
fn handle(&mut self, ctx: &Ctx, msg: RingHop) {
|
||||
if msg.hops_remaining == 0 {
|
||||
let _ = ctx.send(msg.final_dest, RingDone(100));
|
||||
} else {
|
||||
let _ = ctx.send(self.next, RingHop {
|
||||
hops_remaining: msg.hops_remaining - 1,
|
||||
final_dest: msg.final_dest,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Messages arrive in FIFO order even with small buffers, budget constraints,
|
||||
/// and independent mailboxes isolate actors from each other.
|
||||
#[test]
|
||||
fn fifo_ordering_and_mailbox_isolation() {
|
||||
// FIFO with small buffer and budget
|
||||
let rt = std_runtime(RuntimeConfig {
|
||||
channel_buffer_size: 1,
|
||||
actor_message_budget: 8,
|
||||
..Default::default()
|
||||
});
|
||||
let addr = rt.spawn(CounterActor { count: 0 }).unwrap();
|
||||
let inbox = rt.new_inbox::<Count>().unwrap();
|
||||
for _ in 0..100 {
|
||||
rt.send_to(addr, Increment { reply_to: *inbox.addr() }).unwrap();
|
||||
}
|
||||
let replies: Vec<_> = tick_and_drain(&rt, &inbox, 50);
|
||||
assert_eq!(replies.len(), 100, "all messages delivered");
|
||||
for (i, reply) in replies.iter().enumerate() {
|
||||
assert_eq!(*reply, Count(i + 1), "FIFO order preserved at position {i}");
|
||||
}
|
||||
|
||||
// Mailbox isolation: 3 actors each get exactly their own messages
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let mut inboxes = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let addr = rt.spawn(PingPongActor).unwrap();
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
rt.send_to(addr, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||
inboxes.push(inbox);
|
||||
}
|
||||
tick_n(&rt, 10);
|
||||
for (i, inbox) in inboxes.iter().enumerate() {
|
||||
assert!(inbox.try_recv().is_some(), "actor {i} replied");
|
||||
assert!(inbox.try_recv().is_none(), "actor {i} has exactly one reply");
|
||||
}
|
||||
}
|
||||
|
||||
/// 200 actors each get a unique numbered message and reply correctly.
|
||||
/// A 100-hop ring traversal completes.
|
||||
#[test]
|
||||
fn message_routing_at_scale() {
|
||||
// 200-actor numbered routing
|
||||
let rt = std_runtime(RuntimeConfig {
|
||||
max_actors: 300,
|
||||
channel_buffer_size: 1024,
|
||||
num_threads: 1,
|
||||
..Default::default()
|
||||
});
|
||||
let inbox = rt.new_inbox::<NumberedReply>().unwrap();
|
||||
let inbox_addr = *inbox.addr();
|
||||
let mut addrs = Vec::new();
|
||||
for _ in 0..200 {
|
||||
addrs.push(rt.spawn(NumberedActor).unwrap());
|
||||
}
|
||||
rt.tick();
|
||||
for (i, addr) in addrs.iter().enumerate() {
|
||||
rt.send_to(*addr, NumberedMsg { n: i, reply_to: inbox_addr }).unwrap();
|
||||
}
|
||||
tick_n(&rt, 3);
|
||||
let replies: Vec<NumberedReply> = std::iter::from_fn(|| inbox.try_recv()).collect();
|
||||
assert_eq!(replies.len(), 200, "all 200 actors replied");
|
||||
for (i, addr) in addrs.iter().enumerate() {
|
||||
let reply = replies.iter().find(|r| r.n == i);
|
||||
assert!(reply.is_some(), "missing reply for actor #{i}");
|
||||
assert_eq!(reply.unwrap().from, *addr, "reply #{i} came from correct actor");
|
||||
}
|
||||
|
||||
// 100-hop ring
|
||||
let rt = std_runtime(RuntimeConfig {
|
||||
max_actors: 200,
|
||||
channel_buffer_size: 1024,
|
||||
num_threads: 1,
|
||||
..Default::default()
|
||||
});
|
||||
let inbox = rt.new_inbox::<RingDone>().unwrap();
|
||||
let inbox_addr = *inbox.addr();
|
||||
let mut ring_addrs = Vec::new();
|
||||
let mut next = inbox_addr;
|
||||
for _ in (0..100).rev() {
|
||||
let addr = rt.spawn(RingNode { next }).unwrap();
|
||||
ring_addrs.push(addr);
|
||||
next = addr;
|
||||
}
|
||||
ring_addrs.reverse();
|
||||
rt.tick();
|
||||
rt.send_to(ring_addrs[0], RingHop { hops_remaining: 99, final_dest: inbox_addr }).unwrap();
|
||||
let result = tick_until_recv(&rt, &inbox, 110);
|
||||
assert_eq!(result, Some(RingDone(100)), "ring message traverses all 100 hops");
|
||||
}
|
||||
|
||||
/// Messages sent in handlers are delivered: delegation, self-send chains,
|
||||
/// rapid spawn+immediate-send, multiple inbox types coexist.
|
||||
#[test]
|
||||
fn delivery_from_within_handlers() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
|
||||
// Delegation: spawn+send in handler
|
||||
let delegator = rt.spawn(DelegatorActor).unwrap();
|
||||
let inbox = rt.new_inbox::<Done>().unwrap();
|
||||
rt.send_to(delegator, Forward { value: 5, reply_to: *inbox.addr() }).unwrap();
|
||||
let reply = tick_until_recv(&rt, &inbox, 20);
|
||||
assert_eq!(reply, Some(Done(10)), "child spawned during handler receives message");
|
||||
|
||||
// Self-send countdown of 20
|
||||
let self_sender = rt.spawn(SelfSendActor).unwrap();
|
||||
rt.send_to(self_sender, Countdown { remaining: 20, reply_to: *inbox.addr() }).unwrap();
|
||||
let reply = tick_until_recv(&rt, &inbox, 50);
|
||||
assert_eq!(reply, Some(Done(0)), "self-send chain completes");
|
||||
|
||||
// Multiple senders reach same actor
|
||||
let counter = rt.spawn(CounterActor { count: 0 }).unwrap();
|
||||
let inbox_a = rt.new_inbox::<Count>().unwrap();
|
||||
let inbox_b = rt.new_inbox::<Count>().unwrap();
|
||||
rt.send_to(counter, Increment { reply_to: *inbox_a.addr() }).unwrap();
|
||||
rt.send_to(counter, Increment { reply_to: *inbox_b.addr() }).unwrap();
|
||||
tick_n(&rt, 10);
|
||||
assert!(inbox_a.try_recv().is_some());
|
||||
assert_eq!(inbox_b.try_recv(), Some(Count(2)), "both senders reach same actor");
|
||||
|
||||
// 50 rapid spawn+immediate-send pairs
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let pong_inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
for _ in 0..50 {
|
||||
let addr = rt.spawn(PingPongActor).unwrap();
|
||||
rt.send_to(addr, Ping { reply_to: *pong_inbox.addr() }).unwrap();
|
||||
}
|
||||
let replies = tick_and_drain(&rt, &pong_inbox, 50);
|
||||
assert_eq!(replies.len(), 50, "all spawn+send pairs complete");
|
||||
|
||||
// Multiple inbox types coexist
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let counter_addr = rt.spawn(CounterActor { count: 0 }).unwrap();
|
||||
let pinger_addr = rt.spawn(PingPongActor).unwrap();
|
||||
let count_inbox = rt.new_inbox::<Count>().unwrap();
|
||||
let pong_inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
rt.send_to(counter_addr, Increment { reply_to: *count_inbox.addr() }).unwrap();
|
||||
rt.send_to(pinger_addr, Ping { reply_to: *pong_inbox.addr() }).unwrap();
|
||||
tick_n(&rt, 10);
|
||||
assert_eq!(count_inbox.try_recv(), Some(Count(1)));
|
||||
assert_eq!(pong_inbox.try_recv(), Some(Pong));
|
||||
}
|
||||
|
||||
/// Sending to nonexistent address returns error, wrong type increments
|
||||
/// type_mismatch counter.
|
||||
#[test]
|
||||
fn address_error_handling() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
|
||||
// Nonexistent address
|
||||
let bogus = ActorAddress::new_random();
|
||||
assert!(rt.send_to(bogus, Pong).is_err(), "send to unknown address fails");
|
||||
|
||||
// Wrong type
|
||||
let addr = rt.spawn(PingPongActor).unwrap();
|
||||
rt.send_to(addr, Count(42)).unwrap(); // Count instead of Ping
|
||||
rt.send_to(addr, Count(0)).unwrap();
|
||||
rt.send_to(addr, Count(0)).unwrap();
|
||||
tick_n(&rt, 10);
|
||||
let stats = rt.stats();
|
||||
let mismatches: u64 = stats.workers.iter().map(|w| w.type_mismatches).sum();
|
||||
assert_eq!(mismatches, 3, "3 wrong-type messages counted as mismatches");
|
||||
}
|
||||
|
||||
/// Budget fairness: hot actor doesn't starve cold actor, budget is respected
|
||||
/// with self-sends, unlimited budget drains all.
|
||||
#[test]
|
||||
fn fairness_budget_prevents_starvation() {
|
||||
// Hot (1000 msgs) vs cold (1 msg), budget=64
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let hot_counter = Arc::new(AtomicUsize::new(0));
|
||||
let cold_inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
let hot = rt.spawn(CountingPingActor { counter: hot_counter.clone() }).unwrap();
|
||||
let cold = rt.spawn(PingPongActor).unwrap();
|
||||
let dummy = rt.new_inbox::<Pong>().unwrap();
|
||||
for _ in 0..1000 {
|
||||
rt.send_to(hot, Ping { reply_to: *dummy.addr() }).unwrap();
|
||||
}
|
||||
rt.send_to(cold, Ping { reply_to: *cold_inbox.addr() }).unwrap();
|
||||
rt.tick();
|
||||
assert!(cold_inbox.try_recv().is_some(), "cold actor not starved by hot actor");
|
||||
assert!(hot_counter.load(Ordering::SeqCst) <= 64, "hot capped at budget");
|
||||
|
||||
// Budget=4 with self-send chain of 20 → completes across multiple ticks
|
||||
let rt = std_runtime(RuntimeConfig { actor_message_budget: 4, ..Default::default() });
|
||||
let addr = rt.spawn(SelfSendActor).unwrap();
|
||||
let inbox = rt.new_inbox::<Done>().unwrap();
|
||||
rt.send_to(addr, Countdown { remaining: 20, reply_to: *inbox.addr() }).unwrap();
|
||||
tick_n(&rt, 30);
|
||||
assert_eq!(inbox.try_recv(), Some(Done(0)), "self-send chain completes despite budget");
|
||||
|
||||
// Unlimited budget (0) drains all
|
||||
let rt = std_runtime(RuntimeConfig { actor_message_budget: 0, ..Default::default() });
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let dummy = rt.new_inbox::<Pong>().unwrap();
|
||||
let addr = rt.spawn(CountingPingActor { counter: counter.clone() }).unwrap();
|
||||
for _ in 0..500 {
|
||||
rt.send_to(addr, Ping { reply_to: *dummy.addr() }).unwrap();
|
||||
}
|
||||
rt.tick();
|
||||
rt.tick();
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 500, "unlimited budget drains all");
|
||||
}
|
||||
|
||||
/// One-shot timers fire at the right tick and only once. Interval timers fire
|
||||
/// repeatedly at the right period. Timers are cleaned up when actors die.
|
||||
#[test]
|
||||
fn timer_one_shot_and_interval() {
|
||||
// One-shot: delay=3 from on_start
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Ping>().unwrap();
|
||||
rt.spawn(TimerStartActor { target: *inbox.addr(), delay_ticks: 3 }).unwrap();
|
||||
rt.tick(); // tick 1: on_start schedules
|
||||
assert!(inbox.try_recv().is_none(), "no delivery tick 1");
|
||||
rt.tick(); // tick 2
|
||||
assert!(inbox.try_recv().is_none(), "no delivery tick 2");
|
||||
rt.tick(); // tick 3
|
||||
assert!(inbox.try_recv().is_none(), "no delivery tick 3");
|
||||
rt.tick(); // tick 4: fires
|
||||
assert!(inbox.try_recv().is_some(), "timer fires after 3-tick delay");
|
||||
|
||||
// One-shot from handler
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Done>().unwrap();
|
||||
let addr = rt.spawn(DelayPingPongActor).unwrap();
|
||||
rt.send_to(addr, Forward { value: 42, reply_to: *inbox.addr() }).unwrap();
|
||||
rt.tick(); // process Forward, schedule timer
|
||||
assert!(inbox.try_recv().is_none());
|
||||
rt.tick(); // tick 2
|
||||
rt.tick(); // tick 3
|
||||
assert!(inbox.try_recv().is_none());
|
||||
rt.tick(); // tick 4: fires
|
||||
assert_eq!(inbox.try_recv(), Some(Done(42)), "delayed reply from handler timer");
|
||||
|
||||
// One-shot does NOT repeat
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Ping>().unwrap();
|
||||
rt.spawn(TimerStartActor { target: *inbox.addr(), delay_ticks: 1 }).unwrap();
|
||||
rt.tick(); // schedule
|
||||
rt.tick(); // fires
|
||||
assert!(inbox.try_recv().is_some(), "first fire");
|
||||
tick_n(&rt, 5);
|
||||
assert!(inbox.try_recv().is_none(), "one-shot doesn't repeat");
|
||||
|
||||
// Zero-delay fires next tick
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Ping>().unwrap();
|
||||
rt.spawn(TimerStartActor { target: *inbox.addr(), delay_ticks: 0 }).unwrap();
|
||||
rt.tick(); // schedule
|
||||
assert!(inbox.try_recv().is_none(), "not immediate — fires next tick");
|
||||
rt.tick(); // fires
|
||||
assert!(inbox.try_recv().is_some(), "zero-delay fires next tick");
|
||||
|
||||
// Interval: period=2, fires on ticks 3, 5, 7
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Ping>().unwrap();
|
||||
rt.spawn(HeartbeatActor { target: *inbox.addr(), period: 2 }).unwrap();
|
||||
rt.tick(); // tick 1: schedule
|
||||
assert!(inbox.try_recv().is_none());
|
||||
rt.tick(); // tick 2
|
||||
assert!(inbox.try_recv().is_none());
|
||||
rt.tick(); // tick 3: first fire
|
||||
assert!(inbox.try_recv().is_some(), "fire on tick 3");
|
||||
rt.tick(); // tick 4
|
||||
assert!(inbox.try_recv().is_none());
|
||||
rt.tick(); // tick 5: second fire
|
||||
assert!(inbox.try_recv().is_some(), "fire on tick 5");
|
||||
rt.tick(); // tick 6
|
||||
assert!(inbox.try_recv().is_none());
|
||||
rt.tick(); // tick 7: third fire
|
||||
assert!(inbox.try_recv().is_some(), "fire on tick 7");
|
||||
|
||||
// Timer cleanup when target actor dies
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let counter_addr = rt.spawn(CounterActor { count: 0 }).unwrap();
|
||||
rt.spawn(HeartbeatActor { target: counter_addr, period: 1 }).unwrap();
|
||||
tick_n(&rt, 3);
|
||||
rt.stop_actor(counter_addr).unwrap();
|
||||
tick_n(&rt, 5);
|
||||
let stats = rt.stats();
|
||||
assert_eq!(stats.workers[0].num_actors, 1, "only heartbeat actor remains");
|
||||
}
|
||||
|
||||
/// Bounded mailboxes: DropNewest caps at capacity, DropOldest keeps newest,
|
||||
/// unbounded delivers all, mailbox refills after processing.
|
||||
#[test]
|
||||
fn mailbox_backpressure_policies() {
|
||||
// DropNewest: capacity=10, send 50 → only 10 delivered
|
||||
let rt = std_runtime(RuntimeConfig {
|
||||
default_mailbox_capacity: 10,
|
||||
mailbox_overflow: MailboxOverflow::DropNewest,
|
||||
..Default::default()
|
||||
});
|
||||
let inbox = rt.new_inbox::<Count>().unwrap();
|
||||
let addr = rt.spawn(CounterActor { count: 0 }).unwrap();
|
||||
for _ in 0..50 {
|
||||
let _ = rt.send_to(addr, Increment { reply_to: *inbox.addr() });
|
||||
}
|
||||
tick_n(&rt, 20);
|
||||
let mut replies = 0;
|
||||
while inbox.try_recv().is_some() { replies += 1; }
|
||||
assert_eq!(replies, 10, "DropNewest caps at mailbox capacity");
|
||||
let drops: u64 = rt.stats().workers.iter().map(|w| w.messages_dropped).sum();
|
||||
assert_eq!(drops, 40, "40 messages dropped");
|
||||
|
||||
// DropOldest: capacity=5, send 10 → newest 5 kept
|
||||
let rt = std_runtime(RuntimeConfig {
|
||||
default_mailbox_capacity: 5,
|
||||
mailbox_overflow: MailboxOverflow::DropOldest,
|
||||
..Default::default()
|
||||
});
|
||||
let inbox = rt.new_inbox::<Done>().unwrap();
|
||||
let addr = rt.spawn(DoubleActor).unwrap();
|
||||
for i in 0..10 {
|
||||
let _ = rt.send_to(addr, Forward { value: i, reply_to: *inbox.addr() });
|
||||
}
|
||||
tick_n(&rt, 10);
|
||||
let mut replies = Vec::new();
|
||||
while let Some(Done(v)) = inbox.try_recv() { replies.push(v); }
|
||||
assert_eq!(replies.len(), 5, "only 5 kept");
|
||||
assert_eq!(replies, vec![10, 12, 14, 16, 18], "newest values kept (5-9 doubled)");
|
||||
|
||||
// Unbounded: 200 messages all delivered
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Count>().unwrap();
|
||||
let addr = rt.spawn(CounterActor { count: 0 }).unwrap();
|
||||
for _ in 0..200 {
|
||||
let _ = rt.send_to(addr, Increment { reply_to: *inbox.addr() });
|
||||
}
|
||||
tick_n(&rt, 50);
|
||||
let mut count = 0;
|
||||
while inbox.try_recv().is_some() { count += 1; }
|
||||
assert_eq!(count, 200, "unbounded delivers all");
|
||||
|
||||
// Refill after processing
|
||||
let rt = std_runtime(RuntimeConfig {
|
||||
default_mailbox_capacity: 5,
|
||||
actor_message_budget: 5,
|
||||
mailbox_overflow: MailboxOverflow::DropNewest,
|
||||
..Default::default()
|
||||
});
|
||||
let inbox = rt.new_inbox::<Count>().unwrap();
|
||||
let addr = rt.spawn(CounterActor { count: 0 }).unwrap();
|
||||
for _ in 0..5 {
|
||||
let _ = rt.send_to(addr, Increment { reply_to: *inbox.addr() });
|
||||
}
|
||||
rt.tick(); // process batch 1
|
||||
for _ in 0..5 {
|
||||
let _ = rt.send_to(addr, Increment { reply_to: *inbox.addr() });
|
||||
}
|
||||
rt.tick(); // process batch 2
|
||||
let mut count = 0;
|
||||
while inbox.try_recv().is_some() { count += 1; }
|
||||
assert_eq!(count, 10, "mailbox refills after draining");
|
||||
}
|
||||
|
|
@ -1,224 +0,0 @@
|
|||
mod common;
|
||||
use common::*;
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
// ── Identity Hasher Correctness ────────────────────────────────────────────
|
||||
|
||||
/// Given: 200 actors each expecting a unique numbered message
|
||||
/// When: Each actor receives its number and replies with (self_addr, number)
|
||||
/// Then: All 200 replies match -- no message was misrouted by the identity hasher
|
||||
#[test]
|
||||
fn many_actors_all_receive_correct_messages() {
|
||||
#[derive(Clone)]
|
||||
struct NumberedMsg {
|
||||
n: usize,
|
||||
reply_to: ActorAddress,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct NumberedReply {
|
||||
from: ActorAddress,
|
||||
n: usize,
|
||||
}
|
||||
|
||||
struct NumberedActor;
|
||||
|
||||
impl ActorInterface for NumberedActor {
|
||||
type Incoming = NumberedMsg;
|
||||
type Response = ();
|
||||
fn handle(&mut self, ctx: &Ctx, msg: NumberedMsg) {
|
||||
let _ = ctx.send(
|
||||
msg.reply_to,
|
||||
NumberedReply {
|
||||
from: ctx.self_addr(),
|
||||
n: msg.n,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let rt = std_runtime(RuntimeConfig {
|
||||
max_actors: 300,
|
||||
channel_buffer_size: 1024,
|
||||
num_threads: 1,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let inbox = rt.new_inbox::<NumberedReply>().unwrap();
|
||||
let inbox_addr = *inbox.addr();
|
||||
|
||||
// Spawn 200 actors
|
||||
let mut addrs = Vec::new();
|
||||
for _ in 0..200 {
|
||||
addrs.push(rt.spawn(NumberedActor).unwrap());
|
||||
}
|
||||
rt.tick(); // on_start
|
||||
|
||||
// Send unique numbered message to each
|
||||
for (i, addr) in addrs.iter().enumerate() {
|
||||
rt.send_to(
|
||||
*addr,
|
||||
NumberedMsg {
|
||||
n: i,
|
||||
reply_to: inbox_addr,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
rt.tick(); // process + reply
|
||||
rt.tick(); // deliver replies
|
||||
|
||||
// Verify all 200 replies
|
||||
let mut replies: Vec<NumberedReply> = Vec::new();
|
||||
while let Some(reply) = inbox.try_recv() {
|
||||
replies.push(reply);
|
||||
}
|
||||
|
||||
assert_eq!(replies.len(), 200, "should receive exactly 200 replies");
|
||||
|
||||
// Verify each reply came from the correct actor with the correct number
|
||||
for (i, addr) in addrs.iter().enumerate() {
|
||||
let reply = replies.iter().find(|r| r.n == i);
|
||||
assert!(
|
||||
reply.is_some(),
|
||||
"missing reply for actor #{i}"
|
||||
);
|
||||
assert_eq!(
|
||||
reply.unwrap().from, *addr,
|
||||
"reply #{i} came from wrong actor"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Given: A 100-actor ring where each actor forwards to the next
|
||||
/// When: A message enters the ring and traverses all 100 hops
|
||||
/// Then: The message completes the full circuit (address_map lookups all correct)
|
||||
#[test]
|
||||
fn ring_routing_unchanged_after_hasher_optimization() {
|
||||
#[derive(Clone)]
|
||||
struct RingHop {
|
||||
hops_remaining: usize,
|
||||
final_dest: ActorAddress,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct RingDone(usize); // total hops completed
|
||||
|
||||
struct RingNode {
|
||||
next: ActorAddress,
|
||||
}
|
||||
|
||||
impl ActorInterface for RingNode {
|
||||
type Incoming = RingHop;
|
||||
type Response = ();
|
||||
fn handle(&mut self, ctx: &Ctx, msg: RingHop) {
|
||||
if msg.hops_remaining == 0 {
|
||||
let _ = ctx.send(msg.final_dest, RingDone(100));
|
||||
} else {
|
||||
let _ = ctx.send(
|
||||
self.next,
|
||||
RingHop {
|
||||
hops_remaining: msg.hops_remaining - 1,
|
||||
final_dest: msg.final_dest,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let rt = std_runtime(RuntimeConfig {
|
||||
max_actors: 200,
|
||||
channel_buffer_size: 1024,
|
||||
num_threads: 1,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let inbox = rt.new_inbox::<RingDone>().unwrap();
|
||||
let inbox_addr = *inbox.addr();
|
||||
|
||||
// Build chain backwards: last node sends to inbox, first node receives
|
||||
let mut addrs = Vec::new();
|
||||
let mut next = inbox_addr;
|
||||
for _ in (0..100).rev() {
|
||||
let node = RingNode { next };
|
||||
let addr = rt.spawn(node).unwrap();
|
||||
addrs.push(addr);
|
||||
next = addr;
|
||||
}
|
||||
addrs.reverse(); // addrs[0] is start of chain
|
||||
|
||||
rt.tick(); // on_start
|
||||
|
||||
// Inject message at the start
|
||||
rt.send_to(
|
||||
addrs[0],
|
||||
RingHop {
|
||||
hops_remaining: 99,
|
||||
final_dest: inbox_addr,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Tick enough times for the message to traverse all 100 actors
|
||||
for _ in 0..110 {
|
||||
rt.tick();
|
||||
}
|
||||
|
||||
let result = inbox.try_recv();
|
||||
assert!(result.is_some(), "ring message should complete all 100 hops");
|
||||
assert_eq!(result.unwrap(), RingDone(100));
|
||||
}
|
||||
|
||||
/// Given: An actor that calls ctx.stop_self() upon receiving a trigger message
|
||||
/// When: The trigger is sent, then 5 more messages are sent, then ticked
|
||||
/// Then: The actor is removed, only messages before stop are processed
|
||||
#[test]
|
||||
fn stop_self_with_pending_messages_still_works() {
|
||||
let processed = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Msg(bool); // true = trigger stop
|
||||
|
||||
struct StopOnTrigger(Arc<AtomicUsize>);
|
||||
|
||||
impl ActorInterface for StopOnTrigger {
|
||||
type Incoming = Msg;
|
||||
type Response = ();
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Msg) {
|
||||
self.0.fetch_add(1, Ordering::Relaxed);
|
||||
if msg.0 {
|
||||
ctx.stop_self();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let p = processed.clone();
|
||||
let addr = rt.spawn(StopOnTrigger(p)).unwrap();
|
||||
rt.tick(); // on_start
|
||||
|
||||
// Send: 2 normal, 1 trigger, 5 more normal
|
||||
rt.send_to(addr, Msg(false)).unwrap();
|
||||
rt.send_to(addr, Msg(false)).unwrap();
|
||||
rt.send_to(addr, Msg(true)).unwrap(); // stop trigger
|
||||
rt.send_to(addr, Msg(false)).unwrap();
|
||||
rt.send_to(addr, Msg(false)).unwrap();
|
||||
rt.send_to(addr, Msg(false)).unwrap();
|
||||
rt.send_to(addr, Msg(false)).unwrap();
|
||||
rt.send_to(addr, Msg(false)).unwrap();
|
||||
|
||||
rt.tick(); // process messages -- stops after trigger
|
||||
rt.tick(); // cleanup
|
||||
|
||||
// Only 3 messages should be processed (2 normal + 1 trigger)
|
||||
assert_eq!(
|
||||
processed.load(Ordering::Relaxed),
|
||||
3,
|
||||
"should process exactly the messages up to and including the stop trigger"
|
||||
);
|
||||
|
||||
// Subsequent sends should fail
|
||||
assert!(rt.send_to(addr, Msg(false)).is_err());
|
||||
}
|
||||
|
|
@ -1,454 +0,0 @@
|
|||
mod common;
|
||||
use common::*;
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
// ── Lifecycle Hook Helpers ────────────────────────────────────────────────
|
||||
|
||||
/// An actor that records lifecycle events to shared counters.
|
||||
struct LifecycleActor {
|
||||
started: Arc<AtomicUsize>,
|
||||
stopped: Arc<AtomicUsize>,
|
||||
handled: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl ActorInterface for LifecycleActor {
|
||||
type Incoming = Ping;
|
||||
type Response = Pong;
|
||||
|
||||
fn on_start(&mut self, _ctx: &Ctx) {
|
||||
self.started.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn on_stop(&mut self, _ctx: &Ctx) {
|
||||
self.stopped.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Ping) {
|
||||
self.handled.fetch_add(1, Ordering::Relaxed);
|
||||
let _ = ctx.send(msg.reply_to, Pong);
|
||||
}
|
||||
}
|
||||
|
||||
/// An actor that stops itself after processing N messages.
|
||||
struct SelfStopActor {
|
||||
count: usize,
|
||||
stop_after: usize,
|
||||
stopped: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl ActorInterface for SelfStopActor {
|
||||
type Incoming = Forward;
|
||||
type Response = Done;
|
||||
|
||||
fn on_stop(&mut self, _ctx: &Ctx) {
|
||||
self.stopped.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Forward) {
|
||||
self.count += 1;
|
||||
let _ = ctx.send(msg.reply_to, Done(msg.value));
|
||||
if self.count >= self.stop_after {
|
||||
ctx.stop_self();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An actor that sends a farewell message in on_stop.
|
||||
struct FarewellActor {
|
||||
farewell_to: ActorAddress,
|
||||
}
|
||||
|
||||
impl ActorInterface for FarewellActor {
|
||||
type Incoming = Ping;
|
||||
type Response = Pong;
|
||||
|
||||
fn on_stop(&mut self, ctx: &Ctx) {
|
||||
let _ = ctx.send(self.farewell_to, Pong);
|
||||
}
|
||||
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Ping) {
|
||||
let _ = ctx.send(msg.reply_to, Pong);
|
||||
}
|
||||
}
|
||||
|
||||
/// An actor whose on_start panics.
|
||||
struct PanicOnStartActor {
|
||||
handled: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl ActorInterface for PanicOnStartActor {
|
||||
type Incoming = Ping;
|
||||
type Response = Pong;
|
||||
|
||||
fn on_start(&mut self, _ctx: &Ctx) {
|
||||
panic!("on_start panic");
|
||||
}
|
||||
|
||||
fn handle(&mut self, _ctx: &Ctx, _msg: Ping) {
|
||||
self.handled.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles Forward messages, replies Done(value * 2), panics on the panic_at-th message.
|
||||
/// (Needed for on_start_called_again_after_restart and stop_vs_panic tests.)
|
||||
struct RestartTestActor {
|
||||
count: usize,
|
||||
panic_at: usize,
|
||||
}
|
||||
|
||||
impl ActorInterface for RestartTestActor {
|
||||
type Incoming = Forward;
|
||||
type Response = Done;
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Forward) {
|
||||
self.count += 1;
|
||||
if self.count >= self.panic_at {
|
||||
panic!("intentional panic at message {}", self.count);
|
||||
}
|
||||
let _ = ctx.send(msg.reply_to, Done(msg.value * 2));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lifecycle Hook Tests ──────────────────────────────────────────────────
|
||||
|
||||
/// Given an actor with on_start implemented,
|
||||
/// when it is spawned and the runtime ticks,
|
||||
/// then on_start is called exactly once before the first message.
|
||||
#[test]
|
||||
fn on_start_called_before_first_message() {
|
||||
let started = Arc::new(AtomicUsize::new(0));
|
||||
let stopped = Arc::new(AtomicUsize::new(0));
|
||||
let handled = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
|
||||
let addr = rt.spawn(LifecycleActor {
|
||||
started: started.clone(),
|
||||
stopped: stopped.clone(),
|
||||
handled: handled.clone(),
|
||||
}).unwrap();
|
||||
|
||||
// First tick — should call on_start
|
||||
rt.tick();
|
||||
assert_eq!(started.load(Ordering::Relaxed), 1, "on_start called on first tick");
|
||||
assert_eq!(handled.load(Ordering::Relaxed), 0, "no messages processed yet");
|
||||
|
||||
// Send messages and tick more
|
||||
let _ = rt.send_to(addr, Ping { reply_to: *inbox.addr() });
|
||||
rt.tick();
|
||||
assert_eq!(started.load(Ordering::Relaxed), 1, "on_start not called again");
|
||||
assert_eq!(handled.load(Ordering::Relaxed), 1, "message processed after on_start");
|
||||
}
|
||||
|
||||
/// Given an actor with on_start,
|
||||
/// when multiple actors are spawned,
|
||||
/// then each gets its own on_start call exactly once.
|
||||
#[test]
|
||||
fn on_start_called_per_actor() {
|
||||
let started = Arc::new(AtomicUsize::new(0));
|
||||
let stopped = Arc::new(AtomicUsize::new(0));
|
||||
let handled = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
|
||||
for _ in 0..5 {
|
||||
let _ = rt.spawn(LifecycleActor {
|
||||
started: started.clone(),
|
||||
stopped: stopped.clone(),
|
||||
handled: handled.clone(),
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
rt.tick();
|
||||
assert_eq!(started.load(Ordering::Relaxed), 5, "on_start called for each of 5 actors");
|
||||
|
||||
// Subsequent ticks don't repeat on_start
|
||||
rt.tick();
|
||||
rt.tick();
|
||||
assert_eq!(started.load(Ordering::Relaxed), 5, "on_start still 5 after more ticks");
|
||||
}
|
||||
|
||||
/// Given an actor whose on_start panics,
|
||||
/// when it is spawned and the runtime ticks,
|
||||
/// then it is immediately poisoned and never processes messages.
|
||||
#[test]
|
||||
fn on_start_panic_poisons_actor() {
|
||||
let handled = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
|
||||
let addr = rt.spawn(PanicOnStartActor { handled: handled.clone() }).unwrap();
|
||||
|
||||
let _ = rt.send_to(addr, Ping { reply_to: *inbox.addr() });
|
||||
for _ in 0..5 { rt.tick(); }
|
||||
|
||||
assert_eq!(handled.load(Ordering::Relaxed), 0, "actor never processed messages");
|
||||
|
||||
let stats = rt.stats();
|
||||
let total_panics: u64 = stats.workers.iter().map(|w| w.panics).sum();
|
||||
assert_eq!(total_panics, 1, "on_start panic counted");
|
||||
}
|
||||
|
||||
// ── Graceful Stop Tests ───────────────────────────────────────────────────
|
||||
|
||||
/// Given an actor that calls ctx.stop_self() after 3 messages,
|
||||
/// when 5 messages are sent,
|
||||
/// then only 3 are processed, the actor is removed, and on_stop is called.
|
||||
#[test]
|
||||
fn actor_can_stop_self() {
|
||||
let stopped = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Done>().unwrap();
|
||||
|
||||
let addr = rt.spawn(SelfStopActor {
|
||||
count: 0,
|
||||
stop_after: 3,
|
||||
stopped: stopped.clone(),
|
||||
}).unwrap();
|
||||
|
||||
for i in 0..5 {
|
||||
let _ = rt.send_to(addr, Forward { value: i, reply_to: *inbox.addr() });
|
||||
}
|
||||
for _ in 0..10 { rt.tick(); }
|
||||
|
||||
// Only 3 messages should be processed (stop_self after 3rd)
|
||||
let mut replies = Vec::new();
|
||||
while let Some(Done(v)) = inbox.try_recv() {
|
||||
replies.push(v);
|
||||
}
|
||||
assert_eq!(replies.len(), 3, "only 3 messages processed before stop");
|
||||
assert!(replies.contains(&0));
|
||||
assert!(replies.contains(&1));
|
||||
assert!(replies.contains(&2));
|
||||
|
||||
assert_eq!(stopped.load(Ordering::Relaxed), 1, "on_stop called exactly once");
|
||||
|
||||
// Actor should be removed from address map
|
||||
let stats = rt.stats();
|
||||
assert_eq!(stats.actors.len(), 0, "stopped actor removed from address map");
|
||||
}
|
||||
|
||||
/// Given a running actor,
|
||||
/// when runtime.stop_actor(addr) is called,
|
||||
/// then the actor stops, on_stop is called, and it's removed from the pool.
|
||||
#[test]
|
||||
fn runtime_can_stop_actor() {
|
||||
let started = Arc::new(AtomicUsize::new(0));
|
||||
let stopped = Arc::new(AtomicUsize::new(0));
|
||||
let handled = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
|
||||
let addr = rt.spawn(LifecycleActor {
|
||||
started: started.clone(),
|
||||
stopped: stopped.clone(),
|
||||
handled: handled.clone(),
|
||||
}).unwrap();
|
||||
|
||||
// Let it start and process a message
|
||||
let _ = rt.send_to(addr, Ping { reply_to: *inbox.addr() });
|
||||
for _ in 0..3 { rt.tick(); }
|
||||
assert_eq!(handled.load(Ordering::Relaxed), 1);
|
||||
|
||||
// Stop it externally
|
||||
rt.stop_actor(addr).unwrap();
|
||||
for _ in 0..3 { rt.tick(); }
|
||||
|
||||
assert_eq!(stopped.load(Ordering::Relaxed), 1, "on_stop called");
|
||||
|
||||
// Actor should be gone
|
||||
let stats = rt.stats();
|
||||
assert_eq!(stats.actors.len(), 0, "stopped actor removed");
|
||||
assert_eq!(stats.workers[0].num_actors, 0);
|
||||
}
|
||||
|
||||
/// Given a stopped actor,
|
||||
/// when new messages are sent to it,
|
||||
/// then sends return Err (address not found).
|
||||
#[test]
|
||||
fn send_to_stopped_actor_returns_error() {
|
||||
let stopped = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Done>().unwrap();
|
||||
|
||||
let addr = rt.spawn(SelfStopActor {
|
||||
count: 0,
|
||||
stop_after: 1,
|
||||
stopped: stopped.clone(),
|
||||
}).unwrap();
|
||||
|
||||
// One message triggers stop
|
||||
let _ = rt.send_to(addr, Forward { value: 1, reply_to: *inbox.addr() });
|
||||
for _ in 0..10 { rt.tick(); }
|
||||
|
||||
// Actor is now removed — send should fail
|
||||
let result = rt.send_to(addr, Forward { value: 2, reply_to: *inbox.addr() });
|
||||
assert!(result.is_err(), "send to stopped actor should return Err");
|
||||
}
|
||||
|
||||
/// Given a gracefully stopped actor and a panicked actor,
|
||||
/// then stats.stops and stats.panics track them separately.
|
||||
#[test]
|
||||
fn stop_vs_panic_tracked_separately_in_stats() {
|
||||
let stopped = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Done>().unwrap();
|
||||
|
||||
// Actor that stops itself after 1 message
|
||||
let _stop_addr = rt.spawn(SelfStopActor {
|
||||
count: 0,
|
||||
stop_after: 1,
|
||||
stopped: stopped.clone(),
|
||||
}).unwrap();
|
||||
|
||||
// Actor that panics on first message
|
||||
let panic_addr = rt.spawn(RestartTestActor { count: 0, panic_at: 1 }).unwrap();
|
||||
|
||||
let _ = rt.send_to(_stop_addr, Forward { value: 1, reply_to: *inbox.addr() });
|
||||
let _ = rt.send_to(panic_addr, Forward { value: 1, reply_to: *inbox.addr() });
|
||||
for _ in 0..10 { rt.tick(); }
|
||||
|
||||
let stats = rt.stats();
|
||||
let total_stops: u64 = stats.workers.iter().map(|w| w.stops).sum();
|
||||
let total_panics: u64 = stats.workers.iter().map(|w| w.panics).sum();
|
||||
|
||||
assert_eq!(total_stops, 1, "one graceful stop");
|
||||
assert_eq!(total_panics, 1, "one panic");
|
||||
}
|
||||
|
||||
/// Given an actor with on_stop that sends a farewell message,
|
||||
/// when the actor is stopped,
|
||||
/// then the farewell message is delivered.
|
||||
#[test]
|
||||
fn on_stop_can_send_messages() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
|
||||
let addr = rt.spawn(FarewellActor {
|
||||
farewell_to: *inbox.addr(),
|
||||
}).unwrap();
|
||||
|
||||
// Let it start
|
||||
rt.tick();
|
||||
|
||||
// Stop it
|
||||
rt.stop_actor(addr).unwrap();
|
||||
for _ in 0..5 { rt.tick(); }
|
||||
|
||||
// Should receive farewell Pong from on_stop
|
||||
let farewell = inbox.try_recv();
|
||||
assert_eq!(farewell, Some(Pong), "farewell message delivered from on_stop");
|
||||
}
|
||||
|
||||
/// Given a supervisor with a child that panics and is restarted,
|
||||
/// when the child is respawned by the supervisor,
|
||||
/// then on_start is called again on the fresh instance.
|
||||
#[test]
|
||||
fn on_start_called_again_after_restart() {
|
||||
let started = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
|
||||
let started_c = started.clone();
|
||||
let _sup_addr = rt.spawn(Supervisor::new(
|
||||
SupervisorStrategy::OneForOne,
|
||||
5,
|
||||
vec![ChildSpec::new("child", RestartPolicy::Permanent, move |ctx| {
|
||||
ctx.spawn(LifecycleActor {
|
||||
started: started_c.clone(),
|
||||
stopped: Arc::new(AtomicUsize::new(0)),
|
||||
handled: Arc::new(AtomicUsize::new(0)),
|
||||
})
|
||||
})],
|
||||
)).unwrap();
|
||||
|
||||
// First tick: supervisor starts, spawns child, on_start called
|
||||
for _ in 0..3 { rt.tick(); }
|
||||
assert_eq!(started.load(Ordering::Relaxed), 1, "on_start called once");
|
||||
}
|
||||
|
||||
/// Given an actor stopped via stop_actor() with messages already queued,
|
||||
/// when the stop signal arrives after the queued messages (PoisonPill semantics),
|
||||
/// then messages ahead of the signal are processed, then the actor stops.
|
||||
#[test]
|
||||
fn external_stop_is_queued_after_pending_messages() {
|
||||
let stopped = Arc::new(AtomicUsize::new(0));
|
||||
let handled = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
|
||||
let started = Arc::new(AtomicUsize::new(0));
|
||||
let addr = rt.spawn(LifecycleActor {
|
||||
started: started.clone(),
|
||||
stopped: stopped.clone(),
|
||||
handled: handled.clone(),
|
||||
}).unwrap();
|
||||
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
|
||||
// Queue 10 messages, then stop — StopSignal is queued AFTER the 10
|
||||
for _ in 0..10 {
|
||||
let _ = rt.send_to(addr, Ping { reply_to: *inbox.addr() });
|
||||
}
|
||||
rt.stop_actor(addr).unwrap();
|
||||
for _ in 0..10 { rt.tick(); }
|
||||
|
||||
// All 10 messages processed (they were ahead of StopSignal in the queue)
|
||||
let total_handled = handled.load(Ordering::Relaxed);
|
||||
assert_eq!(total_handled, 10, "all messages processed before stop signal");
|
||||
assert_eq!(stopped.load(Ordering::Relaxed), 1, "on_stop called");
|
||||
|
||||
// Actor is removed
|
||||
let stats = rt.stats();
|
||||
assert_eq!(stats.actors.len(), 0, "stopped actor removed");
|
||||
}
|
||||
|
||||
/// Given a running actor with no pending messages,
|
||||
/// when stop_actor() is called and then new messages are sent,
|
||||
/// then the stop takes priority and new messages are not processed.
|
||||
#[test]
|
||||
fn external_stop_before_new_messages_prevents_processing() {
|
||||
let stopped = Arc::new(AtomicUsize::new(0));
|
||||
let handled = Arc::new(AtomicUsize::new(0));
|
||||
let started = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
|
||||
let addr = rt.spawn(LifecycleActor {
|
||||
started: started.clone(),
|
||||
stopped: stopped.clone(),
|
||||
handled: handled.clone(),
|
||||
}).unwrap();
|
||||
|
||||
// Let actor start
|
||||
rt.tick();
|
||||
|
||||
// Stop first, then send messages
|
||||
rt.stop_actor(addr).unwrap();
|
||||
for _ in 0..5 {
|
||||
let _ = rt.send_to(addr, Ping { reply_to: *inbox.addr() });
|
||||
}
|
||||
for _ in 0..10 { rt.tick(); }
|
||||
|
||||
// Stop signal was first in queue, so no messages processed
|
||||
assert_eq!(handled.load(Ordering::Relaxed), 0, "no messages processed after stop");
|
||||
assert_eq!(stopped.load(Ordering::Relaxed), 1, "on_stop called");
|
||||
}
|
||||
|
||||
/// Given stop_actor is called on a nonexistent address,
|
||||
/// then it returns Err.
|
||||
#[test]
|
||||
fn stop_nonexistent_actor_returns_error() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let fake_addr = swactor::actor::ActorAddress::default();
|
||||
let result = rt.stop_actor(fake_addr);
|
||||
assert!(result.is_err(), "stop_actor on nonexistent address should return Err");
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,362 +0,0 @@
|
|||
mod common;
|
||||
use common::*;
|
||||
|
||||
// ── Load-Aware Placement Tests ─────────────────────────────────────────────
|
||||
|
||||
/// Given a multi-threaded runtime where one worker has many more actors,
|
||||
/// when new actors are spawned after a few ticks (so stats propagate),
|
||||
/// then they should be placed on the lighter worker.
|
||||
#[test]
|
||||
fn load_aware_placement_prefers_lighter_worker() {
|
||||
// 2 threads: intentionally imbalance by spawning many actors first
|
||||
let rt = std_runtime(RuntimeConfig {
|
||||
num_threads: 2,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Phase 1: Spawn 20 actors. With round-robin, they split ~10/10.
|
||||
let mut addrs = Vec::new();
|
||||
for _ in 0..20 {
|
||||
addrs.push(rt.spawn(CounterActor { count: 0 }).unwrap());
|
||||
}
|
||||
|
||||
// Run so stats propagate, then bombard worker 0's actors with messages
|
||||
// to create mailbox depth imbalance.
|
||||
let handle = rt.run().unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
|
||||
// Send 500 messages to the first 10 actors (likely on worker 0).
|
||||
for addr in &addrs[..10] {
|
||||
for _ in 0..50 {
|
||||
let _ = handle.runtime.send_to(*addr, Increment {
|
||||
reply_to: *addr, // self-reply to keep mailbox depth up
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
|
||||
// Phase 2: Spawn 10 more actors. With load-aware placement,
|
||||
// they should bias toward the lighter worker.
|
||||
let mut late_addrs = Vec::new();
|
||||
for _ in 0..10 {
|
||||
late_addrs.push(handle.runtime.spawn(CounterActor { count: 0 }).unwrap());
|
||||
}
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
|
||||
let stats = handle.runtime.stats();
|
||||
handle.shutdown();
|
||||
handle.join();
|
||||
|
||||
// Verify the system is operational — both workers should have actors
|
||||
let total_actors: usize = stats.workers.iter().map(|w| w.num_actors).sum();
|
||||
assert!(total_actors >= 20, "expected at least 20 actors, got {}", total_actors);
|
||||
|
||||
// The lighter worker should have gotten more of the late actors.
|
||||
assert!(
|
||||
stats.workers.iter().all(|w| w.num_actors > 0),
|
||||
"both workers should have actors, got {:?}",
|
||||
stats.workers.iter().map(|w| w.num_actors).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
/// Given a single-threaded runtime (1 worker),
|
||||
/// when many actors are spawned,
|
||||
/// then all go to worker 0 regardless of load (no panic, no error).
|
||||
#[test]
|
||||
fn load_aware_placement_single_worker_degrades_gracefully() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
|
||||
for _ in 0..50 {
|
||||
rt.spawn(CounterActor { count: 0 }).unwrap();
|
||||
}
|
||||
|
||||
// Tick several times to let stats update
|
||||
for _ in 0..10 {
|
||||
rt.tick();
|
||||
}
|
||||
|
||||
let stats = rt.stats();
|
||||
assert_eq!(stats.workers.len(), 1);
|
||||
assert_eq!(stats.workers[0].num_actors, 50);
|
||||
}
|
||||
|
||||
/// Given a fresh runtime with no prior ticks,
|
||||
/// when actors are spawned in a burst,
|
||||
/// then they distribute evenly (round-robin fallback when stats are all zero).
|
||||
#[test]
|
||||
fn load_aware_placement_falls_back_to_round_robin_on_fresh_runtime() {
|
||||
let rt = std_runtime(RuntimeConfig {
|
||||
num_threads: 4,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Spawn 100 actors before any ticks (all stats are zero)
|
||||
for _ in 0..100 {
|
||||
rt.spawn(CounterActor { count: 0 }).unwrap();
|
||||
}
|
||||
|
||||
let handle = rt.run().unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
|
||||
let stats = handle.runtime.stats();
|
||||
handle.shutdown();
|
||||
handle.join();
|
||||
|
||||
// With 4 workers and 100 actors, each should have ~25 (+-5).
|
||||
for w in &stats.workers {
|
||||
assert!(
|
||||
w.num_actors >= 20 && w.num_actors <= 30,
|
||||
"worker {} has {} actors, expected ~25 (round-robin)",
|
||||
w.id, w.num_actors
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mailbox Backpressure Tests ─────────────────────────────────────────────
|
||||
|
||||
/// Given a runtime with bounded mailboxes (capacity=10, DropNewest),
|
||||
/// when 50 messages are sent to an actor before any ticks,
|
||||
/// then only the first 10 are delivered and the rest are dropped.
|
||||
#[test]
|
||||
fn bounded_mailbox_drop_newest_caps_at_capacity() {
|
||||
let rt = std_runtime(RuntimeConfig {
|
||||
default_mailbox_capacity: 10,
|
||||
mailbox_overflow: MailboxOverflow::DropNewest,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let inbox = rt.new_inbox::<Count>().unwrap();
|
||||
let addr = rt.spawn(CounterActor { count: 0 }).unwrap();
|
||||
|
||||
// Send 50 messages — only first 10 should be queued
|
||||
for _ in 0..50 {
|
||||
let _ = rt.send_to(addr, Increment { reply_to: *inbox.addr() });
|
||||
}
|
||||
|
||||
// Tick enough times to process all queued messages
|
||||
for _ in 0..20 {
|
||||
rt.tick();
|
||||
}
|
||||
|
||||
// Count replies — should be exactly 10 (the mailbox capacity)
|
||||
let mut replies = 0;
|
||||
while inbox.try_recv().is_some() {
|
||||
replies += 1;
|
||||
}
|
||||
assert_eq!(replies, 10, "should deliver exactly mailbox_capacity messages");
|
||||
|
||||
// Stats should show drops
|
||||
let stats = rt.stats();
|
||||
let total_drops: u64 = stats.workers.iter().map(|w| w.messages_dropped).sum();
|
||||
assert_eq!(total_drops, 40, "40 messages should have been dropped");
|
||||
}
|
||||
|
||||
/// Given a runtime with bounded mailboxes (capacity=5, DropOldest),
|
||||
/// when 10 messages are sent before any tick,
|
||||
/// then only the 5 most recent messages are delivered.
|
||||
#[test]
|
||||
fn bounded_mailbox_drop_oldest_keeps_newest() {
|
||||
let rt = std_runtime(RuntimeConfig {
|
||||
default_mailbox_capacity: 5,
|
||||
mailbox_overflow: MailboxOverflow::DropOldest,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let inbox = rt.new_inbox::<Done>().unwrap();
|
||||
let addr = rt.spawn(DoubleActor).unwrap();
|
||||
|
||||
// Send messages with values 0..10. DoubleActor replies Done(value * 2).
|
||||
for i in 0..10 {
|
||||
let _ = rt.send_to(addr, Forward {
|
||||
value: i,
|
||||
reply_to: *inbox.addr(),
|
||||
});
|
||||
}
|
||||
|
||||
for _ in 0..10 {
|
||||
rt.tick();
|
||||
}
|
||||
|
||||
// Collect all replies
|
||||
let mut replies = Vec::new();
|
||||
while let Some(Done(v)) = inbox.try_recv() {
|
||||
replies.push(v);
|
||||
}
|
||||
|
||||
assert_eq!(replies.len(), 5, "should deliver exactly 5 messages");
|
||||
// The 5 most recent: values 5,6,7,8,9 -> doubled: 10,12,14,16,18
|
||||
assert_eq!(replies, vec![10, 12, 14, 16, 18], "should keep the newest messages");
|
||||
}
|
||||
|
||||
/// Given a runtime with unbounded mailboxes (capacity=0, the default),
|
||||
/// when many messages are sent,
|
||||
/// then all are delivered (backward compatibility).
|
||||
#[test]
|
||||
fn unbounded_mailbox_delivers_all_messages() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
|
||||
let inbox = rt.new_inbox::<Count>().unwrap();
|
||||
let addr = rt.spawn(CounterActor { count: 0 }).unwrap();
|
||||
|
||||
for _ in 0..200 {
|
||||
let _ = rt.send_to(addr, Increment { reply_to: *inbox.addr() });
|
||||
}
|
||||
|
||||
for _ in 0..50 {
|
||||
rt.tick();
|
||||
}
|
||||
|
||||
let mut replies = 0;
|
||||
while inbox.try_recv().is_some() {
|
||||
replies += 1;
|
||||
}
|
||||
assert_eq!(replies, 200, "all 200 messages should be delivered with unbounded mailbox");
|
||||
|
||||
let stats = rt.stats();
|
||||
let total_drops: u64 = stats.workers.iter().map(|w| w.messages_dropped).sum();
|
||||
assert_eq!(total_drops, 0, "no drops with unbounded mailbox");
|
||||
}
|
||||
|
||||
/// Given bounded mailboxes with budget, when an actor processes messages
|
||||
/// and frees mailbox space, then new messages should be accepted on subsequent ticks.
|
||||
#[test]
|
||||
fn bounded_mailbox_refills_after_processing() {
|
||||
let rt = std_runtime(RuntimeConfig {
|
||||
default_mailbox_capacity: 5,
|
||||
actor_message_budget: 5,
|
||||
mailbox_overflow: MailboxOverflow::DropNewest,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let inbox = rt.new_inbox::<Count>().unwrap();
|
||||
let addr = rt.spawn(CounterActor { count: 0 }).unwrap();
|
||||
|
||||
// Send first batch of 5 — fills mailbox exactly
|
||||
for _ in 0..5 {
|
||||
let _ = rt.send_to(addr, Increment { reply_to: *inbox.addr() });
|
||||
}
|
||||
|
||||
// Tick to process all 5 (budget=5, capacity=5)
|
||||
rt.tick();
|
||||
|
||||
// Send second batch of 5 — mailbox is empty, so all 5 should be accepted
|
||||
for _ in 0..5 {
|
||||
let _ = rt.send_to(addr, Increment { reply_to: *inbox.addr() });
|
||||
}
|
||||
|
||||
rt.tick();
|
||||
|
||||
let mut replies = 0;
|
||||
while inbox.try_recv().is_some() {
|
||||
replies += 1;
|
||||
}
|
||||
assert_eq!(replies, 10, "all 10 messages across 2 batches should be processed");
|
||||
|
||||
let stats = rt.stats();
|
||||
let total_drops: u64 = stats.workers.iter().map(|w| w.messages_dropped).sum();
|
||||
assert_eq!(total_drops, 0, "no drops when mailbox drains between batches");
|
||||
}
|
||||
|
||||
// ── Dead Actor Cleanup Tests ───────────────────────────────────────────────
|
||||
|
||||
/// Given an actor that panics and is poisoned,
|
||||
/// when ticks continue,
|
||||
/// then the actor is removed from stats and sends to its address fail.
|
||||
#[test]
|
||||
fn dead_actor_cleaned_up_from_stats_and_address_map() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
|
||||
let good = rt.spawn(PingPongActor).unwrap();
|
||||
let bad = rt.spawn(PanicActor).unwrap();
|
||||
|
||||
// Trigger panic
|
||||
let _ = rt.send_to(bad, PanicMsg);
|
||||
for _ in 0..5 { rt.tick(); }
|
||||
|
||||
let stats = rt.stats();
|
||||
// Good actor still present, bad actor cleaned up
|
||||
assert_eq!(stats.workers[0].num_actors, 1, "only the healthy actor should remain");
|
||||
assert!(
|
||||
stats.actors.iter().any(|(a, _)| *a == good),
|
||||
"good actor should be in address map"
|
||||
);
|
||||
assert!(
|
||||
!stats.actors.iter().any(|(a, _)| *a == bad),
|
||||
"poisoned actor should be removed from address map"
|
||||
);
|
||||
|
||||
// Sends to cleaned-up actor fail
|
||||
let result = rt.send_to(bad, PanicMsg);
|
||||
assert!(result.is_err(), "send to cleaned-up actor should fail");
|
||||
}
|
||||
|
||||
/// Given many actors that all panic,
|
||||
/// when ticks proceed,
|
||||
/// then all are cleaned up and stats reflect zero actors.
|
||||
#[test]
|
||||
fn bulk_dead_actor_cleanup() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
|
||||
let mut addrs = Vec::new();
|
||||
for _ in 0..20 {
|
||||
addrs.push(rt.spawn(PanicActor).unwrap());
|
||||
}
|
||||
|
||||
// Trigger all panics
|
||||
for &addr in &addrs {
|
||||
let _ = rt.send_to(addr, PanicMsg);
|
||||
}
|
||||
for _ in 0..10 { rt.tick(); }
|
||||
|
||||
let stats = rt.stats();
|
||||
assert_eq!(stats.workers[0].num_actors, 0, "all poisoned actors should be cleaned up");
|
||||
assert_eq!(
|
||||
stats.actors.len(), 0,
|
||||
"address map should be empty after all actors poisoned"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Actor Recovery Helpers ──────────────────────────────────────────────────
|
||||
|
||||
/// Handles Forward messages, replies Done(value * 2), panics on the panic_at-th message.
|
||||
struct RestartTestActor {
|
||||
count: usize,
|
||||
panic_at: usize,
|
||||
}
|
||||
|
||||
impl ActorInterface for RestartTestActor {
|
||||
type Incoming = Forward;
|
||||
type Response = Done;
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Forward) {
|
||||
self.count += 1;
|
||||
if self.count >= self.panic_at {
|
||||
panic!("intentional panic at message {}", self.count);
|
||||
}
|
||||
let _ = ctx.send(msg.reply_to, Done(msg.value * 2));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Actor Recovery Tests ───────────────────────────────────────────────────
|
||||
|
||||
/// Given an actor that panics,
|
||||
/// when it panics,
|
||||
/// then it is poisoned and future messages are discarded.
|
||||
#[test]
|
||||
fn non_restartable_actor_still_poisons_on_panic() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Done>().unwrap();
|
||||
|
||||
// Normal spawn — not restartable
|
||||
let addr = rt.spawn(RestartTestActor { count: 0, panic_at: 1 }).unwrap();
|
||||
|
||||
let _ = rt.send_to(addr, Forward { value: 42, reply_to: *inbox.addr() });
|
||||
for _ in 0..5 { rt.tick(); }
|
||||
|
||||
let stats = rt.stats();
|
||||
let total_panics: u64 = stats.workers.iter().map(|w| w.panics).sum();
|
||||
let total_restarts: u64 = stats.workers.iter().map(|w| w.restarts).sum();
|
||||
assert_eq!(total_panics, 1, "should panic");
|
||||
assert_eq!(total_restarts, 0, "should not restart (not restartable)");
|
||||
}
|
||||
|
|
@ -1,758 +0,0 @@
|
|||
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());
|
||||
}
|
||||
364
tests/runtime_stress.rs
Normal file
364
tests/runtime_stress.rs
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
//! Runtime Stress Tests — multi-threaded execution, parking, placement, and scale.
|
||||
//!
|
||||
//! Covers: single vs multi-threaded processing, high-volume MT delivery,
|
||||
//! panic isolation under load, worker parking/shutdown, sustained throughput,
|
||||
//! and load-aware actor placement.
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Poll `inbox` until a message arrives or `timeout` elapses.
|
||||
fn poll_inbox<M: swactor::actor::Message>(
|
||||
inbox: &Inbox<M>,
|
||||
timeout: Duration,
|
||||
) -> Option<M> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if let Some(msg) = inbox.try_recv() {
|
||||
return Some(msg);
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
return None;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait until `counter` reaches `target` or `timeout` elapses.
|
||||
fn wait_for_count(counter: &AtomicUsize, target: usize, timeout: Duration) -> usize {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
let n = counter.load(Ordering::SeqCst);
|
||||
if n >= target {
|
||||
return n;
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
return n;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Single-threaded vs multi-threaded runtime basics.
|
||||
///
|
||||
/// Story: We start with a single-threaded runtime driven by tick(), confirm
|
||||
/// nothing happens without ticking, then graduate to a multi-threaded runtime
|
||||
/// with run() and verify background processing, cross-worker delegation,
|
||||
/// custom thread counts, and clean shutdown.
|
||||
#[test]
|
||||
fn single_vs_multi_threaded_basics() {
|
||||
// ── Part A: Single-threaded requires tick() ──
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let addr = rt.spawn(PingPongActor).unwrap();
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
rt.send_to(addr, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||
|
||||
assert!(inbox.try_recv().is_none(), "no processing before tick");
|
||||
tick_n(&rt, 2);
|
||||
assert!(inbox.try_recv().is_some(), "tick() drives single-threaded processing");
|
||||
|
||||
// ── Part B: Multi-threaded processes without ticking ──
|
||||
let rt_mt = std_runtime(RuntimeConfig { num_threads: 4, ..Default::default() });
|
||||
let addr = rt_mt.spawn(PingPongActor).unwrap();
|
||||
let inbox = rt_mt.new_inbox::<Pong>().unwrap();
|
||||
rt_mt.send_to(addr, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||
|
||||
let handle = rt_mt.run().unwrap();
|
||||
let reply = poll_inbox(&inbox, Duration::from_secs(5));
|
||||
assert!(reply.is_some(), "background workers process without manual ticking");
|
||||
|
||||
// ── Part C: Cross-worker delegation (2 threads, spawn child from handler) ──
|
||||
let addr2 = handle.runtime.spawn(DelegatorActor).unwrap();
|
||||
let done_inbox = handle.runtime.new_inbox::<Done>().unwrap();
|
||||
handle.runtime.send_to(addr2, Forward { value: 3, reply_to: *done_inbox.addr() }).unwrap();
|
||||
|
||||
let reply = poll_inbox(&done_inbox, Duration::from_secs(5));
|
||||
assert_eq!(reply, Some(Done(6)), "cross-worker delegation delivers reply");
|
||||
|
||||
// ── Part D: Custom thread count reflected in stats ──
|
||||
let stats = handle.runtime.stats();
|
||||
assert_eq!(stats.num_workers, 4, "runtime respects requested thread count");
|
||||
|
||||
// ── Part E: Clean shutdown ──
|
||||
handle.shutdown();
|
||||
handle.join();
|
||||
// Test passes by not hanging.
|
||||
}
|
||||
|
||||
/// High-volume multi-threaded delivery.
|
||||
///
|
||||
/// Story: We throw large workloads at a 4-thread runtime — 50 senders
|
||||
/// each firing 100 messages at one receiver, 200 concurrent spawn+send
|
||||
/// pairs, and a 50-level chain that must hop across workers.
|
||||
#[test]
|
||||
fn mt_high_volume_delivery() {
|
||||
let cfg = || RuntimeConfig {
|
||||
num_threads: 4,
|
||||
max_actors: 5_000,
|
||||
channel_buffer_size: 10_000,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// ── Part A: 50 senders × 100 messages → one receiver ──
|
||||
{
|
||||
let rt = std_runtime(cfg());
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let dummy = rt.new_inbox::<Pong>().unwrap();
|
||||
let receiver = rt.spawn(CountingPingActor { counter: counter.clone() }).unwrap();
|
||||
|
||||
let total_expected = 50 * 100;
|
||||
for _ in 0..50 {
|
||||
for _ in 0..100 {
|
||||
rt.send_to(receiver, Ping { reply_to: *dummy.addr() }).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
let handle = rt.run().unwrap();
|
||||
let processed = wait_for_count(&counter, total_expected, Duration::from_secs(5));
|
||||
handle.shutdown();
|
||||
handle.join();
|
||||
assert_eq!(processed, total_expected, "all 5000 messages delivered to single receiver");
|
||||
}
|
||||
|
||||
// ── Part B: 200 concurrent spawn+send pairs ──
|
||||
{
|
||||
let rt = std_runtime(cfg());
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let dummy = rt.new_inbox::<Pong>().unwrap();
|
||||
for _ in 0..200 {
|
||||
let a = rt.spawn(CountingPingActor { counter: counter.clone() }).unwrap();
|
||||
rt.send_to(a, Ping { reply_to: *dummy.addr() }).unwrap();
|
||||
}
|
||||
|
||||
let handle = rt.run().unwrap();
|
||||
let received = wait_for_count(&counter, 200, Duration::from_secs(5));
|
||||
handle.shutdown();
|
||||
handle.join();
|
||||
assert_eq!(received, 200, "all 200 spawn+send pairs complete");
|
||||
}
|
||||
|
||||
// ── Part C: 50-level chain across workers ──
|
||||
{
|
||||
let rt = std_runtime(RuntimeConfig { num_threads: 2, max_actors: 5_000, ..Default::default() });
|
||||
let addr = rt.spawn(ChainActor).unwrap();
|
||||
let inbox = rt.new_inbox::<Done>().unwrap();
|
||||
rt.send_to(addr, ChainMsg { remaining: 50, depth: 0, reply_to: *inbox.addr() }).unwrap();
|
||||
|
||||
let handle = rt.run().unwrap();
|
||||
let reply = poll_inbox(&inbox, Duration::from_secs(5));
|
||||
handle.shutdown();
|
||||
handle.join();
|
||||
assert_eq!(reply, Some(Done(50)), "50-level chain completes across workers");
|
||||
}
|
||||
}
|
||||
|
||||
/// Panic isolation under multi-threaded load.
|
||||
///
|
||||
/// Story: 10 panicking actors and 10 healthy actors on 4 threads — every
|
||||
/// panic is isolated and all 1000 healthy messages are still processed.
|
||||
#[test]
|
||||
fn mt_panic_isolation_under_load() {
|
||||
let rt = std_runtime(RuntimeConfig {
|
||||
num_threads: 4,
|
||||
max_actors: 5_000,
|
||||
channel_buffer_size: 10_000,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let dummy = rt.new_inbox::<Pong>().unwrap();
|
||||
|
||||
let mut panic_addrs = Vec::new();
|
||||
let mut healthy_addrs = Vec::new();
|
||||
for _ in 0..10 {
|
||||
panic_addrs.push(rt.spawn(PanicActor).unwrap());
|
||||
healthy_addrs.push(rt.spawn(CountingPingActor { counter: counter.clone() }).unwrap());
|
||||
}
|
||||
|
||||
// Trigger panics and flood healthy actors.
|
||||
for &addr in &panic_addrs {
|
||||
rt.send_to(addr, PanicMsg).unwrap();
|
||||
}
|
||||
for &addr in &healthy_addrs {
|
||||
for _ in 0..100 {
|
||||
rt.send_to(addr, Ping { reply_to: *dummy.addr() }).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
let handle = rt.run().unwrap();
|
||||
let expected = 10 * 100;
|
||||
let processed = wait_for_count(&counter, expected, Duration::from_secs(5));
|
||||
handle.shutdown();
|
||||
handle.join();
|
||||
|
||||
assert_eq!(
|
||||
processed, expected,
|
||||
"all {expected} healthy messages processed despite panicking peers"
|
||||
);
|
||||
}
|
||||
|
||||
/// Worker parking and shutdown latency.
|
||||
///
|
||||
/// Story: Workers park after idle time. We verify they wake quickly on new
|
||||
/// messages, that messages sent after run() are delivered, and that shutdown
|
||||
/// wakes all parked workers promptly.
|
||||
#[test]
|
||||
fn worker_parking_and_shutdown() {
|
||||
// ── Part A: Parked workers wake on send ──
|
||||
let rt = std_runtime(RuntimeConfig { num_threads: 2, ..Default::default() });
|
||||
let addr = rt.spawn(PingPongActor).unwrap();
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
|
||||
let handle = rt.run().unwrap();
|
||||
std::thread::sleep(Duration::from_millis(50)); // Let workers park.
|
||||
|
||||
let before = Instant::now();
|
||||
handle.runtime.send_to(addr, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||
let reply = poll_inbox(&inbox, Duration::from_secs(1));
|
||||
let latency = before.elapsed();
|
||||
|
||||
assert!(reply.is_some(), "parked worker should wake and process");
|
||||
assert!(latency.as_millis() < 100, "wake latency should be <100ms, was {:?}", latency);
|
||||
|
||||
// ── Part B: Send after run() delivers ──
|
||||
let addr2 = handle.runtime.spawn(PingPongActor).unwrap();
|
||||
let inbox2 = handle.runtime.new_inbox::<Pong>().unwrap();
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
handle.runtime.send_to(addr2, Ping { reply_to: *inbox2.addr() }).unwrap();
|
||||
|
||||
let reply2 = poll_inbox(&inbox2, Duration::from_secs(5));
|
||||
assert!(reply2.is_some(), "message sent after run() must be delivered");
|
||||
|
||||
handle.shutdown();
|
||||
handle.join();
|
||||
|
||||
// ── Part C: Shutdown wakes parked workers quickly ──
|
||||
let rt2 = std_runtime(RuntimeConfig { num_threads: 4, ..Default::default() });
|
||||
let h2 = rt2.run().unwrap();
|
||||
std::thread::sleep(Duration::from_millis(50)); // Let workers park.
|
||||
|
||||
let before = Instant::now();
|
||||
h2.shutdown();
|
||||
h2.join();
|
||||
let shutdown_time = before.elapsed();
|
||||
|
||||
assert!(
|
||||
shutdown_time.as_millis() < 500,
|
||||
"shutdown should complete quickly with parked workers, took {:?}",
|
||||
shutdown_time
|
||||
);
|
||||
}
|
||||
|
||||
/// Sustained throughput with no message loss.
|
||||
///
|
||||
/// Story: We send 10 batches of 100 messages, ticking between batches on a
|
||||
/// single-threaded runtime. Each batch must make forward progress, and after
|
||||
/// draining, all 1000 messages are accounted for.
|
||||
#[test]
|
||||
fn sustained_throughput_no_message_loss() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let dummy = rt.new_inbox::<Pong>().unwrap();
|
||||
let addr = rt.spawn(CountingPingActor { counter: counter.clone() }).unwrap();
|
||||
|
||||
for batch in 0..10 {
|
||||
for _ in 0..100 {
|
||||
rt.send_to(addr, Ping { reply_to: *dummy.addr() }).unwrap();
|
||||
}
|
||||
tick_n(&rt, 5);
|
||||
let processed = counter.load(Ordering::SeqCst);
|
||||
assert!(
|
||||
processed > batch * 50,
|
||||
"batch {batch}: expected progress, only {processed} processed"
|
||||
);
|
||||
}
|
||||
|
||||
// Drain remaining.
|
||||
tick_n(&rt, 100);
|
||||
let total = counter.load(Ordering::SeqCst);
|
||||
assert_eq!(total, 1000, "sustained load should not drop any messages");
|
||||
}
|
||||
|
||||
/// Load-aware actor placement.
|
||||
///
|
||||
/// Story: A fresh runtime falls back to round-robin (even distribution).
|
||||
/// Under imbalanced load, new actors bias toward the lighter worker.
|
||||
/// A single-worker runtime degrades gracefully.
|
||||
#[test]
|
||||
fn load_aware_actor_placement() {
|
||||
// ── Part A: Round-robin fallback on fresh runtime (4 workers, 100 actors) ──
|
||||
let rt = std_runtime(RuntimeConfig { num_threads: 4, ..Default::default() });
|
||||
for _ in 0..100 {
|
||||
rt.spawn(CounterActor { count: 0 }).unwrap();
|
||||
}
|
||||
|
||||
let handle = rt.run().unwrap();
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
|
||||
let stats = handle.runtime.stats();
|
||||
handle.shutdown();
|
||||
handle.join();
|
||||
|
||||
for w in &stats.workers {
|
||||
assert!(
|
||||
w.num_actors >= 20 && w.num_actors <= 30,
|
||||
"worker {} has {} actors, expected ~25 (round-robin)",
|
||||
w.id, w.num_actors
|
||||
);
|
||||
}
|
||||
|
||||
// ── Part B: Imbalanced load biases toward lighter worker ──
|
||||
let rt2 = std_runtime(RuntimeConfig { num_threads: 2, ..Default::default() });
|
||||
let mut addrs = Vec::new();
|
||||
for _ in 0..20 {
|
||||
addrs.push(rt2.spawn(CounterActor { count: 0 }).unwrap());
|
||||
}
|
||||
|
||||
let h2 = rt2.run().unwrap();
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
|
||||
// Bombard the first 10 actors (likely worker 0) with messages.
|
||||
for addr in &addrs[..10] {
|
||||
for _ in 0..50 {
|
||||
let _ = h2.runtime.send_to(*addr, Increment { reply_to: *addr });
|
||||
}
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
|
||||
// Spawn 10 more — should bias toward lighter worker.
|
||||
for _ in 0..10 {
|
||||
h2.runtime.spawn(CounterActor { count: 0 }).unwrap();
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
|
||||
let stats2 = h2.runtime.stats();
|
||||
h2.shutdown();
|
||||
h2.join();
|
||||
|
||||
let total_actors: usize = stats2.workers.iter().map(|w| w.num_actors).sum();
|
||||
assert!(total_actors >= 20, "expected at least 20 actors, got {total_actors}");
|
||||
assert!(
|
||||
stats2.workers.iter().all(|w| w.num_actors > 0),
|
||||
"both workers should have actors: {:?}",
|
||||
stats2.workers.iter().map(|w| w.num_actors).collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
// ── Part C: Single-worker degrades gracefully ──
|
||||
let rt3 = std_runtime(RuntimeConfig::default());
|
||||
for _ in 0..50 {
|
||||
rt3.spawn(CounterActor { count: 0 }).unwrap();
|
||||
}
|
||||
tick_n(&rt3, 10);
|
||||
|
||||
let stats3 = rt3.stats();
|
||||
assert_eq!(stats3.workers.len(), 1);
|
||||
assert_eq!(stats3.workers[0].num_actors, 50);
|
||||
}
|
||||
|
|
@ -1,866 +0,0 @@
|
|||
mod common;
|
||||
use common::*;
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
// ─── Supervisor Helpers ────────────────────────────────────────────────────
|
||||
|
||||
/// Actor that panics after receiving a configurable number of messages.
|
||||
struct PanicAfterN {
|
||||
trigger: usize,
|
||||
count: usize,
|
||||
counter: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl ActorInterface for PanicAfterN {
|
||||
type Incoming = Ping;
|
||||
type Response = ();
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Ping) {
|
||||
self.count += 1;
|
||||
self.counter.fetch_add(1, Ordering::SeqCst);
|
||||
let _ = ctx.send(msg.reply_to, Pong);
|
||||
if self.count >= self.trigger {
|
||||
panic!("intentional panic at message {}", self.count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- handle_down tests ---
|
||||
|
||||
/// Given an actor with handle_down and a monitored target,
|
||||
/// when the target dies, the watcher receives a Down via handle_down.
|
||||
#[test]
|
||||
fn handle_down_receives_death_notification() {
|
||||
struct MonitoringTracker {
|
||||
target: ActorAddress,
|
||||
downs: Vec<Down>,
|
||||
inbox: ActorAddress,
|
||||
}
|
||||
impl ActorInterface for MonitoringTracker {
|
||||
type Incoming = Ping;
|
||||
type Response = ();
|
||||
fn on_start(&mut self, ctx: &Ctx) {
|
||||
ctx.monitor(self.target);
|
||||
}
|
||||
fn handle(&mut self, ctx: &Ctx, _msg: Ping) {
|
||||
let _ = ctx.send(self.inbox, Count(self.downs.len()));
|
||||
}
|
||||
fn handle_down(&mut self, _ctx: &Ctx, down: Down) {
|
||||
self.downs.push(down);
|
||||
}
|
||||
}
|
||||
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Count>().unwrap();
|
||||
let inbox_addr = *inbox.addr();
|
||||
let target = rt.spawn(PanicActor).unwrap();
|
||||
let tracker = rt.spawn(MonitoringTracker {
|
||||
target,
|
||||
downs: vec![],
|
||||
inbox: inbox_addr,
|
||||
}).unwrap();
|
||||
rt.tick(); // on_start for both
|
||||
|
||||
// Kill the target
|
||||
rt.send_to(target, PanicMsg).unwrap();
|
||||
rt.tick(); // target panics
|
||||
rt.tick(); // Down delivered to tracker via handle_down
|
||||
|
||||
// Ask tracker how many downs it saw
|
||||
rt.send_to(tracker, Ping { reply_to: inbox_addr }).unwrap();
|
||||
rt.tick();
|
||||
assert_eq!(inbox.try_recv(), Some(Count(1)));
|
||||
}
|
||||
|
||||
/// Given an actor whose Incoming type IS Down, handle_down is NOT called --
|
||||
/// the Down goes through the normal handle() method (backward compatibility).
|
||||
#[test]
|
||||
fn handle_down_skipped_when_incoming_is_down() {
|
||||
struct DownAsIncoming {
|
||||
target: ActorAddress,
|
||||
inbox: ActorAddress,
|
||||
}
|
||||
impl ActorInterface for DownAsIncoming {
|
||||
type Incoming = Down;
|
||||
type Response = ();
|
||||
fn on_start(&mut self, ctx: &Ctx) {
|
||||
ctx.monitor(self.target);
|
||||
}
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Down) {
|
||||
let _ = ctx.send(self.inbox, msg);
|
||||
}
|
||||
fn handle_down(&mut self, _ctx: &Ctx, _down: Down) {
|
||||
panic!("handle_down must not be called when Incoming=Down");
|
||||
}
|
||||
}
|
||||
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Down>().unwrap();
|
||||
let inbox_addr = *inbox.addr();
|
||||
let target = rt.spawn(PanicActor).unwrap();
|
||||
let _watcher = rt.spawn(DownAsIncoming { target, inbox: inbox_addr }).unwrap();
|
||||
rt.tick(); // on_start
|
||||
|
||||
rt.send_to(target, PanicMsg).unwrap();
|
||||
rt.tick(); // panic
|
||||
rt.tick(); // Down delivered through handle(), not handle_down
|
||||
|
||||
let received = inbox.try_recv().expect("Down should be delivered via handle()");
|
||||
assert_eq!(received.reason, StopReason::Panicked);
|
||||
}
|
||||
|
||||
// --- ctx.stop_actor tests ---
|
||||
|
||||
/// Given two actors, one can stop the other via ctx.stop_actor().
|
||||
#[test]
|
||||
fn ctx_stop_actor_stops_target() {
|
||||
#[derive(Clone)]
|
||||
struct StopCmd {
|
||||
target: ActorAddress,
|
||||
}
|
||||
struct Stopper;
|
||||
impl ActorInterface for Stopper {
|
||||
type Incoming = StopCmd;
|
||||
type Response = ();
|
||||
fn handle(&mut self, ctx: &Ctx, msg: StopCmd) {
|
||||
let _ = ctx.stop_actor(msg.target);
|
||||
}
|
||||
}
|
||||
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let target = rt.spawn(PingPongActor).unwrap();
|
||||
let stopper = rt.spawn(Stopper).unwrap();
|
||||
rt.tick(); // on_start
|
||||
|
||||
rt.send_to(stopper, StopCmd { target }).unwrap();
|
||||
rt.tick(); // stopper handles StopCmd -> stop_actor(target)
|
||||
rt.tick(); // StopSignal delivered to target, target stops
|
||||
rt.tick(); // cleanup
|
||||
|
||||
assert!(rt.send_to(target, Ping { reply_to: ActorAddress::default() }).is_err());
|
||||
// Stopper should still be alive
|
||||
assert!(rt.send_to(stopper, StopCmd { target }).is_ok());
|
||||
}
|
||||
|
||||
// --- Supervisor tests ---
|
||||
|
||||
/// Given a supervisor with one permanent child,
|
||||
/// when the child panics, the supervisor restarts it.
|
||||
#[test]
|
||||
fn supervisor_restarts_permanent_child_on_panic() {
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let counter_c = counter.clone();
|
||||
let inbox_holder: Arc<std::sync::Mutex<Option<ActorAddress>>> =
|
||||
Arc::new(std::sync::Mutex::new(None));
|
||||
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
let inbox_addr = *inbox.addr();
|
||||
*inbox_holder.lock().unwrap() = Some(inbox_addr);
|
||||
|
||||
let sup = Supervisor::new(
|
||||
SupervisorStrategy::OneForOne,
|
||||
5,
|
||||
vec![ChildSpec::new("worker", RestartPolicy::Permanent, move |ctx| {
|
||||
ctx.spawn(PanicAfterN {
|
||||
trigger: 2, // panics on 2nd message
|
||||
count: 0,
|
||||
counter: counter_c.clone(),
|
||||
})
|
||||
})],
|
||||
);
|
||||
let _sup_addr = rt.spawn(sup).unwrap();
|
||||
rt.tick(); // supervisor on_start -> spawns child
|
||||
rt.tick(); // child on_start
|
||||
|
||||
// Find the child by checking stats
|
||||
let stats = rt.stats();
|
||||
assert_eq!(stats.workers[0].num_actors, 2); // supervisor + child
|
||||
|
||||
// Discover child address from stats
|
||||
let child_addr = stats.actors.iter()
|
||||
.find(|(addr, _)| *addr != _sup_addr)
|
||||
.map(|(addr, _)| *addr)
|
||||
.unwrap();
|
||||
|
||||
// First message: child processes, increments counter
|
||||
rt.send_to(child_addr, Ping { reply_to: inbox_addr }).unwrap();
|
||||
rt.tick();
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 1);
|
||||
|
||||
// Second message: child panics (trigger=2)
|
||||
rt.send_to(child_addr, Ping { reply_to: inbox_addr }).unwrap();
|
||||
rt.tick(); // child panics and is poisoned
|
||||
rt.tick(); // cleanup: Down delivered to supervisor via handle_down
|
||||
rt.tick(); // supervisor restarts child (spawns new one)
|
||||
rt.tick(); // new child on_start
|
||||
|
||||
// Supervisor is still alive, and a new child exists
|
||||
let stats = rt.stats();
|
||||
assert_eq!(stats.workers[0].num_actors, 2); // supervisor + new child
|
||||
}
|
||||
|
||||
/// Given a supervisor with a transient child,
|
||||
/// when the child stops normally, it is NOT restarted.
|
||||
#[test]
|
||||
fn supervisor_does_not_restart_transient_child_on_normal_stop() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
|
||||
struct StopsAfterFirst;
|
||||
impl ActorInterface for StopsAfterFirst {
|
||||
type Incoming = Ping;
|
||||
type Response = ();
|
||||
fn handle(&mut self, ctx: &Ctx, _msg: Ping) {
|
||||
ctx.stop_self();
|
||||
}
|
||||
}
|
||||
|
||||
let sup = Supervisor::new(
|
||||
SupervisorStrategy::OneForOne,
|
||||
5,
|
||||
vec![ChildSpec::new("worker", RestartPolicy::Transient, |ctx| {
|
||||
ctx.spawn(StopsAfterFirst)
|
||||
})],
|
||||
);
|
||||
let sup_addr = rt.spawn(sup).unwrap();
|
||||
rt.tick(); // supervisor on_start -> child spawned
|
||||
rt.tick(); // child on_start
|
||||
|
||||
let stats = rt.stats();
|
||||
assert_eq!(stats.workers[0].num_actors, 2); // sup + child
|
||||
|
||||
// Find child address
|
||||
let child_addr = stats.actors.iter()
|
||||
.find(|(addr, _)| *addr != sup_addr)
|
||||
.map(|(addr, _)| *addr)
|
||||
.unwrap();
|
||||
|
||||
// Send message -- child stops itself
|
||||
rt.send_to(child_addr, Ping { reply_to: ActorAddress::default() }).unwrap();
|
||||
rt.tick(); // child handles, stops self
|
||||
rt.tick(); // cleanup: Down(Normal) delivered to supervisor
|
||||
rt.tick(); // supervisor sees Transient + Normal -> no restart
|
||||
|
||||
let stats = rt.stats();
|
||||
assert_eq!(stats.workers[0].num_actors, 1); // only supervisor remains
|
||||
}
|
||||
|
||||
/// Given a supervisor with a transient child,
|
||||
/// when the child panics, it IS restarted.
|
||||
#[test]
|
||||
fn supervisor_restarts_transient_child_on_panic() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let counter_c = counter.clone();
|
||||
|
||||
let sup = Supervisor::new(
|
||||
SupervisorStrategy::OneForOne,
|
||||
5,
|
||||
vec![ChildSpec::new("worker", RestartPolicy::Transient, move |ctx| {
|
||||
ctx.spawn(PanicAfterN {
|
||||
trigger: 1, // panics on first message
|
||||
count: 0,
|
||||
counter: counter_c.clone(),
|
||||
})
|
||||
})],
|
||||
);
|
||||
let sup_addr = rt.spawn(sup).unwrap();
|
||||
rt.tick(); // supervisor starts, spawns child
|
||||
rt.tick(); // child on_start
|
||||
|
||||
let child_addr = rt.stats().actors.iter()
|
||||
.find(|(addr, _)| *addr != sup_addr)
|
||||
.map(|(addr, _)| *addr)
|
||||
.unwrap();
|
||||
|
||||
// Send message -- child panics
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
rt.send_to(child_addr, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||
rt.tick(); // child panics
|
||||
rt.tick(); // Down(Panicked) -> supervisor restarts
|
||||
rt.tick(); // new child spawned
|
||||
rt.tick(); // new child on_start
|
||||
|
||||
// Supervisor + new child alive
|
||||
let stats = rt.stats();
|
||||
assert_eq!(stats.workers[0].num_actors, 2);
|
||||
}
|
||||
|
||||
/// Given a supervisor with a temporary child,
|
||||
/// when the child dies (any reason), it is never restarted.
|
||||
#[test]
|
||||
fn supervisor_never_restarts_temporary_child() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
|
||||
let sup = Supervisor::new(
|
||||
SupervisorStrategy::OneForOne,
|
||||
5,
|
||||
vec![ChildSpec::new("worker", RestartPolicy::Temporary, |ctx| {
|
||||
ctx.spawn(PanicActor)
|
||||
})],
|
||||
);
|
||||
let sup_addr = rt.spawn(sup).unwrap();
|
||||
rt.tick(); // supervisor starts, spawns child
|
||||
rt.tick(); // child on_start
|
||||
|
||||
let child_addr = rt.stats().actors.iter()
|
||||
.find(|(addr, _)| *addr != sup_addr)
|
||||
.map(|(addr, _)| *addr)
|
||||
.unwrap();
|
||||
|
||||
// Kill the child
|
||||
rt.send_to(child_addr, PanicMsg).unwrap();
|
||||
rt.tick(); // panic
|
||||
rt.tick(); // Down -> supervisor sees Temporary -> no restart
|
||||
rt.tick(); // settle
|
||||
|
||||
let stats = rt.stats();
|
||||
assert_eq!(stats.workers[0].num_actors, 1); // only supervisor
|
||||
}
|
||||
|
||||
/// Given a supervisor with max_restarts=2,
|
||||
/// when more than 2 restarts occur, the supervisor stops itself (meltdown).
|
||||
#[test]
|
||||
fn supervisor_meltdown_after_max_restarts() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let sup = Supervisor::new(
|
||||
SupervisorStrategy::OneForOne,
|
||||
2, // only 2 restarts allowed
|
||||
vec![ChildSpec::new("crasher", RestartPolicy::Permanent, {
|
||||
let counter = counter.clone();
|
||||
move |ctx| {
|
||||
ctx.spawn(PanicAfterN {
|
||||
trigger: 1,
|
||||
count: 0,
|
||||
counter: counter.clone(),
|
||||
})
|
||||
}
|
||||
})],
|
||||
);
|
||||
let sup_addr = rt.spawn(sup).unwrap();
|
||||
rt.tick(); rt.tick(); // supervisor + child started
|
||||
|
||||
// Crash the child 3 times
|
||||
for _ in 0..3 {
|
||||
if let Some((child_addr, _)) = rt.stats().actors.iter()
|
||||
.find(|(addr, _)| *addr != sup_addr)
|
||||
{
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
let _ = rt.send_to(*child_addr, Ping { reply_to: *inbox.addr() });
|
||||
rt.tick(); // child panics
|
||||
rt.tick(); // Down delivered -> restart or meltdown
|
||||
rt.tick(); // new child spawned (or supervisor stopped)
|
||||
rt.tick(); // settle
|
||||
}
|
||||
}
|
||||
|
||||
// After 3 crashes with max_restarts=2, supervisor should have stopped itself
|
||||
let stats = rt.stats();
|
||||
let sup_alive = stats.actors.iter().any(|(addr, _)| *addr == sup_addr);
|
||||
assert!(!sup_alive, "supervisor should have stopped after exceeding max_restarts");
|
||||
}
|
||||
|
||||
/// Given a supervisor with multiple children,
|
||||
/// when one child panics, only that child is restarted (OneForOne).
|
||||
#[test]
|
||||
fn supervisor_one_for_one_only_restarts_failed_child() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let counter_a = Arc::new(AtomicUsize::new(0));
|
||||
let counter_b = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let sup = Supervisor::new(
|
||||
SupervisorStrategy::OneForOne,
|
||||
5,
|
||||
vec![
|
||||
ChildSpec::new("crasher", RestartPolicy::Permanent, {
|
||||
let c = counter_a.clone();
|
||||
move |ctx| ctx.spawn_named("child_a", PanicAfterN {
|
||||
trigger: 1, count: 0, counter: c.clone(),
|
||||
})
|
||||
}),
|
||||
ChildSpec::new("stable", RestartPolicy::Permanent, {
|
||||
let c = counter_b.clone();
|
||||
move |ctx| ctx.spawn_named("child_b", CountingPingActor { counter: c.clone() })
|
||||
}),
|
||||
],
|
||||
);
|
||||
let _sup_addr = rt.spawn(sup).unwrap();
|
||||
rt.tick(); rt.tick(); // start up
|
||||
|
||||
let child_a = rt.where_is("child_a").expect("child_a should be named");
|
||||
let child_b = rt.where_is("child_b").expect("child_b should be named");
|
||||
|
||||
// Send to child_b to prove it's alive
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
rt.send_to(child_b, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||
rt.tick();
|
||||
let b_processed_before = counter_b.load(Ordering::SeqCst);
|
||||
assert!(b_processed_before >= 1);
|
||||
|
||||
// Crash child_a
|
||||
rt.send_to(child_a, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||
rt.tick(); // child_a panics
|
||||
rt.tick(); // Down -> supervisor restarts child_a
|
||||
rt.tick(); rt.tick(); // new child spawned + on_start
|
||||
|
||||
// child_b should still be alive (same address, same name)
|
||||
let child_b_after = rt.where_is("child_b").expect("child_b should still exist");
|
||||
assert_eq!(child_b, child_b_after, "child_b address should be unchanged");
|
||||
|
||||
rt.send_to(child_b, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||
rt.tick();
|
||||
assert!(counter_b.load(Ordering::SeqCst) > b_processed_before,
|
||||
"child_b should still be processing messages");
|
||||
|
||||
// Supervisor + 2 children should be alive
|
||||
assert_eq!(rt.stats().workers[0].num_actors, 3);
|
||||
}
|
||||
|
||||
/// Given a OneForAll supervisor with 3 children,
|
||||
/// when one child panics, ALL children are stopped and restarted in spec order.
|
||||
#[test]
|
||||
fn supervisor_one_for_all_restarts_all_on_single_failure() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let counter_a = Arc::new(AtomicUsize::new(0));
|
||||
let counter_b = Arc::new(AtomicUsize::new(0));
|
||||
let counter_c = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let sup = Supervisor::new(
|
||||
SupervisorStrategy::OneForAll,
|
||||
5,
|
||||
vec![
|
||||
ChildSpec::new("a", RestartPolicy::Permanent, {
|
||||
let c = counter_a.clone();
|
||||
move |ctx| ctx.spawn_named("ofa_a", PanicAfterN {
|
||||
trigger: 1, count: 0, counter: c.clone(),
|
||||
})
|
||||
}),
|
||||
ChildSpec::new("b", RestartPolicy::Permanent, {
|
||||
let c = counter_b.clone();
|
||||
move |ctx| ctx.spawn_named("ofa_b", CountingPingActor { counter: c.clone() })
|
||||
}),
|
||||
ChildSpec::new("c", RestartPolicy::Permanent, {
|
||||
let c = counter_c.clone();
|
||||
move |ctx| ctx.spawn_named("ofa_c", CountingPingActor { counter: c.clone() })
|
||||
}),
|
||||
],
|
||||
);
|
||||
let _sup_addr = rt.spawn(sup).unwrap();
|
||||
rt.tick(); rt.tick(); // startup
|
||||
|
||||
let old_b = rt.where_is("ofa_b").expect("ofa_b exists");
|
||||
let old_c = rt.where_is("ofa_c").expect("ofa_c exists");
|
||||
let child_a = rt.where_is("ofa_a").expect("ofa_a exists");
|
||||
|
||||
// Crash child_a
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
rt.send_to(child_a, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||
rt.tick(); // child_a panics
|
||||
// supervisor receives Down(a) -> OneForAll -> stops b and c
|
||||
for _ in 0..8 { rt.tick(); }
|
||||
|
||||
// All 3 children should be alive with NEW addresses
|
||||
let stats = rt.stats();
|
||||
assert_eq!(stats.workers[0].num_actors, 4); // sup + 3 new children
|
||||
|
||||
let new_b = rt.where_is("ofa_b").expect("ofa_b re-registered after restart");
|
||||
let new_c = rt.where_is("ofa_c").expect("ofa_c re-registered after restart");
|
||||
assert_ne!(old_b, new_b, "child_b should have a new address after restart");
|
||||
assert_ne!(old_c, new_c, "child_c should have a new address after restart");
|
||||
}
|
||||
|
||||
/// Given a RestForOne supervisor with children [a, b, c],
|
||||
/// when child b panics, children b and c are restarted.
|
||||
/// Child a is unaffected.
|
||||
#[test]
|
||||
fn supervisor_rest_for_one_restarts_rest_after_failed() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let counter_a = Arc::new(AtomicUsize::new(0));
|
||||
let counter_b = Arc::new(AtomicUsize::new(0));
|
||||
let counter_c = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let sup = Supervisor::new(
|
||||
SupervisorStrategy::RestForOne,
|
||||
5,
|
||||
vec![
|
||||
ChildSpec::new("a", RestartPolicy::Permanent, {
|
||||
let c = counter_a.clone();
|
||||
move |ctx| ctx.spawn_named("rfo_a", CountingPingActor { counter: c.clone() })
|
||||
}),
|
||||
ChildSpec::new("b", RestartPolicy::Permanent, {
|
||||
let c = counter_b.clone();
|
||||
move |ctx| ctx.spawn_named("rfo_b", PanicAfterN {
|
||||
trigger: 1, count: 0, counter: c.clone(),
|
||||
})
|
||||
}),
|
||||
ChildSpec::new("c", RestartPolicy::Permanent, {
|
||||
let c = counter_c.clone();
|
||||
move |ctx| ctx.spawn_named("rfo_c", CountingPingActor { counter: c.clone() })
|
||||
}),
|
||||
],
|
||||
);
|
||||
let _sup_addr = rt.spawn(sup).unwrap();
|
||||
rt.tick(); rt.tick(); // startup
|
||||
|
||||
let old_a = rt.where_is("rfo_a").expect("rfo_a exists");
|
||||
let old_c = rt.where_is("rfo_c").expect("rfo_c exists");
|
||||
let child_b = rt.where_is("rfo_b").expect("rfo_b exists");
|
||||
|
||||
// Crash child_b
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
rt.send_to(child_b, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||
rt.tick(); // child_b panics
|
||||
for _ in 0..8 { rt.tick(); }
|
||||
|
||||
// All 3 children should be alive
|
||||
let stats = rt.stats();
|
||||
assert_eq!(stats.workers[0].num_actors, 4); // sup + 3 children
|
||||
|
||||
// child_a should be UNCHANGED
|
||||
let new_a = rt.where_is("rfo_a").expect("rfo_a still exists");
|
||||
assert_eq!(old_a, new_a, "child_a should not be restarted in RestForOne when b fails");
|
||||
|
||||
// child_c should have a NEW address
|
||||
let new_c = rt.where_is("rfo_c").expect("rfo_c re-registered");
|
||||
assert_ne!(old_c, new_c, "child_c should have a new address after RestForOne restart");
|
||||
}
|
||||
|
||||
/// Given a OneForAll supervisor, when the last child of the failed set confirms death,
|
||||
/// all children are restarted in spec order.
|
||||
#[test]
|
||||
fn supervisor_one_for_all_waits_for_all_downs_before_restart() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
|
||||
let sup = Supervisor::new(
|
||||
SupervisorStrategy::OneForAll,
|
||||
5,
|
||||
vec![
|
||||
ChildSpec::new("x", RestartPolicy::Permanent, |ctx| ctx.spawn(PingPongActor)),
|
||||
ChildSpec::new("y", RestartPolicy::Permanent, |ctx| ctx.spawn(PingPongActor)),
|
||||
],
|
||||
);
|
||||
let sup_addr = rt.spawn(sup).unwrap();
|
||||
rt.tick(); rt.tick(); // startup
|
||||
|
||||
assert_eq!(rt.stats().workers[0].num_actors, 3); // sup + 2 children
|
||||
|
||||
// Stop one child
|
||||
let actors: Vec<_> = rt.stats().actors.iter()
|
||||
.filter(|(addr, _)| *addr != sup_addr)
|
||||
.map(|(addr, _)| *addr)
|
||||
.collect();
|
||||
rt.stop_actor(actors[0]).unwrap();
|
||||
|
||||
// Tick enough times for full cycle
|
||||
for _ in 0..10 { rt.tick(); }
|
||||
|
||||
// Should have supervisor + 2 new children
|
||||
assert_eq!(rt.stats().workers[0].num_actors, 3);
|
||||
}
|
||||
|
||||
/// Given a supervisor that stops, its children also stop.
|
||||
#[test]
|
||||
fn supervisor_on_stop_kills_children() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
|
||||
let sup = Supervisor::new(
|
||||
SupervisorStrategy::OneForOne,
|
||||
5,
|
||||
vec![
|
||||
ChildSpec::new("a", RestartPolicy::Permanent, |ctx| ctx.spawn(PingPongActor)),
|
||||
ChildSpec::new("b", RestartPolicy::Permanent, |ctx| ctx.spawn(PingPongActor)),
|
||||
],
|
||||
);
|
||||
let sup_addr = rt.spawn(sup).unwrap();
|
||||
rt.tick(); rt.tick(); // start up
|
||||
|
||||
assert_eq!(rt.stats().workers[0].num_actors, 3); // sup + 2 children
|
||||
|
||||
// Stop the supervisor
|
||||
rt.stop_actor(sup_addr).unwrap();
|
||||
rt.tick(); // StopSignal delivered to supervisor, on_stop sends stop to children
|
||||
rt.tick(); // supervisor cleaned up, stop signals delivered to children
|
||||
rt.tick(); // children stop
|
||||
rt.tick(); // children cleaned up
|
||||
|
||||
assert_eq!(rt.stats().workers[0].num_actors, 0);
|
||||
}
|
||||
|
||||
// ── Router tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn router_round_robin_distributes_across_workers() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let collected = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
|
||||
struct Collector(Arc<std::sync::Mutex<Vec<(ActorAddress, usize)>>>);
|
||||
#[derive(Clone)]
|
||||
struct Work(usize);
|
||||
impl ActorInterface for Collector {
|
||||
type Incoming = Work;
|
||||
type Response = ();
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Work) {
|
||||
self.0.lock().unwrap().push((ctx.self_addr(), msg.0));
|
||||
}
|
||||
}
|
||||
|
||||
let c = collected.clone();
|
||||
let router = Router::<Work>::new(
|
||||
RoutingStrategy::RoundRobin,
|
||||
3,
|
||||
move |ctx| ctx.spawn(Collector(c.clone())),
|
||||
10,
|
||||
);
|
||||
let router_addr = rt.spawn(router).unwrap();
|
||||
rt.tick(); // on_start spawns 3 workers
|
||||
|
||||
for i in 0..6 {
|
||||
rt.send_to(router_addr, Work(i)).unwrap();
|
||||
}
|
||||
rt.tick(); // router receives 6 Work messages, forwards to workers
|
||||
rt.tick(); // workers process their messages
|
||||
|
||||
let data = collected.lock().unwrap();
|
||||
assert_eq!(data.len(), 6);
|
||||
|
||||
// Count how many unique workers received messages
|
||||
let mut per_worker = std::collections::HashMap::new();
|
||||
for (addr, _) in data.iter() {
|
||||
*per_worker.entry(*addr).or_insert(0usize) += 1;
|
||||
}
|
||||
// All 3 workers should have received exactly 2 messages each
|
||||
assert_eq!(per_worker.len(), 3);
|
||||
for count in per_worker.values() {
|
||||
assert_eq!(*count, 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn router_broadcast_sends_to_all_workers() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let count = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
struct Counter(Arc<AtomicUsize>);
|
||||
#[derive(Clone)]
|
||||
struct Ping;
|
||||
impl ActorInterface for Counter {
|
||||
type Incoming = Ping;
|
||||
type Response = ();
|
||||
fn handle(&mut self, _ctx: &Ctx, _msg: Ping) {
|
||||
self.0.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
let c = count.clone();
|
||||
let router = Router::<Ping>::new(
|
||||
RoutingStrategy::Broadcast,
|
||||
3,
|
||||
move |ctx| ctx.spawn(Counter(c.clone())),
|
||||
10,
|
||||
);
|
||||
let router_addr = rt.spawn(router).unwrap();
|
||||
rt.tick(); // on_start spawns workers
|
||||
|
||||
rt.send_to(router_addr, Ping).unwrap();
|
||||
rt.tick(); // router broadcasts
|
||||
rt.tick(); // workers process
|
||||
|
||||
assert_eq!(count.load(Ordering::Relaxed), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn router_random_delivers_to_some_worker() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let collected = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
|
||||
struct Collector(Arc<std::sync::Mutex<Vec<ActorAddress>>>);
|
||||
#[derive(Clone)]
|
||||
struct Work;
|
||||
impl ActorInterface for Collector {
|
||||
type Incoming = Work;
|
||||
type Response = ();
|
||||
fn handle(&mut self, ctx: &Ctx, _msg: Work) {
|
||||
self.0.lock().unwrap().push(ctx.self_addr());
|
||||
}
|
||||
}
|
||||
|
||||
let c = collected.clone();
|
||||
let router = Router::<Work>::new(
|
||||
RoutingStrategy::Random,
|
||||
3,
|
||||
move |ctx| ctx.spawn(Collector(c.clone())),
|
||||
10,
|
||||
);
|
||||
let router_addr = rt.spawn(router).unwrap();
|
||||
rt.tick();
|
||||
|
||||
for _ in 0..30 {
|
||||
rt.send_to(router_addr, Work).unwrap();
|
||||
}
|
||||
rt.tick();
|
||||
rt.tick();
|
||||
|
||||
let data = collected.lock().unwrap();
|
||||
assert_eq!(data.len(), 30);
|
||||
|
||||
let unique: std::collections::HashSet<_> = data.iter().collect();
|
||||
assert!(unique.len() >= 2, "expected at least 2 workers used, got {}", unique.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn router_replaces_dead_worker() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let spawn_count = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
struct PanicOnFirst {
|
||||
first: bool,
|
||||
}
|
||||
#[derive(Clone)]
|
||||
struct Work;
|
||||
impl ActorInterface for PanicOnFirst {
|
||||
type Incoming = Work;
|
||||
type Response = ();
|
||||
fn handle(&mut self, _ctx: &Ctx, _msg: Work) {
|
||||
if self.first {
|
||||
self.first = false;
|
||||
panic!("first message panic");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sc = spawn_count.clone();
|
||||
let router = Router::<Work>::new(
|
||||
RoutingStrategy::RoundRobin,
|
||||
3,
|
||||
move |ctx| {
|
||||
let n = sc.fetch_add(1, Ordering::Relaxed);
|
||||
ctx.spawn(PanicOnFirst { first: n == 0 })
|
||||
},
|
||||
10,
|
||||
);
|
||||
let router_addr = rt.spawn(router).unwrap();
|
||||
rt.tick(); // spawn workers (3 spawned)
|
||||
assert_eq!(spawn_count.load(Ordering::Relaxed), 3);
|
||||
|
||||
// Send a message that will hit worker 0
|
||||
rt.send_to(router_addr, Work).unwrap();
|
||||
rt.tick(); // router forwards to worker 0
|
||||
rt.tick(); // worker 0 panics
|
||||
rt.tick(); // cleanup + Down delivered to router
|
||||
rt.tick(); // router spawns replacement
|
||||
rt.tick(); // replacement starts
|
||||
|
||||
// Should have spawned 4 total (3 original + 1 replacement)
|
||||
assert_eq!(spawn_count.load(Ordering::Relaxed), 4);
|
||||
|
||||
// Verify all 3 slots are live
|
||||
assert_eq!(rt.stats().workers[0].num_actors, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn router_meltdown_after_max_restarts() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
|
||||
struct AlwaysPanics;
|
||||
#[derive(Clone)]
|
||||
struct Work;
|
||||
impl ActorInterface for AlwaysPanics {
|
||||
type Incoming = Work;
|
||||
type Response = ();
|
||||
fn handle(&mut self, _ctx: &Ctx, _msg: Work) {
|
||||
panic!("always");
|
||||
}
|
||||
}
|
||||
|
||||
let router = Router::<Work>::new(
|
||||
RoutingStrategy::RoundRobin,
|
||||
1,
|
||||
|ctx| ctx.spawn(AlwaysPanics),
|
||||
2, // max 2 restarts
|
||||
);
|
||||
let router_addr = rt.spawn(router).unwrap();
|
||||
rt.tick(); // on_start
|
||||
|
||||
// Kill the worker 3 times (> max_restarts=2)
|
||||
for _ in 0..3 {
|
||||
rt.send_to(router_addr, Work).unwrap();
|
||||
for _ in 0..5 {
|
||||
rt.tick();
|
||||
}
|
||||
}
|
||||
|
||||
// After 3 restarts, router should have shut down
|
||||
for _ in 0..5 {
|
||||
rt.tick();
|
||||
}
|
||||
assert_eq!(rt.stats().workers[0].num_actors, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn router_on_stop_kills_workers() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
|
||||
struct Dummy;
|
||||
#[derive(Clone)]
|
||||
struct Work;
|
||||
impl ActorInterface for Dummy {
|
||||
type Incoming = Work;
|
||||
type Response = ();
|
||||
fn handle(&mut self, _ctx: &Ctx, _msg: Work) {}
|
||||
}
|
||||
|
||||
let router = Router::<Work>::new(
|
||||
RoutingStrategy::RoundRobin,
|
||||
3,
|
||||
|ctx| ctx.spawn(Dummy),
|
||||
10,
|
||||
);
|
||||
let router_addr = rt.spawn(router).unwrap();
|
||||
rt.tick(); // on_start
|
||||
assert_eq!(rt.stats().workers[0].num_actors, 4); // router + 3 workers
|
||||
|
||||
rt.stop_actor(router_addr).unwrap();
|
||||
for _ in 0..5 {
|
||||
rt.tick();
|
||||
}
|
||||
|
||||
assert_eq!(rt.stats().workers[0].num_actors, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn router_broadcast_multiple_messages_all_received() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let total = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
struct Sink(Arc<AtomicUsize>);
|
||||
#[derive(Clone)]
|
||||
struct Tick;
|
||||
impl ActorInterface for Sink {
|
||||
type Incoming = Tick;
|
||||
type Response = ();
|
||||
fn handle(&mut self, _ctx: &Ctx, _msg: Tick) {
|
||||
self.0.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
let t = total.clone();
|
||||
let router = Router::<Tick>::new(
|
||||
RoutingStrategy::Broadcast,
|
||||
3,
|
||||
move |ctx| ctx.spawn(Sink(t.clone())),
|
||||
10,
|
||||
);
|
||||
let router_addr = rt.spawn(router).unwrap();
|
||||
rt.tick();
|
||||
|
||||
for _ in 0..5 {
|
||||
rt.send_to(router_addr, Tick).unwrap();
|
||||
}
|
||||
rt.tick(); // router broadcasts
|
||||
rt.tick(); // workers process
|
||||
|
||||
assert_eq!(total.load(Ordering::Relaxed), 15);
|
||||
}
|
||||
|
|
@ -1,213 +0,0 @@
|
|||
mod common;
|
||||
use common::*;
|
||||
|
||||
// ── Timer Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Actor that schedules a one-shot timer in on_start: sends a Ping to target after N ticks.
|
||||
struct TimerStartActor {
|
||||
target: ActorAddress,
|
||||
delay_ticks: u64,
|
||||
}
|
||||
|
||||
impl ActorInterface for TimerStartActor {
|
||||
type Incoming = Ping;
|
||||
type Response = Pong;
|
||||
|
||||
fn on_start(&mut self, ctx: &Ctx) {
|
||||
ctx.send_after_ticks(self.target, Ping { reply_to: ctx.self_addr() }, self.delay_ticks);
|
||||
}
|
||||
|
||||
fn handle(&mut self, _ctx: &Ctx, _msg: Ping) {}
|
||||
}
|
||||
|
||||
/// Actor that schedules a one-shot timer when it receives a Forward message.
|
||||
struct DelayPingPongActor;
|
||||
|
||||
impl ActorInterface for DelayPingPongActor {
|
||||
type Incoming = Forward;
|
||||
type Response = Done;
|
||||
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Forward) {
|
||||
ctx.send_after_ticks(msg.reply_to, Done(msg.value), 3);
|
||||
}
|
||||
}
|
||||
|
||||
/// Actor that schedules an interval timer on start: sends Ping every N ticks.
|
||||
struct HeartbeatActor {
|
||||
target: ActorAddress,
|
||||
period: u64,
|
||||
}
|
||||
|
||||
impl ActorInterface for HeartbeatActor {
|
||||
type Incoming = Ping;
|
||||
type Response = Pong;
|
||||
|
||||
fn on_start(&mut self, ctx: &Ctx) {
|
||||
ctx.send_interval_ticks(self.target, Ping { reply_to: ctx.self_addr() }, self.period);
|
||||
}
|
||||
|
||||
fn handle(&mut self, _ctx: &Ctx, _msg: Ping) {}
|
||||
}
|
||||
|
||||
// ── Timer Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Given an actor that schedules a one-shot timer in on_start,
|
||||
/// when enough ticks pass,
|
||||
/// then the timer message is delivered to the target.
|
||||
#[test]
|
||||
fn one_shot_timer_fires_after_n_ticks() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Ping>().unwrap();
|
||||
|
||||
let _timer_actor = rt.spawn(TimerStartActor {
|
||||
target: *inbox.addr(),
|
||||
delay_ticks: 3,
|
||||
}).unwrap();
|
||||
|
||||
// Tick 1: on_start schedules timer (fire_at = current_tick + 3 = 4)
|
||||
rt.tick(); // tick 1: on_start, timer scheduled
|
||||
assert!(inbox.try_recv().is_none(), "no delivery before delay");
|
||||
|
||||
rt.tick(); // tick 2
|
||||
assert!(inbox.try_recv().is_none(), "no delivery on tick 2");
|
||||
|
||||
rt.tick(); // tick 3
|
||||
assert!(inbox.try_recv().is_none(), "no delivery on tick 3");
|
||||
|
||||
rt.tick(); // tick 4: timer fires
|
||||
let msg = inbox.try_recv();
|
||||
assert!(msg.is_some(), "timer message delivered after 3-tick delay");
|
||||
}
|
||||
|
||||
/// Given an actor that schedules a one-shot timer from a message handler,
|
||||
/// when enough ticks pass after the triggering message,
|
||||
/// then the delayed response arrives.
|
||||
#[test]
|
||||
fn handler_can_schedule_one_shot_timer() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Done>().unwrap();
|
||||
|
||||
let addr = rt.spawn(DelayPingPongActor).unwrap();
|
||||
|
||||
let _ = rt.send_to(addr, Forward { value: 42, reply_to: *inbox.addr() });
|
||||
rt.tick(); // process Forward, schedule timer (delay=3)
|
||||
|
||||
assert!(inbox.try_recv().is_none(), "no immediate reply");
|
||||
|
||||
rt.tick(); // tick 2
|
||||
rt.tick(); // tick 3
|
||||
assert!(inbox.try_recv().is_none(), "not yet");
|
||||
|
||||
rt.tick(); // tick 4: timer fires
|
||||
let reply = inbox.try_recv();
|
||||
assert_eq!(reply, Some(Done(42)), "delayed reply arrives after 3 ticks");
|
||||
}
|
||||
|
||||
/// Given a one-shot timer,
|
||||
/// when it fires,
|
||||
/// then it does NOT fire again on subsequent ticks (consumed).
|
||||
#[test]
|
||||
fn one_shot_timer_fires_only_once() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Ping>().unwrap();
|
||||
|
||||
let _timer_actor = rt.spawn(TimerStartActor {
|
||||
target: *inbox.addr(),
|
||||
delay_ticks: 1,
|
||||
}).unwrap();
|
||||
|
||||
rt.tick(); // on_start schedules timer
|
||||
rt.tick(); // timer fires
|
||||
assert!(inbox.try_recv().is_some(), "first fire");
|
||||
|
||||
// Subsequent ticks should NOT fire again
|
||||
for _ in 0..5 { rt.tick(); }
|
||||
assert!(inbox.try_recv().is_none(), "one-shot does not repeat");
|
||||
}
|
||||
|
||||
/// Given an interval timer with period 2,
|
||||
/// when multiple ticks pass,
|
||||
/// then the timer fires repeatedly every 2 ticks.
|
||||
#[test]
|
||||
fn interval_timer_fires_repeatedly() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Ping>().unwrap();
|
||||
|
||||
let _heartbeat = rt.spawn(HeartbeatActor {
|
||||
target: *inbox.addr(),
|
||||
period: 2,
|
||||
}).unwrap();
|
||||
|
||||
rt.tick(); // tick 1: on_start, interval scheduled (next_fire = current + 2 = 3)
|
||||
assert!(inbox.try_recv().is_none(), "no fire on tick 1");
|
||||
|
||||
rt.tick(); // tick 2
|
||||
assert!(inbox.try_recv().is_none(), "no fire on tick 2");
|
||||
|
||||
rt.tick(); // tick 3: first fire
|
||||
assert!(inbox.try_recv().is_some(), "fire on tick 3");
|
||||
|
||||
rt.tick(); // tick 4
|
||||
assert!(inbox.try_recv().is_none(), "no fire on tick 4");
|
||||
|
||||
rt.tick(); // tick 5: second fire
|
||||
assert!(inbox.try_recv().is_some(), "fire on tick 5");
|
||||
|
||||
rt.tick(); // tick 6
|
||||
assert!(inbox.try_recv().is_none(), "no fire on tick 6");
|
||||
|
||||
rt.tick(); // tick 7: third fire
|
||||
assert!(inbox.try_recv().is_some(), "fire on tick 7");
|
||||
}
|
||||
|
||||
/// Given an interval timer targeting an actor that gets stopped,
|
||||
/// when the actor is removed,
|
||||
/// then the interval timer is cleaned up (no orphan timers).
|
||||
#[test]
|
||||
fn interval_timer_cleaned_up_when_actor_dies() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let _inbox = rt.new_inbox::<Ping>().unwrap();
|
||||
|
||||
// Heartbeat sends to a counter that we'll kill
|
||||
let counter_addr = rt.spawn(CounterActor { count: 0 }).unwrap();
|
||||
|
||||
// HeartbeatActor sends Ping to counter every tick
|
||||
let _hb = rt.spawn(HeartbeatActor {
|
||||
target: counter_addr,
|
||||
period: 1,
|
||||
}).unwrap();
|
||||
|
||||
// Let it run a few ticks
|
||||
for _ in 0..3 { rt.tick(); }
|
||||
|
||||
// Stop the counter
|
||||
rt.stop_actor(counter_addr).unwrap();
|
||||
for _ in 0..5 { rt.tick(); }
|
||||
|
||||
// Counter is gone, interval timer should be GC'd.
|
||||
let stats = rt.stats();
|
||||
// Only the heartbeat actor should remain
|
||||
assert_eq!(stats.workers[0].num_actors, 1);
|
||||
}
|
||||
|
||||
/// Given a timer with delay 0,
|
||||
/// when the next tick fires,
|
||||
/// then the message is delivered immediately on the next tick.
|
||||
#[test]
|
||||
fn timer_with_zero_delay_fires_next_tick() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Ping>().unwrap();
|
||||
|
||||
let _timer_actor = rt.spawn(TimerStartActor {
|
||||
target: *inbox.addr(),
|
||||
delay_ticks: 0,
|
||||
}).unwrap();
|
||||
|
||||
rt.tick(); // on_start schedules timer with delay=0
|
||||
// Timer requests are processed after tick_all (phase 5.5)
|
||||
// Timer fires on the NEXT tick (phase 2.5)
|
||||
assert!(inbox.try_recv().is_none(), "not yet -- timer fires next tick");
|
||||
|
||||
rt.tick(); // timer fires
|
||||
assert!(inbox.try_recv().is_some(), "zero-delay timer fires on next tick");
|
||||
}
|
||||
742
tests/std_extension.rs
Normal file
742
tests/std_extension.rs
Normal file
|
|
@ -0,0 +1,742 @@
|
|||
//! StdExtension Tests — higher-level patterns from swactor-std.
|
||||
//!
|
||||
//! Covers: naming registry, groups/pub-sub, ask pattern, supervision
|
||||
//! strategies and restart policies, and router work distribution.
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
// ── Local actors ────────────────────────────────────────────────────────────
|
||||
|
||||
/// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns a named child from a handler.
|
||||
struct NamedSpawnerActor {
|
||||
reply_to: ActorAddress,
|
||||
}
|
||||
|
||||
impl ActorInterface for NamedSpawnerActor {
|
||||
type Incoming = Ping;
|
||||
type Response = ();
|
||||
fn handle(&mut self, ctx: &Ctx, _msg: Ping) {
|
||||
if let Ok(addr) = ctx.spawn_named("child", PingPongActor) {
|
||||
ctx.send(self.reply_to, MyAddr(addr)).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Panics after `trigger` messages.
|
||||
struct PanicAfterN {
|
||||
trigger: usize,
|
||||
count: usize,
|
||||
counter: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl ActorInterface for PanicAfterN {
|
||||
type Incoming = Ping;
|
||||
type Response = ();
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Ping) {
|
||||
self.count += 1;
|
||||
self.counter.fetch_add(1, Ordering::SeqCst);
|
||||
let _ = ctx.send(msg.reply_to, Pong);
|
||||
if self.count >= self.trigger {
|
||||
panic!("intentional panic at message {}", self.count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops itself on first message.
|
||||
struct StopsAfterFirst;
|
||||
impl ActorInterface for StopsAfterFirst {
|
||||
type Incoming = Ping;
|
||||
type Response = ();
|
||||
fn handle(&mut self, ctx: &Ctx, _msg: Ping) {
|
||||
ctx.stop_self();
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Naming Registry
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Full naming lifecycle: register, lookup, send, duplicate fails, auto-unregister
|
||||
/// on stop and panic, name reuse, registered_names list, manual unregister.
|
||||
#[test]
|
||||
fn naming_registry_lifecycle() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
|
||||
// Register "alice", lookup, send Ping → Pong
|
||||
let alice = rt.spawn_named("alice", PingPongActor).unwrap();
|
||||
assert_eq!(rt.where_is("alice"), Some(alice));
|
||||
rt.send_to(alice, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||
rt.tick();
|
||||
assert!(inbox.try_recv().is_some(), "named actor processes messages");
|
||||
|
||||
// Duplicate fails, original binding preserved
|
||||
assert!(rt.spawn_named("alice", PingPongActor).is_err());
|
||||
assert_eq!(rt.where_is("alice"), Some(alice));
|
||||
|
||||
// Unknown name → None
|
||||
assert_eq!(rt.where_is("ghost"), None);
|
||||
|
||||
// Stop "alice" → name freed
|
||||
rt.stop_actor(alice).unwrap();
|
||||
rt.tick();
|
||||
assert_eq!(rt.where_is("alice"), None, "name freed after stop");
|
||||
|
||||
// Reuse the name
|
||||
let alice2 = rt.spawn_named("alice", PingPongActor).unwrap();
|
||||
assert_ne!(alice, alice2);
|
||||
assert_eq!(rt.where_is("alice"), Some(alice2));
|
||||
|
||||
// Panic also frees the name
|
||||
let bob = rt.spawn_named("bob", PanicActor).unwrap();
|
||||
rt.tick();
|
||||
rt.send_to(bob, PanicMsg).unwrap();
|
||||
rt.tick();
|
||||
assert_eq!(rt.where_is("bob"), None, "name freed after panic");
|
||||
let _bob2 = rt.spawn_named("bob", PingPongActor).unwrap();
|
||||
assert!(rt.where_is("bob").is_some());
|
||||
|
||||
// registered_names enumerates all
|
||||
rt.spawn_named("gamma", PingPongActor).unwrap();
|
||||
let mut names = rt.registered_names();
|
||||
names.sort();
|
||||
assert!(names.contains(&"alice".to_string()));
|
||||
assert!(names.contains(&"bob".to_string()));
|
||||
assert!(names.contains(&"gamma".to_string()));
|
||||
|
||||
// Manual unregister: name freed but actor lives
|
||||
let charlie_inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
let charlie = rt.spawn_named("charlie", PingPongActor).unwrap();
|
||||
rt.tick();
|
||||
let removed = rt.unregister("charlie");
|
||||
assert_eq!(removed, Some(charlie));
|
||||
assert_eq!(rt.where_is("charlie"), None, "name freed by unregister");
|
||||
rt.send_to(charlie, Ping { reply_to: *charlie_inbox.addr() }).unwrap();
|
||||
rt.tick();
|
||||
assert!(charlie_inbox.try_recv().is_some(), "actor still alive after name unregistered");
|
||||
}
|
||||
|
||||
/// Actors resolve and register names from handlers using ctx.
|
||||
#[test]
|
||||
fn naming_from_actor_handlers() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<MyAddr>().unwrap();
|
||||
|
||||
// ctx.where_is from handler
|
||||
let target = rt.spawn_named("target", PingPongActor).unwrap();
|
||||
let looker = rt.spawn(NameLookupActor {
|
||||
target_name: "target",
|
||||
reply_to: *inbox.addr(),
|
||||
}).unwrap();
|
||||
rt.tick();
|
||||
rt.send_to(looker, Ping { reply_to: ActorAddress::default() }).unwrap();
|
||||
tick_n(&rt, 3);
|
||||
assert_eq!(inbox.try_recv(), Some(MyAddr(target)), "ctx.where_is resolves");
|
||||
|
||||
// ctx.spawn_named from handler
|
||||
let spawner = rt.spawn(NamedSpawnerActor { reply_to: *inbox.addr() }).unwrap();
|
||||
rt.tick();
|
||||
rt.send_to(spawner, Ping { reply_to: ActorAddress::default() }).unwrap();
|
||||
tick_n(&rt, 3);
|
||||
let child_addr = inbox.try_recv().expect("child address returned");
|
||||
assert_eq!(rt.where_is("child"), Some(child_addr.0), "name registered from handler");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Groups / Pub-Sub
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Full groups lifecycle: join, publish broadcasts, leave stops delivery,
|
||||
/// dead actor auto-removed, multi-group cleanup, empty group deleted,
|
||||
/// join and publish from handlers.
|
||||
#[test]
|
||||
fn groups_pub_sub_lifecycle() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
|
||||
// Join 3 actors, publish → all 3 get it
|
||||
let a = rt.spawn(PingPongActor).unwrap();
|
||||
let b = rt.spawn(PingPongActor).unwrap();
|
||||
let c = rt.spawn(PingPongActor).unwrap();
|
||||
rt.join_group(a, "workers");
|
||||
rt.join_group(b, "workers");
|
||||
rt.join_group(c, "workers");
|
||||
rt.tick();
|
||||
|
||||
let count = rt.publish_to("workers", Ping { reply_to: *inbox.addr() });
|
||||
assert_eq!(count, 3, "3 members, 3 messages sent");
|
||||
rt.tick();
|
||||
let mut pongs = 0;
|
||||
while inbox.try_recv().is_some() { pongs += 1; }
|
||||
assert_eq!(pongs, 3, "all 3 received");
|
||||
|
||||
// Leave stops delivery
|
||||
rt.leave_group(c, "workers");
|
||||
let count = rt.publish_to("workers", Ping { reply_to: *inbox.addr() });
|
||||
assert_eq!(count, 2, "2 after leave");
|
||||
rt.tick();
|
||||
let mut pongs = 0;
|
||||
while inbox.try_recv().is_some() { pongs += 1; }
|
||||
assert_eq!(pongs, 2);
|
||||
|
||||
// Dead actor auto-removed
|
||||
rt.stop_actor(b).unwrap();
|
||||
rt.tick();
|
||||
let count = rt.publish_to("workers", Ping { reply_to: *inbox.addr() });
|
||||
assert_eq!(count, 1, "dead actor removed");
|
||||
|
||||
// Multi-group cleanup: actor in alpha/beta/gamma dies → all cleaned
|
||||
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();
|
||||
assert!(rt.group_members("alpha").is_empty());
|
||||
assert!(rt.group_members("beta").is_empty());
|
||||
assert!(rt.group_members("gamma").is_empty());
|
||||
|
||||
// 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 removed");
|
||||
|
||||
// Empty group query
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
assert!(rt.group_members("nonexistent").is_empty());
|
||||
|
||||
// ctx.join_group from on_start
|
||||
struct GroupJoiner;
|
||||
impl ActorInterface for GroupJoiner {
|
||||
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 x = rt.spawn(GroupJoiner).unwrap();
|
||||
let y = rt.spawn(GroupJoiner).unwrap();
|
||||
rt.tick();
|
||||
let members = rt.group_members("auto-joined");
|
||||
assert_eq!(members.len(), 2);
|
||||
assert!(members.contains(&x));
|
||||
assert!(members.contains(&y));
|
||||
|
||||
// ctx.publish from handler
|
||||
#[derive(Clone)]
|
||||
struct BroadcastCmd { reply_to: ActorAddress }
|
||||
|
||||
struct Broadcaster;
|
||||
impl ActorInterface for Broadcaster {
|
||||
type Incoming = BroadcastCmd;
|
||||
type Response = ();
|
||||
fn on_start(&mut self, ctx: &Ctx) {
|
||||
ctx.join_group("bcast");
|
||||
}
|
||||
fn handle(&mut self, ctx: &Ctx, msg: BroadcastCmd) {
|
||||
ctx.publish("bcast", Ping { reply_to: msg.reply_to });
|
||||
}
|
||||
}
|
||||
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
let p1 = rt.spawn(PingPongActor).unwrap();
|
||||
let p2 = rt.spawn(PingPongActor).unwrap();
|
||||
rt.join_group(p1, "bcast");
|
||||
rt.join_group(p2, "bcast");
|
||||
let broadcaster = rt.spawn(Broadcaster).unwrap();
|
||||
rt.tick();
|
||||
rt.send_to(broadcaster, BroadcastCmd { reply_to: *inbox.addr() }).unwrap();
|
||||
tick_n(&rt, 3);
|
||||
let mut pongs = 0;
|
||||
while inbox.try_recv().is_some() { pongs += 1; }
|
||||
assert!(pongs >= 2, "at least 2 PingPong members replied, got {pongs}");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Ask Pattern
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Ask pattern: basic ask, repeated asks track state, try_recv before/after
|
||||
/// tick, dead actor times out.
|
||||
#[test]
|
||||
fn ask_pattern() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
|
||||
// Basic ask
|
||||
let actor = rt.spawn(PingPongActor).unwrap();
|
||||
rt.tick();
|
||||
let pong: Pong = rt.ask(actor, |reply_to| Ping { reply_to })
|
||||
.unwrap().recv_ticking(&rt, 10).unwrap();
|
||||
assert_eq!(pong, Pong);
|
||||
|
||||
// Repeated asks track state
|
||||
let counter = rt.spawn(CounterActor { count: 0 }).unwrap();
|
||||
rt.tick();
|
||||
let c1: Count = rt.ask(counter, |reply_to| Increment { reply_to })
|
||||
.unwrap().recv_ticking(&rt, 10).unwrap();
|
||||
let c2: Count = rt.ask(counter, |reply_to| Increment { reply_to })
|
||||
.unwrap().recv_ticking(&rt, 10).unwrap();
|
||||
let c3: Count = rt.ask(counter, |reply_to| Increment { reply_to })
|
||||
.unwrap().recv_ticking(&rt, 10).unwrap();
|
||||
assert_eq!((c1, c2, c3), (Count(1), Count(2), Count(3)));
|
||||
|
||||
// try_recv: None before tick, Some after
|
||||
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();
|
||||
assert!(ask.try_recv().is_none(), "no response before tick");
|
||||
rt.tick();
|
||||
assert_eq!(ask.try_recv(), Some(Pong));
|
||||
|
||||
// Dead actor → timeout
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let actor = rt.spawn(PingPongActor).unwrap();
|
||||
rt.tick();
|
||||
rt.stop_actor(actor).unwrap();
|
||||
rt.tick();
|
||||
if let Ok(ask) = rt.ask::<Ping, Pong>(actor, |reply_to| Ping { reply_to }) {
|
||||
assert!(ask.recv_ticking(&rt, 5).is_err(), "timeout with dead actor");
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Supervision
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Restart policies: permanent always restarts, transient only on panic,
|
||||
/// temporary never restarts, meltdown after max_restarts.
|
||||
#[test]
|
||||
fn supervision_restart_policies() {
|
||||
// Permanent child panics → restarted
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let counter_c = counter.clone();
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
let sup = Supervisor::new(
|
||||
SupervisorStrategy::OneForOne, 5,
|
||||
vec![ChildSpec::new("worker", RestartPolicy::Permanent, move |ctx| {
|
||||
ctx.spawn(PanicAfterN { trigger: 2, count: 0, counter: counter_c.clone() })
|
||||
})],
|
||||
);
|
||||
let sup_addr = rt.spawn(sup).unwrap();
|
||||
tick_n(&rt, 2);
|
||||
let child = rt.stats().actors.iter()
|
||||
.find(|(a, _)| *a != sup_addr).map(|(a, _)| *a).unwrap();
|
||||
rt.send_to(child, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||
rt.tick();
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 1);
|
||||
rt.send_to(child, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||
tick_n(&rt, 5); // panics, supervisor restarts
|
||||
assert_eq!(rt.stats().workers[0].num_actors, 2, "supervisor + restarted child");
|
||||
|
||||
// Transient stops normally → NOT restarted
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let sup = Supervisor::new(
|
||||
SupervisorStrategy::OneForOne, 5,
|
||||
vec![ChildSpec::new("worker", RestartPolicy::Transient, |ctx| {
|
||||
ctx.spawn(StopsAfterFirst)
|
||||
})],
|
||||
);
|
||||
let sup_addr = rt.spawn(sup).unwrap();
|
||||
tick_n(&rt, 2);
|
||||
let child = rt.stats().actors.iter()
|
||||
.find(|(a, _)| *a != sup_addr).map(|(a, _)| *a).unwrap();
|
||||
rt.send_to(child, Ping { reply_to: ActorAddress::default() }).unwrap();
|
||||
tick_n(&rt, 4);
|
||||
assert_eq!(rt.stats().workers[0].num_actors, 1, "transient+normal → no restart");
|
||||
|
||||
// Transient panics → restarted
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let counter_c = counter.clone();
|
||||
let sup = Supervisor::new(
|
||||
SupervisorStrategy::OneForOne, 5,
|
||||
vec![ChildSpec::new("worker", RestartPolicy::Transient, move |ctx| {
|
||||
ctx.spawn(PanicAfterN { trigger: 1, count: 0, counter: counter_c.clone() })
|
||||
})],
|
||||
);
|
||||
let sup_addr = rt.spawn(sup).unwrap();
|
||||
tick_n(&rt, 2);
|
||||
let child = rt.stats().actors.iter()
|
||||
.find(|(a, _)| *a != sup_addr).map(|(a, _)| *a).unwrap();
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
rt.send_to(child, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||
tick_n(&rt, 5);
|
||||
assert_eq!(rt.stats().workers[0].num_actors, 2, "transient+panic → restarted");
|
||||
|
||||
// Temporary never restarts
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let sup = Supervisor::new(
|
||||
SupervisorStrategy::OneForOne, 5,
|
||||
vec![ChildSpec::new("worker", RestartPolicy::Temporary, |ctx| ctx.spawn(PanicActor))],
|
||||
);
|
||||
let sup_addr = rt.spawn(sup).unwrap();
|
||||
tick_n(&rt, 2);
|
||||
let child = rt.stats().actors.iter()
|
||||
.find(|(a, _)| *a != sup_addr).map(|(a, _)| *a).unwrap();
|
||||
rt.send_to(child, PanicMsg).unwrap();
|
||||
tick_n(&rt, 4);
|
||||
assert_eq!(rt.stats().workers[0].num_actors, 1, "temporary → no restart");
|
||||
|
||||
// Meltdown: max_restarts=2, crash 3 times → supervisor stops
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let sup = Supervisor::new(
|
||||
SupervisorStrategy::OneForOne, 2,
|
||||
vec![ChildSpec::new("crasher", RestartPolicy::Permanent, {
|
||||
let c = counter.clone();
|
||||
move |ctx| ctx.spawn(PanicAfterN { trigger: 1, count: 0, counter: c.clone() })
|
||||
})],
|
||||
);
|
||||
let sup_addr = rt.spawn(sup).unwrap();
|
||||
tick_n(&rt, 2);
|
||||
for _ in 0..3 {
|
||||
if let Some((child, _)) = rt.stats().actors.iter()
|
||||
.find(|(a, _)| *a != sup_addr)
|
||||
{
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
let _ = rt.send_to(*child, Ping { reply_to: *inbox.addr() });
|
||||
tick_n(&rt, 5);
|
||||
}
|
||||
}
|
||||
let sup_alive = rt.stats().actors.iter().any(|(a, _)| *a == sup_addr);
|
||||
assert!(!sup_alive, "supervisor stopped after exceeding max_restarts");
|
||||
}
|
||||
|
||||
/// Strategies: OneForOne, OneForAll, RestForOne. Stopping supervisor kills children.
|
||||
#[test]
|
||||
fn supervision_strategies() {
|
||||
// OneForOne: only failed child restarted
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let counter_a = Arc::new(AtomicUsize::new(0));
|
||||
let counter_b = Arc::new(AtomicUsize::new(0));
|
||||
let sup = Supervisor::new(
|
||||
SupervisorStrategy::OneForOne, 5,
|
||||
vec![
|
||||
ChildSpec::new("crasher", RestartPolicy::Permanent, {
|
||||
let c = counter_a.clone();
|
||||
move |ctx| ctx.spawn_named("ofo_a", PanicAfterN {
|
||||
trigger: 1, count: 0, counter: c.clone(),
|
||||
})
|
||||
}),
|
||||
ChildSpec::new("stable", RestartPolicy::Permanent, {
|
||||
let c = counter_b.clone();
|
||||
move |ctx| ctx.spawn_named("ofo_b", CountingPingActor { counter: c.clone() })
|
||||
}),
|
||||
],
|
||||
);
|
||||
rt.spawn(sup).unwrap();
|
||||
tick_n(&rt, 2);
|
||||
let child_a = rt.where_is("ofo_a").unwrap();
|
||||
let child_b = rt.where_is("ofo_b").unwrap();
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
rt.send_to(child_a, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||
tick_n(&rt, 5);
|
||||
let child_b_after = rt.where_is("ofo_b").unwrap();
|
||||
assert_eq!(child_b, child_b_after, "child_b unchanged in OneForOne");
|
||||
rt.send_to(child_b, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||
rt.tick();
|
||||
assert!(counter_b.load(Ordering::SeqCst) >= 1, "child_b still processing");
|
||||
|
||||
// OneForAll: all children restarted
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let sup = Supervisor::new(
|
||||
SupervisorStrategy::OneForAll, 5,
|
||||
vec![
|
||||
ChildSpec::new("a", RestartPolicy::Permanent, {
|
||||
let c = Arc::new(AtomicUsize::new(0));
|
||||
move |ctx| ctx.spawn_named("ofa_a", PanicAfterN {
|
||||
trigger: 1, count: 0, counter: c.clone(),
|
||||
})
|
||||
}),
|
||||
ChildSpec::new("b", RestartPolicy::Permanent, {
|
||||
let c = Arc::new(AtomicUsize::new(0));
|
||||
move |ctx| ctx.spawn_named("ofa_b", CountingPingActor { counter: c.clone() })
|
||||
}),
|
||||
],
|
||||
);
|
||||
rt.spawn(sup).unwrap();
|
||||
tick_n(&rt, 2);
|
||||
let old_b = rt.where_is("ofa_b").unwrap();
|
||||
let child_a = rt.where_is("ofa_a").unwrap();
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
rt.send_to(child_a, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||
tick_n(&rt, 8);
|
||||
let new_b = rt.where_is("ofa_b").expect("ofa_b re-registered");
|
||||
assert_ne!(old_b, new_b, "child_b restarted in OneForAll");
|
||||
|
||||
// RestForOne: failed child + later children restarted, earlier unaffected
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let sup = Supervisor::new(
|
||||
SupervisorStrategy::RestForOne, 5,
|
||||
vec![
|
||||
ChildSpec::new("a", RestartPolicy::Permanent, {
|
||||
let c = Arc::new(AtomicUsize::new(0));
|
||||
move |ctx| ctx.spawn_named("rfo_a", CountingPingActor { counter: c.clone() })
|
||||
}),
|
||||
ChildSpec::new("b", RestartPolicy::Permanent, {
|
||||
let c = Arc::new(AtomicUsize::new(0));
|
||||
move |ctx| ctx.spawn_named("rfo_b", PanicAfterN {
|
||||
trigger: 1, count: 0, counter: c.clone(),
|
||||
})
|
||||
}),
|
||||
ChildSpec::new("c", RestartPolicy::Permanent, {
|
||||
let c = Arc::new(AtomicUsize::new(0));
|
||||
move |ctx| ctx.spawn_named("rfo_c", CountingPingActor { counter: c.clone() })
|
||||
}),
|
||||
],
|
||||
);
|
||||
rt.spawn(sup).unwrap();
|
||||
tick_n(&rt, 2);
|
||||
let old_a = rt.where_is("rfo_a").unwrap();
|
||||
let old_c = rt.where_is("rfo_c").unwrap();
|
||||
let child_b = rt.where_is("rfo_b").unwrap();
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
rt.send_to(child_b, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||
tick_n(&rt, 8);
|
||||
let new_a = rt.where_is("rfo_a").unwrap();
|
||||
let new_c = rt.where_is("rfo_c").expect("rfo_c re-registered");
|
||||
assert_eq!(old_a, new_a, "child_a unchanged in RestForOne");
|
||||
assert_ne!(old_c, new_c, "child_c restarted in RestForOne");
|
||||
|
||||
// Stopping supervisor kills children
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let sup = Supervisor::new(
|
||||
SupervisorStrategy::OneForOne, 5,
|
||||
vec![
|
||||
ChildSpec::new("a", RestartPolicy::Permanent, |ctx| ctx.spawn(PingPongActor)),
|
||||
ChildSpec::new("b", RestartPolicy::Permanent, |ctx| ctx.spawn(PingPongActor)),
|
||||
],
|
||||
);
|
||||
let sup_addr = rt.spawn(sup).unwrap();
|
||||
tick_n(&rt, 2);
|
||||
assert_eq!(rt.stats().workers[0].num_actors, 3);
|
||||
rt.stop_actor(sup_addr).unwrap();
|
||||
tick_n(&rt, 5);
|
||||
assert_eq!(rt.stats().workers[0].num_actors, 0, "stopping supervisor kills children");
|
||||
}
|
||||
|
||||
/// handle_down dispatch and ctx.stop_actor from handler.
|
||||
#[test]
|
||||
fn handle_down_dispatch() {
|
||||
// ctx.stop_actor from handler stops target
|
||||
#[derive(Clone)]
|
||||
struct StopCmd { target: ActorAddress }
|
||||
struct Stopper;
|
||||
impl ActorInterface for Stopper {
|
||||
type Incoming = StopCmd;
|
||||
type Response = ();
|
||||
fn handle(&mut self, ctx: &Ctx, msg: StopCmd) {
|
||||
let _ = ctx.stop_actor(msg.target);
|
||||
}
|
||||
}
|
||||
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let target = rt.spawn(PingPongActor).unwrap();
|
||||
let stopper = rt.spawn(Stopper).unwrap();
|
||||
rt.tick();
|
||||
rt.send_to(stopper, StopCmd { target }).unwrap();
|
||||
tick_n(&rt, 4);
|
||||
assert!(rt.send_to(target, Ping { reply_to: ActorAddress::default() }).is_err(),
|
||||
"target stopped by ctx.stop_actor");
|
||||
assert!(rt.send_to(stopper, StopCmd { target }).is_ok(), "stopper still alive");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Router
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Router distributes work: round-robin is even, broadcast hits all, random
|
||||
/// uses multiple workers. Dead workers replaced. Stop router kills workers.
|
||||
/// Meltdown after max restarts.
|
||||
#[test]
|
||||
fn router_work_distribution() {
|
||||
// Round-robin: 3 workers, 6 msgs → 2 each
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let collected = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
struct Collector(Arc<std::sync::Mutex<Vec<(ActorAddress, usize)>>>);
|
||||
#[derive(Clone)]
|
||||
struct Work(usize);
|
||||
impl ActorInterface for Collector {
|
||||
type Incoming = Work;
|
||||
type Response = ();
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Work) {
|
||||
self.0.lock().unwrap().push((ctx.self_addr(), msg.0));
|
||||
}
|
||||
}
|
||||
|
||||
let c = collected.clone();
|
||||
let router = Router::<Work>::new(
|
||||
RoutingStrategy::RoundRobin, 3,
|
||||
move |ctx| ctx.spawn(Collector(c.clone())), 10,
|
||||
);
|
||||
let router_addr = rt.spawn(router).unwrap();
|
||||
rt.tick();
|
||||
for i in 0..6 {
|
||||
rt.send_to(router_addr, Work(i)).unwrap();
|
||||
}
|
||||
tick_n(&rt, 3);
|
||||
let data = collected.lock().unwrap();
|
||||
assert_eq!(data.len(), 6);
|
||||
let mut per_worker = std::collections::HashMap::new();
|
||||
for (addr, _) in data.iter() {
|
||||
*per_worker.entry(*addr).or_insert(0usize) += 1;
|
||||
}
|
||||
assert_eq!(per_worker.len(), 3, "3 distinct workers");
|
||||
for count in per_worker.values() {
|
||||
assert_eq!(*count, 2, "each worker gets exactly 2");
|
||||
}
|
||||
|
||||
// Broadcast: 5 msgs to 3 workers → 15 total
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let total = Arc::new(AtomicUsize::new(0));
|
||||
struct BCounter(Arc<AtomicUsize>);
|
||||
#[derive(Clone)]
|
||||
struct BPing;
|
||||
impl ActorInterface for BCounter {
|
||||
type Incoming = BPing;
|
||||
type Response = ();
|
||||
fn handle(&mut self, _ctx: &Ctx, _msg: BPing) {
|
||||
self.0.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
let t = total.clone();
|
||||
let router = Router::<BPing>::new(
|
||||
RoutingStrategy::Broadcast, 3,
|
||||
move |ctx| ctx.spawn(BCounter(t.clone())), 10,
|
||||
);
|
||||
let router_addr = rt.spawn(router).unwrap();
|
||||
rt.tick();
|
||||
for _ in 0..5 {
|
||||
rt.send_to(router_addr, BPing).unwrap();
|
||||
}
|
||||
tick_n(&rt, 3);
|
||||
assert_eq!(total.load(Ordering::Relaxed), 15, "5 broadcasts × 3 workers = 15");
|
||||
|
||||
// Random: 30 msgs → at least 2 workers used
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let rcollected = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
struct RCollector(Arc<std::sync::Mutex<Vec<ActorAddress>>>);
|
||||
#[derive(Clone)]
|
||||
struct RWork;
|
||||
impl ActorInterface for RCollector {
|
||||
type Incoming = RWork;
|
||||
type Response = ();
|
||||
fn handle(&mut self, ctx: &Ctx, _msg: RWork) {
|
||||
self.0.lock().unwrap().push(ctx.self_addr());
|
||||
}
|
||||
}
|
||||
let c = rcollected.clone();
|
||||
let router = Router::<RWork>::new(
|
||||
RoutingStrategy::Random, 3,
|
||||
move |ctx| ctx.spawn(RCollector(c.clone())), 10,
|
||||
);
|
||||
let router_addr = rt.spawn(router).unwrap();
|
||||
rt.tick();
|
||||
for _ in 0..30 {
|
||||
rt.send_to(router_addr, RWork).unwrap();
|
||||
}
|
||||
tick_n(&rt, 3);
|
||||
let data = rcollected.lock().unwrap();
|
||||
let unique: std::collections::HashSet<_> = data.iter().collect();
|
||||
assert!(unique.len() >= 2, "random uses at least 2 workers");
|
||||
|
||||
// Dead worker replaced
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let spawn_count = Arc::new(AtomicUsize::new(0));
|
||||
struct PanicOnFirst { first: bool }
|
||||
#[derive(Clone)]
|
||||
struct DWork;
|
||||
impl ActorInterface for PanicOnFirst {
|
||||
type Incoming = DWork;
|
||||
type Response = ();
|
||||
fn handle(&mut self, _ctx: &Ctx, _msg: DWork) {
|
||||
if self.first { self.first = false; panic!("first message panic"); }
|
||||
}
|
||||
}
|
||||
let sc = spawn_count.clone();
|
||||
let router = Router::<DWork>::new(
|
||||
RoutingStrategy::RoundRobin, 3,
|
||||
move |ctx| { sc.fetch_add(1, Ordering::Relaxed); ctx.spawn(PanicOnFirst { first: sc.load(Ordering::Relaxed) == 1 }) },
|
||||
10,
|
||||
);
|
||||
let router_addr = rt.spawn(router).unwrap();
|
||||
rt.tick();
|
||||
rt.send_to(router_addr, DWork).unwrap();
|
||||
tick_n(&rt, 5);
|
||||
assert!(spawn_count.load(Ordering::Relaxed) >= 4, "replacement spawned");
|
||||
|
||||
// Meltdown: max_restarts=2
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
struct AlwaysPanics;
|
||||
#[derive(Clone)]
|
||||
struct MWork;
|
||||
impl ActorInterface for AlwaysPanics {
|
||||
type Incoming = MWork;
|
||||
type Response = ();
|
||||
fn handle(&mut self, _ctx: &Ctx, _msg: MWork) { panic!("always"); }
|
||||
}
|
||||
let router = Router::<MWork>::new(
|
||||
RoutingStrategy::RoundRobin, 1,
|
||||
|ctx| ctx.spawn(AlwaysPanics), 2,
|
||||
);
|
||||
let router_addr = rt.spawn(router).unwrap();
|
||||
rt.tick();
|
||||
for _ in 0..3 {
|
||||
rt.send_to(router_addr, MWork).unwrap();
|
||||
tick_n(&rt, 5);
|
||||
}
|
||||
tick_n(&rt, 5);
|
||||
assert_eq!(rt.stats().workers[0].num_actors, 0, "router melted down");
|
||||
|
||||
// Stop router kills workers
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
struct Dummy;
|
||||
#[derive(Clone)]
|
||||
struct SWork;
|
||||
impl ActorInterface for Dummy {
|
||||
type Incoming = SWork;
|
||||
type Response = ();
|
||||
fn handle(&mut self, _ctx: &Ctx, _msg: SWork) {}
|
||||
}
|
||||
let router = Router::<SWork>::new(
|
||||
RoutingStrategy::RoundRobin, 3,
|
||||
|ctx| ctx.spawn(Dummy), 10,
|
||||
);
|
||||
let router_addr = rt.spawn(router).unwrap();
|
||||
rt.tick();
|
||||
assert_eq!(rt.stats().workers[0].num_actors, 4);
|
||||
rt.stop_actor(router_addr).unwrap();
|
||||
tick_n(&rt, 5);
|
||||
assert_eq!(rt.stats().workers[0].num_actors, 0, "stop router kills workers");
|
||||
}
|
||||
|
|
@ -1,335 +0,0 @@
|
|||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use swactor::actor::{ActorAddress, ActorExited, ActorInterface, ExitReason};
|
||||
use swactor::runtime::{Ctx, Runtime, RuntimeConfig};
|
||||
use swactor_std::{CtxWatching, RuntimeWatching, StdExtension};
|
||||
|
||||
// ── Actors ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// An actor that panics when it receives PanicMsg.
|
||||
struct PanicOnCommand;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct PanicMsg;
|
||||
|
||||
impl ActorInterface for PanicOnCommand {
|
||||
type Incoming = PanicMsg;
|
||||
type Response = ();
|
||||
fn handle(&mut self, _ctx: &Ctx, _msg: PanicMsg) {
|
||||
panic!("deliberate panic for test");
|
||||
}
|
||||
}
|
||||
|
||||
/// An actor that watches targets and counts exit notifications.
|
||||
struct ExitWatcher {
|
||||
exit_count: Arc<AtomicUsize>,
|
||||
last_reason: Arc<std::sync::Mutex<Option<ExitReason>>>,
|
||||
last_addr: Arc<std::sync::Mutex<Option<ActorAddress>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum WatcherCmd {
|
||||
WatchThis(ActorAddress),
|
||||
UnwatchThis(ActorAddress),
|
||||
}
|
||||
|
||||
impl ActorInterface for ExitWatcher {
|
||||
type Incoming = WatcherCmd;
|
||||
type Response = ();
|
||||
fn handle(&mut self, ctx: &Ctx, msg: WatcherCmd) {
|
||||
match msg {
|
||||
WatcherCmd::WatchThis(target) => {
|
||||
ctx.watch(target);
|
||||
}
|
||||
WatcherCmd::UnwatchThis(target) => {
|
||||
ctx.unwatch(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_actor_exit(&mut self, _ctx: &Ctx, exited: ActorExited) {
|
||||
self.exit_count.fetch_add(1, Ordering::SeqCst);
|
||||
*self.last_reason.lock().unwrap() = Some(exited.reason);
|
||||
*self.last_addr.lock().unwrap() = Some(exited.addr);
|
||||
}
|
||||
}
|
||||
|
||||
impl ExitWatcher {
|
||||
fn new() -> (Self, WatcherState) {
|
||||
let exit_count = Arc::new(AtomicUsize::new(0));
|
||||
let last_reason = Arc::new(std::sync::Mutex::new(None));
|
||||
let last_addr = Arc::new(std::sync::Mutex::new(None));
|
||||
let state = WatcherState {
|
||||
exit_count: exit_count.clone(),
|
||||
last_reason: last_reason.clone(),
|
||||
last_addr: last_addr.clone(),
|
||||
};
|
||||
(
|
||||
ExitWatcher {
|
||||
exit_count,
|
||||
last_reason,
|
||||
last_addr,
|
||||
},
|
||||
state,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared state for inspecting what ExitWatcher observed.
|
||||
struct WatcherState {
|
||||
exit_count: Arc<AtomicUsize>,
|
||||
last_reason: Arc<std::sync::Mutex<Option<ExitReason>>>,
|
||||
last_addr: Arc<std::sync::Mutex<Option<ActorAddress>>>,
|
||||
}
|
||||
|
||||
impl WatcherState {
|
||||
fn count(&self) -> usize {
|
||||
self.exit_count.load(Ordering::SeqCst)
|
||||
}
|
||||
fn last_reason(&self) -> Option<ExitReason> {
|
||||
self.last_reason.lock().unwrap().clone()
|
||||
}
|
||||
fn last_addr(&self) -> Option<ActorAddress> {
|
||||
*self.last_addr.lock().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
/// A silent actor that does nothing (for targets that shouldn't panic).
|
||||
struct Sleeper;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Noop;
|
||||
|
||||
impl ActorInterface for Sleeper {
|
||||
type Incoming = Noop;
|
||||
type Response = ();
|
||||
fn handle(&mut self, _ctx: &Ctx, _msg: Noop) {}
|
||||
}
|
||||
|
||||
// ── Helper ──────────────────────────────────────────────────────────────────
|
||||
|
||||
fn tick_n(rt: &Runtime, n: usize) {
|
||||
for _ in 0..n {
|
||||
rt.tick();
|
||||
}
|
||||
}
|
||||
|
||||
fn watch_config() -> RuntimeConfig {
|
||||
RuntimeConfig {
|
||||
num_threads: 1,
|
||||
..RuntimeConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn watch_runtime() -> Runtime {
|
||||
Runtime::new(watch_config())
|
||||
.with_extension(Arc::new(StdExtension::new()))
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Given a watcher and a target actor,
|
||||
/// when the target panics,
|
||||
/// then the watcher's on_actor_exit fires with ExitReason::Panicked.
|
||||
#[test]
|
||||
fn watch_receives_notification_on_panic() {
|
||||
let rt = watch_runtime();
|
||||
let (watcher_actor, state) = ExitWatcher::new();
|
||||
|
||||
let target = rt.spawn(PanicOnCommand).unwrap();
|
||||
let watcher = rt.spawn(watcher_actor).unwrap();
|
||||
|
||||
// Tell watcher to watch the target
|
||||
rt.send_to(watcher, WatcherCmd::WatchThis(target)).unwrap();
|
||||
tick_n(&rt, 3);
|
||||
|
||||
// Kill the target
|
||||
rt.send_to(target, PanicMsg).unwrap();
|
||||
tick_n(&rt, 5);
|
||||
|
||||
assert_eq!(state.count(), 1, "watcher should have received exactly one ActorExited");
|
||||
assert_eq!(state.last_reason(), Some(ExitReason::Panicked));
|
||||
assert_eq!(state.last_addr(), Some(target));
|
||||
}
|
||||
|
||||
/// Given a watcher that watches then unwatches a target,
|
||||
/// when the target panics,
|
||||
/// then the watcher receives NO notification.
|
||||
#[test]
|
||||
fn unwatch_prevents_notification() {
|
||||
let rt = watch_runtime();
|
||||
let (watcher_actor, state) = ExitWatcher::new();
|
||||
|
||||
let target = rt.spawn(PanicOnCommand).unwrap();
|
||||
let watcher = rt.spawn(watcher_actor).unwrap();
|
||||
|
||||
// Watch
|
||||
rt.send_to(watcher, WatcherCmd::WatchThis(target)).unwrap();
|
||||
tick_n(&rt, 3);
|
||||
|
||||
// Unwatch
|
||||
rt.send_to(watcher, WatcherCmd::UnwatchThis(target)).unwrap();
|
||||
tick_n(&rt, 3);
|
||||
|
||||
// Kill target
|
||||
rt.send_to(target, PanicMsg).unwrap();
|
||||
tick_n(&rt, 5);
|
||||
|
||||
assert_eq!(state.count(), 0, "after unwatch, no notification should be delivered");
|
||||
}
|
||||
|
||||
/// Given a watcher that dies before the target,
|
||||
/// when the target subsequently panics,
|
||||
/// then there is no panic or leak.
|
||||
#[test]
|
||||
fn watcher_dies_before_target_no_panic() {
|
||||
let rt = watch_runtime();
|
||||
|
||||
let target = rt.spawn(PanicOnCommand).unwrap();
|
||||
let target2 = rt.spawn(PanicOnCommand).unwrap();
|
||||
|
||||
// Watch via runtime-level API
|
||||
rt.watch(target2, target);
|
||||
tick_n(&rt, 3);
|
||||
|
||||
// Kill watcher (target2) first
|
||||
rt.send_to(target2, PanicMsg).unwrap();
|
||||
tick_n(&rt, 5);
|
||||
|
||||
// Kill target — should not crash
|
||||
rt.send_to(target, PanicMsg).unwrap();
|
||||
tick_n(&rt, 5);
|
||||
|
||||
// If we got here, no crash.
|
||||
}
|
||||
|
||||
/// Given a watcher that calls watch() twice on the same target,
|
||||
/// when the target panics,
|
||||
/// then the watcher receives exactly one notification.
|
||||
#[test]
|
||||
fn idempotent_watch_delivers_one_notification() {
|
||||
let rt = watch_runtime();
|
||||
let (watcher_actor, state) = ExitWatcher::new();
|
||||
|
||||
let target = rt.spawn(PanicOnCommand).unwrap();
|
||||
let watcher = rt.spawn(watcher_actor).unwrap();
|
||||
|
||||
// Watch twice
|
||||
rt.send_to(watcher, WatcherCmd::WatchThis(target)).unwrap();
|
||||
tick_n(&rt, 3);
|
||||
rt.send_to(watcher, WatcherCmd::WatchThis(target)).unwrap();
|
||||
tick_n(&rt, 3);
|
||||
|
||||
// Kill target
|
||||
rt.send_to(target, PanicMsg).unwrap();
|
||||
tick_n(&rt, 5);
|
||||
|
||||
assert_eq!(state.count(), 1, "double watch should produce exactly one notification");
|
||||
}
|
||||
|
||||
/// Given multiple watchers on the same target,
|
||||
/// when the target panics,
|
||||
/// then all watchers receive the notification.
|
||||
#[test]
|
||||
fn multiple_watchers_all_notified() {
|
||||
let rt = watch_runtime();
|
||||
let (w1_actor, s1) = ExitWatcher::new();
|
||||
let (w2_actor, s2) = ExitWatcher::new();
|
||||
let (w3_actor, s3) = ExitWatcher::new();
|
||||
|
||||
let target = rt.spawn(PanicOnCommand).unwrap();
|
||||
let w1 = rt.spawn(w1_actor).unwrap();
|
||||
let w2 = rt.spawn(w2_actor).unwrap();
|
||||
let w3 = rt.spawn(w3_actor).unwrap();
|
||||
|
||||
rt.send_to(w1, WatcherCmd::WatchThis(target)).unwrap();
|
||||
rt.send_to(w2, WatcherCmd::WatchThis(target)).unwrap();
|
||||
rt.send_to(w3, WatcherCmd::WatchThis(target)).unwrap();
|
||||
tick_n(&rt, 3);
|
||||
|
||||
rt.send_to(target, PanicMsg).unwrap();
|
||||
tick_n(&rt, 5);
|
||||
|
||||
assert_eq!(s1.count(), 1, "watcher 1 should be notified");
|
||||
assert_eq!(s2.count(), 1, "watcher 2 should be notified");
|
||||
assert_eq!(s3.count(), 1, "watcher 3 should be notified");
|
||||
}
|
||||
|
||||
/// Self-watch doesn't crash the runtime.
|
||||
#[test]
|
||||
fn self_watch_does_not_crash() {
|
||||
let rt = watch_runtime();
|
||||
let (watcher_actor, _state) = ExitWatcher::new();
|
||||
|
||||
let actor = rt.spawn(watcher_actor).unwrap();
|
||||
rt.send_to(actor, WatcherCmd::WatchThis(actor)).unwrap();
|
||||
tick_n(&rt, 5);
|
||||
|
||||
// No crash = pass
|
||||
}
|
||||
|
||||
/// Runtime-level watch (outside actor context) delivers notification.
|
||||
#[test]
|
||||
fn runtime_level_watch_delivers_notification() {
|
||||
let rt = watch_runtime();
|
||||
let (watcher_actor, state) = ExitWatcher::new();
|
||||
|
||||
let target = rt.spawn(PanicOnCommand).unwrap();
|
||||
let watcher = rt.spawn(watcher_actor).unwrap();
|
||||
tick_n(&rt, 2); // ensure both spawned
|
||||
|
||||
rt.watch(watcher, target);
|
||||
|
||||
rt.send_to(target, PanicMsg).unwrap();
|
||||
tick_n(&rt, 5);
|
||||
|
||||
assert_eq!(state.count(), 1, "runtime-level watch should deliver notification");
|
||||
assert_eq!(state.last_reason(), Some(ExitReason::Panicked));
|
||||
}
|
||||
|
||||
/// Given a watcher watching target via on_actor_exit,
|
||||
/// when target panics,
|
||||
/// then the watcher can react by spawning a replacement (supervision pattern).
|
||||
#[test]
|
||||
fn watcher_can_react_to_death_by_spawning() {
|
||||
let rt = watch_runtime();
|
||||
let spawned = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
struct Supervisor {
|
||||
spawned_count: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum SupervisorMsg {
|
||||
WatchThis(ActorAddress),
|
||||
}
|
||||
|
||||
impl ActorInterface for Supervisor {
|
||||
type Incoming = SupervisorMsg;
|
||||
type Response = ();
|
||||
fn handle(&mut self, ctx: &Ctx, msg: SupervisorMsg) {
|
||||
match msg {
|
||||
SupervisorMsg::WatchThis(target) => ctx.watch(target),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_actor_exit(&mut self, ctx: &Ctx, _exited: ActorExited) {
|
||||
// React: spawn a replacement
|
||||
let replacement = ctx.spawn(Sleeper).unwrap();
|
||||
let _ = replacement;
|
||||
self.spawned_count.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
let target = rt.spawn(PanicOnCommand).unwrap();
|
||||
let sup = rt.spawn(Supervisor { spawned_count: spawned.clone() }).unwrap();
|
||||
|
||||
rt.send_to(sup, SupervisorMsg::WatchThis(target)).unwrap();
|
||||
tick_n(&rt, 3);
|
||||
|
||||
rt.send_to(target, PanicMsg).unwrap();
|
||||
tick_n(&rt, 5);
|
||||
|
||||
assert_eq!(spawned.load(Ordering::SeqCst), 1, "supervisor should have spawned a replacement");
|
||||
}
|
||||
Loading…
Reference in a new issue