diff --git a/tests/actor_lifecycle.rs b/tests/actor_lifecycle.rs new file mode 100644 index 0000000..0724466 --- /dev/null +++ b/tests/actor_lifecycle.rs @@ -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, + stopped: Arc, + handled: Arc, +} + +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, +} + +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, +} + +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, +} + +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); + +#[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, + last_reason: Arc>>, + last_addr: Arc>>, +} + +#[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, + last_reason: Arc>>, +} + +impl WatcherState { + fn count(&self) -> usize { + self.exit_count.load(Ordering::SeqCst) + } + fn last_reason(&self) -> Option { + 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, +} + +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, +} + +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::().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::().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::().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::().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::().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::().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::().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::().unwrap(); + let count_inbox = rt.new_inbox::().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::().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::().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::().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, + } + + #[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::().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::().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::().unwrap(); + let inbox2 = rt.new_inbox::().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::().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::().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, + 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::().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::().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); +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 595818d..e794262 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -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( 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( rt: &Runtime, diff --git a/tests/message_delivery.rs b/tests/message_delivery.rs new file mode 100644 index 0000000..2087200 --- /dev/null +++ b/tests/message_delivery.rs @@ -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::().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::().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::().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 = 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::().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::().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::().unwrap(); + let inbox_b = rt.new_inbox::().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::().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::().unwrap(); + let pong_inbox = rt.new_inbox::().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::().unwrap(); + let hot = rt.spawn(CountingPingActor { counter: hot_counter.clone() }).unwrap(); + let cold = rt.spawn(PingPongActor).unwrap(); + let dummy = rt.new_inbox::().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::().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::().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::().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::().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::().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::().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::().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::().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::().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::().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::().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"); +} diff --git a/tests/runtime_hasher.rs b/tests/runtime_hasher.rs deleted file mode 100644 index 8cccf74..0000000 --- a/tests/runtime_hasher.rs +++ /dev/null @@ -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::().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 = 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::().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); - - 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()); -} diff --git a/tests/runtime_hooks.rs b/tests/runtime_hooks.rs deleted file mode 100644 index db4128d..0000000 --- a/tests/runtime_hooks.rs +++ /dev/null @@ -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, - stopped: Arc, - handled: Arc, -} - -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, -} - -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, -} - -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::().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::().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::().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::().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::().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::().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::().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::().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::().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"); -} diff --git a/tests/runtime_lifecycle.rs b/tests/runtime_lifecycle.rs deleted file mode 100644 index a49efb2..0000000 --- a/tests/runtime_lifecycle.rs +++ /dev/null @@ -1,1496 +0,0 @@ -mod common; -use common::*; - -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::Arc; - -// ── Additional actors for edge-case tests ──────────────────────────────── - -/// Sends a countdown message to itself, then replies Done(0) when remaining hits zero. -/// Tests pending_local self-delivery path. -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 }, - ); - } - } -} - -/// Spawns a DoubleActor child, sends it work, then panics. -/// The child should still process the forwarded message. -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"); - } -} - -/// Processes `remaining_good` messages, then panics on the next one. -/// Uses a shared counter so the test can observe how many were processed. -struct PanicAfterNActor { - remaining_good: usize, - counter: Arc, -} - -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); - } -} - -/// Sends a reply, then panics. Tests that messages sent before the panic -/// are still delivered (they're already in the queue). -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"); - } -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Actor Lifecycle -// ═══════════════════════════════════════════════════════════════════════════ - -#[test] -fn actor_receives_message_and_replies() { - // Given a spawned PingPongActor - let rt = std_runtime(RuntimeConfig::default()); - let addr = rt.spawn(PingPongActor).unwrap(); - let inbox = rt.new_inbox::().unwrap(); - - // When I send it a Ping - rt.send_to(addr, Ping { reply_to: *inbox.addr() }).unwrap(); - - // Then my inbox receives a Pong - let reply = tick_until_recv(&rt, &inbox, 10); - assert!(reply.is_some(), "actor should have replied with Pong"); -} - -#[test] -fn actor_maintains_state_across_messages() { - // Given a CounterActor starting at 0 - let rt = std_runtime(RuntimeConfig::default()); - let addr = rt.spawn(CounterActor { count: 0 }).unwrap(); - let inbox = rt.new_inbox::().unwrap(); - - // When I send 3 Increments - for _ in 0..3 { - rt.send_to(addr, Increment { reply_to: *inbox.addr() }).unwrap(); - } - - // Then replies are Count(1), Count(2), Count(3) — state accumulated - let replies = tick_and_drain(&rt, &inbox, 10); - assert_eq!(replies, vec![Count(1), Count(2), Count(3)]); -} - -#[test] -fn actor_spawns_child_and_child_replies() { - // Given a DelegatorActor - let rt = std_runtime(RuntimeConfig::default()); - let addr = rt.spawn(DelegatorActor).unwrap(); - let inbox = rt.new_inbox::().unwrap(); - - // When I ask it to process value 7 - rt.send_to(addr, Forward { value: 7, reply_to: *inbox.addr() }).unwrap(); - - // Then the child doubled it — inbox gets Done(14) - let reply = tick_until_recv(&rt, &inbox, 20); - assert_eq!(reply, Some(Done(14)), "child should have doubled the value"); -} - -#[test] -fn three_level_chain_reaches_leaf() { - // Given a ChainActor that will spawn 2 more levels - let rt = std_runtime(RuntimeConfig::default()); - let addr = rt.spawn(ChainActor).unwrap(); - let inbox = rt.new_inbox::().unwrap(); - - // When I send remaining=2 (root -> child -> grandchild) - rt.send_to(addr, ChainMsg { remaining: 2, depth: 0, reply_to: *inbox.addr() }).unwrap(); - - // Then the grandchild (depth 2) replies - let reply = tick_until_recv(&rt, &inbox, 30); - assert_eq!(reply, Some(Done(2)), "leaf at depth 2 should have replied"); -} - -#[test] -fn fan_out_distributes_work_to_children() { - // Given a FanOutActor told to spawn 5 children - let rt = std_runtime(RuntimeConfig::default()); - let addr = rt.spawn(FanOutActor).unwrap(); - let inbox = rt.new_inbox::().unwrap(); - - // When it spawns 5 children, each doubling their index - rt.send_to(addr, FanOut { count: 5, reply_to: *inbox.addr() }).unwrap(); - - // Then I receive 5 replies whose values are {2, 4, 6, 8, 10} - let mut replies = tick_and_drain(&rt, &inbox, 20); - let mut values: Vec = replies.drain(..).map(|d| d.0).collect(); - values.sort(); - assert_eq!(values, vec![2, 4, 6, 8, 10], "each child should have doubled its index"); -} - -#[test] -fn actor_knows_its_own_address() { - // Given a SelfAddrActor - let rt = std_runtime(RuntimeConfig::default()); - let addr = rt.spawn(SelfAddrActor).unwrap(); - let inbox = rt.new_inbox::().unwrap(); - - // When I ask it for its address - rt.send_to(addr, WhoAreYou { reply_to: *inbox.addr() }).unwrap(); - - // Then the address it reports matches the one from spawn - let reply = tick_until_recv(&rt, &inbox, 10); - assert_eq!(reply, Some(MyAddr(addr)), "actor should know its own address"); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Message Delivery -// ═══════════════════════════════════════════════════════════════════════════ - -#[test] -fn messages_arrive_in_fifo_order() { - // Given a CounterActor - let rt = std_runtime(RuntimeConfig::default()); - let addr = rt.spawn(CounterActor { count: 0 }).unwrap(); - let inbox = rt.new_inbox::().unwrap(); - - // When I send 5 Increments - for _ in 0..5 { - rt.send_to(addr, Increment { reply_to: *inbox.addr() }).unwrap(); - } - - // Then replies arrive Count(1)..Count(5) in order - let replies = tick_and_drain(&rt, &inbox, 10); - assert_eq!( - replies, - vec![Count(1), Count(2), Count(3), Count(4), Count(5)], - "messages must be processed in FIFO order" - ); -} - -#[test] -fn multiple_actors_have_independent_mailboxes() { - // Given 3 PingPongActors, each with its own inbox - let rt = std_runtime(RuntimeConfig::default()); - let mut addrs = Vec::new(); - let mut inboxes = Vec::new(); - for _ in 0..3 { - let addr = rt.spawn(PingPongActor).unwrap(); - let inbox = rt.new_inbox::().unwrap(); - rt.send_to(addr, Ping { reply_to: *inbox.addr() }).unwrap(); - addrs.push(addr); - inboxes.push(inbox); - } - - // When all messages are processed - for _ in 0..10 { - rt.tick(); - } - - // Then each inbox sees exactly one Pong — no cross-contamination - for (i, inbox) in inboxes.iter().enumerate() { - assert!(inbox.try_recv().is_some(), "actor {i} should have replied"); - assert!(inbox.try_recv().is_none(), "actor {i} should have only one reply"); - } -} - -#[test] -fn multiple_senders_reach_same_actor() { - // Given 1 CounterActor - let rt = std_runtime(RuntimeConfig::default()); - let addr = rt.spawn(CounterActor { count: 0 }).unwrap(); - let inbox_a = rt.new_inbox::().unwrap(); - let inbox_b = rt.new_inbox::().unwrap(); - - // When two different callers each send an Increment - rt.send_to(addr, Increment { reply_to: *inbox_a.addr() }).unwrap(); - rt.send_to(addr, Increment { reply_to: *inbox_b.addr() }).unwrap(); - - // Then both replies arrive and the counter incremented for each - for _ in 0..10 { - rt.tick(); - } - let a = inbox_a.try_recv(); - let b = inbox_b.try_recv(); - assert!(a.is_some(), "first sender should get a reply"); - assert!(b.is_some(), "second sender should get a reply"); - // Second caller sees Count(2), proving both messages were handled - assert_eq!(b, Some(Count(2))); -} - -#[test] -fn send_to_nonexistent_address_returns_error() { - // Given a runtime with no actors at a random address - let rt = std_runtime(RuntimeConfig::default()); - let bogus = ActorAddress::new_random(); - - // When I try to send to that address - let result = rt.send_to(bogus, Pong); - - // Then I get an error - assert!(result.is_err(), "sending to unknown address should fail"); -} - -#[test] -fn messages_sent_within_handler_are_delivered() { - // Given a DelegatorActor (spawns child + sends in same handler call) - let rt = std_runtime(RuntimeConfig::default()); - let addr = rt.spawn(DelegatorActor).unwrap(); - let inbox = rt.new_inbox::().unwrap(); - - // When I trigger the delegator - rt.send_to(addr, Forward { value: 5, reply_to: *inbox.addr() }).unwrap(); - - // Then the child receives the forwarded msg and replies to my inbox - let reply = tick_until_recv(&rt, &inbox, 20); - assert!(reply.is_some(), "child spawned during handler should receive its message"); - assert_eq!(reply.unwrap(), Done(10)); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Threading Model -// ═══════════════════════════════════════════════════════════════════════════ - -#[test] -fn tick_drives_single_threaded_processing() { - // Given a single-threaded runtime - let rt = std_runtime(RuntimeConfig::default()); - let addr = rt.spawn(PingPongActor).unwrap(); - let inbox = rt.new_inbox::().unwrap(); - - // When I send a message and tick manually - rt.send_to(addr, Ping { reply_to: *inbox.addr() }).unwrap(); - - // Before ticking: nothing received - assert!(inbox.try_recv().is_none(), "should not receive before tick"); - - // After ticking: reply available - rt.tick(); - rt.tick(); - assert!(inbox.try_recv().is_some(), "tick() should drive processing"); -} - -#[test] -fn run_processes_messages_in_background() { - // Given a multi-threaded runtime - let rt = std_runtime(RuntimeConfig { num_threads: 4, ..Default::default() }); - let addr = rt.spawn(PingPongActor).unwrap(); - let inbox = rt.new_inbox::().unwrap(); - rt.send_to(addr, Ping { reply_to: *inbox.addr() }).unwrap(); - - // When I call run() (spawns background worker threads) - let handle = rt.run().unwrap(); - - // Then the inbox receives a reply without manual ticking - let mut received = false; - for _ in 0..100 { - if inbox.try_recv().is_some() { - received = true; - break; - } - std::thread::sleep(std::time::Duration::from_millis(10)); - } - handle.shutdown(); - handle.join(); - assert!(received, "background workers should process the message"); -} - -#[test] -fn shutdown_stops_background_workers() { - // Given a running multi-threaded runtime - let rt = std_runtime(RuntimeConfig { num_threads: 2, ..Default::default() }); - let handle = rt.run().unwrap(); - - // When I call shutdown + join - handle.shutdown(); - handle.join(); - - // Then join returns (threads have stopped) — test passes by not hanging -} - -#[test] -fn cross_worker_delegation_delivers_reply() { - // Given a 2-thread runtime with a DelegatorActor - let rt = std_runtime(RuntimeConfig { num_threads: 2, ..Default::default() }); - let addr = rt.spawn(DelegatorActor).unwrap(); - let inbox = rt.new_inbox::().unwrap(); - rt.send_to(addr, Forward { value: 3, reply_to: *inbox.addr() }).unwrap(); - - // When processing runs across worker threads - let handle = rt.run().unwrap(); - - // Then the reply reaches the inbox despite potentially crossing workers - let mut reply = None; - for _ in 0..100 { - if let Some(msg) = inbox.try_recv() { - reply = Some(msg); - break; - } - std::thread::sleep(std::time::Duration::from_millis(10)); - } - handle.shutdown(); - handle.join(); - assert_eq!(reply, Some(Done(6)), "cross-worker delegation should deliver the reply"); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Backpressure & Scale -// ═══════════════════════════════════════════════════════════════════════════ - -#[test] -fn inbox_handles_burst_of_messages() { - // Given a CounterActor and a small runtime - let rt = std_runtime(RuntimeConfig::default()); - let addr = rt.spawn(CounterActor { count: 0 }).unwrap(); - let inbox = rt.new_inbox::().unwrap(); - - // When I send a burst of 20 messages - for _ in 0..20 { - rt.send_to(addr, Increment { reply_to: *inbox.addr() }).unwrap(); - } - - // Then all 20 are delivered in order - let replies = tick_and_drain(&rt, &inbox, 30); - assert_eq!(replies.len(), 20, "all 20 messages should be delivered"); - // Verify ordering: last reply should be Count(20) - assert_eq!(replies.last(), Some(&Count(20)), "messages should arrive in FIFO order"); -} - -#[test] -fn hundred_actors_all_receive_messages() { - // Given 100 PingPongActors - let rt = std_runtime(RuntimeConfig { - max_actors: 2000, - ..Default::default() - }); - let mut pairs = Vec::new(); - for _ in 0..100 { - let addr = rt.spawn(PingPongActor).unwrap(); - let inbox = rt.new_inbox::().unwrap(); - rt.send_to(addr, Ping { reply_to: *inbox.addr() }).unwrap(); - pairs.push(inbox); - } - - // When all messages are processed - for _ in 0..50 { - rt.tick(); - } - - // Then all 100 inboxes have a Pong - let received = pairs.iter().filter(|inbox| inbox.try_recv().is_some()).count(); - assert_eq!(received, 100, "all 100 actors should have replied"); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Panic Safety -// ═══════════════════════════════════════════════════════════════════════════ - -#[test] -fn panic_in_handler_does_not_kill_other_actors() { - // Given a PanicActor and a PingPongActor on the same runtime - let rt = std_runtime(RuntimeConfig::default()); - let panic_addr = rt.spawn(PanicActor).unwrap(); - let good_addr = rt.spawn(PingPongActor).unwrap(); - let inbox = rt.new_inbox::().unwrap(); - - // When the PanicActor panics (stderr output expected) - rt.send_to(panic_addr, PanicMsg).unwrap(); - for _ in 0..5 { - rt.tick(); - } - - // Then PingPongActor still works normally - rt.send_to(good_addr, Ping { reply_to: *inbox.addr() }).unwrap(); - let reply = tick_until_recv(&rt, &inbox, 10); - assert!(reply.is_some(), "healthy actor should still work after peer panics"); -} - -#[test] -fn panic_does_not_corrupt_subsequent_messages() { - // Given a PanicActor and a CounterActor - let rt = std_runtime(RuntimeConfig::default()); - let panic_addr = rt.spawn(PanicActor).unwrap(); - let counter_addr = rt.spawn(CounterActor { count: 0 }).unwrap(); - let inbox = rt.new_inbox::().unwrap(); - - // When the PanicActor panics, then the CounterActor handles messages - rt.send_to(panic_addr, PanicMsg).unwrap(); - rt.send_to(counter_addr, Increment { reply_to: *inbox.addr() }).unwrap(); - rt.send_to(panic_addr, PanicMsg).unwrap(); // panic again - rt.send_to(counter_addr, Increment { reply_to: *inbox.addr() }).unwrap(); - - // Then the CounterActor is unaffected — state accumulates correctly - let replies = tick_and_drain(&rt, &inbox, 20); - assert_eq!(replies, vec![Count(1), Count(2)], "counter should be unaffected by peer panics"); -} - -#[test] -fn panicked_actor_is_poisoned_and_discards_future_messages() { - let rt = std_runtime(RuntimeConfig::default()); - let panic_addr = rt.spawn(PanicActor).unwrap(); - let good_addr = rt.spawn(CounterActor { count: 0 }).unwrap(); - let inbox = rt.new_inbox::().unwrap(); - - // Send a panic message, then more panic messages — they should be discarded - rt.send_to(panic_addr, PanicMsg).unwrap(); - rt.send_to(panic_addr, PanicMsg).unwrap(); - rt.send_to(panic_addr, PanicMsg).unwrap(); - - // Also send to a healthy actor to prove the system still works - rt.send_to(good_addr, Increment { reply_to: *inbox.addr() }).unwrap(); - - // When messages are processed - for _ in 0..20 { - rt.tick(); - } - - // Then: healthy actor still works, and only 1 panic recorded (not 3) - let reply = inbox.try_recv(); - assert!(reply.is_some(), "healthy actor should still reply after peer is poisoned"); - - let s = rt.stats(); - let total_panics: u64 = s.workers.iter().map(|w| w.panics).sum(); - assert_eq!(total_panics, 1, "only the first panic should be recorded; rest are discarded"); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Observability -// ═══════════════════════════════════════════════════════════════════════════ - -#[test] -fn stats_report_spawned_actors() { - // Given 3 spawned actors - let rt = std_runtime(RuntimeConfig::default()); - for _ in 0..3 { - rt.spawn(PingPongActor).unwrap(); - } - rt.tick(); - - // When I check stats - let s = rt.stats(); - - // Then the system accounts for every spawned actor - assert!( - s.actors.len() >= 3, - "stats should report at least 3 actors, got {}", - s.actors.len() - ); -} - -#[test] -fn stats_report_message_throughput() { - // Given 3 actors that each process 10 messages - let rt = std_runtime(RuntimeConfig::default()); - let counter = Arc::new(AtomicUsize::new(0)); - let inbox = rt.new_inbox::().unwrap(); - let inbox_addr = *inbox.addr(); - - let mut addrs = Vec::new(); - for _ in 0..3 { - addrs.push(rt.spawn(CountingPingActor { counter: counter.clone() }).unwrap()); - } - - for addr in &addrs { - for _ in 0..10 { - rt.send_to(*addr, Ping { reply_to: inbox_addr }).unwrap(); - } - } - - // When messages are processed - for _ in 0..50 { - rt.tick(); - } - - // Then stats reflect the throughput - let s = rt.stats(); - let total: u64 = s.workers.iter().map(|w| w.messages_processed).sum(); - assert!( - total >= 30, - "at least 30 messages should be processed, got {}", - total - ); -} - -#[test] -fn stats_record_panics() { - // Given a PanicActor that panics twice - let rt = std_runtime(RuntimeConfig::default()); - let addr = rt.spawn(PanicActor).unwrap(); - - rt.send_to(addr, PanicMsg).unwrap(); - rt.send_to(addr, PanicMsg).unwrap(); - - // When messages are processed (stderr output expected) - for _ in 0..10 { - rt.tick(); - } - - // Then stats record the panic (second message is discarded — actor is poisoned) - let s = rt.stats(); - let total_panics: u64 = s.workers.iter().map(|w| w.panics).sum(); - assert!( - total_panics >= 1, - "stats should record at least 1 panic, got {}", - total_panics - ); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Edge Cases & Adversarial Tests -// ═══════════════════════════════════════════════════════════════════════════ - -#[test] -fn wrong_type_to_actor_increments_type_mismatch_counter() { - // Given a PingPongActor that expects Ping - let rt = std_runtime(RuntimeConfig::default()); - let addr = rt.spawn(PingPongActor).unwrap(); - - // When I send it a Count message (wrong type) - rt.send_to(addr, Count(42)).unwrap(); - for _ in 0..10 { - rt.tick(); - } - - // Then stats record the type mismatch - let s = rt.stats(); - let mismatches: u64 = s.workers.iter().map(|w| w.type_mismatches).sum(); - assert_eq!(mismatches, 1, "sending wrong type should increment type_mismatches"); -} - -// FIXME dont count dropped messages -#[test] -fn type_mismatch_still_counted_as_processed() { - // Given a PingPongActor - let rt = std_runtime(RuntimeConfig::default()); - let addr = rt.spawn(PingPongActor).unwrap(); - - // When I send it 3 wrong-type messages - for _ in 0..3 { - rt.send_to(addr, Count(0)).unwrap(); - } - for _ in 0..10 { - rt.tick(); - } - - // Then all 3 are counted in both type_mismatches AND messages_processed - let s = rt.stats(); - let mismatches: u64 = s.workers.iter().map(|w| w.type_mismatches).sum(); - let processed: u64 = s.workers.iter().map(|w| w.messages_processed).sum(); - assert_eq!(mismatches, 3); - assert!( - processed >= 3, - "type-mismatched messages count as processed (dequeued+attempted), got {}", - processed - ); -} - -#[test] -fn self_send_chain_completes() { - // Given a SelfSendActor that will bounce a message to itself 10 times - let rt = std_runtime(RuntimeConfig::default()); - let addr = rt.spawn(SelfSendActor).unwrap(); - let inbox = rt.new_inbox::().unwrap(); - - // When triggered with remaining=10 - rt.send_to(addr, Countdown { remaining: 10, reply_to: *inbox.addr() }).unwrap(); - - // Then after enough ticks the chain completes. - let reply = tick_until_recv(&rt, &inbox, 50); - assert_eq!(reply, Some(Done(0)), "self-send chain should complete"); -} - -#[test] -fn panic_mid_batch_discards_remaining_messages() { - // Given an actor that processes 2 messages then panics on the 3rd - let counter = Arc::new(AtomicUsize::new(0)); - let rt = std_runtime(RuntimeConfig::default()); - let dummy = rt.new_inbox::().unwrap(); - let addr = rt.spawn(PanicAfterNActor { - remaining_good: 2, - counter: counter.clone(), - }).unwrap(); - - // When I queue 5 messages and tick (all arrive before first tick_all) - for _ in 0..5 { - rt.send_to(addr, Ping { reply_to: *dummy.addr() }).unwrap(); - } - for _ in 0..20 { - rt.tick(); - } - - // Then only 2 messages were processed — the 3rd panicked, 4th+5th discarded - assert_eq!( - counter.load(Ordering::SeqCst), - 2, - "only messages before the panic should be processed" - ); - let s = rt.stats(); - let panics: u64 = s.workers.iter().map(|w| w.panics).sum(); - assert_eq!(panics, 1, "exactly one panic should be recorded"); -} - -#[test] -fn spawn_then_panic_child_survives() { - // Given a SpawnThenPanicActor - let rt = std_runtime(RuntimeConfig::default()); - let addr = rt.spawn(SpawnThenPanicActor).unwrap(); - let inbox = rt.new_inbox::().unwrap(); - - // When the parent spawns a child, sends it work, then panics - rt.send_to(addr, Forward { value: 5, reply_to: *inbox.addr() }).unwrap(); - - // Then the child still processes the forwarded message and replies Done(10) - let reply = tick_until_recv(&rt, &inbox, 30); - assert_eq!( - reply, - Some(Done(10)), - "child spawned before parent panic should still work" - ); -} - -#[test] -fn panic_after_send_still_delivers_sent_messages() { - // Given a SendThenPanicActor (sends Pong, then panics) - let rt = std_runtime(RuntimeConfig::default()); - let addr = rt.spawn(SendThenPanicActor).unwrap(); - let inbox = rt.new_inbox::().unwrap(); - - // When it processes a Ping (sends reply, then panics) - rt.send_to(addr, Ping { reply_to: *inbox.addr() }).unwrap(); - - // Then the Pong reply still arrives — sends happen before the panic unwinds - let reply = tick_until_recv(&rt, &inbox, 20); - assert!( - reply.is_some(), - "message sent before panic should still be delivered" - ); -} - -// FIXME: document somewhere this behavior. -#[test] -fn send_to_poisoned_actor_is_a_silent_black_hole() { - // Given a poisoned actor (panicked on first message) - let rt = std_runtime(RuntimeConfig::default()); - let panic_addr = rt.spawn(PanicActor).unwrap(); - rt.send_to(panic_addr, PanicMsg).unwrap(); - for _ in 0..5 { - rt.tick(); - } - - // When I send more messages to it (after cleanup, address is removed) - let result = rt.send_to(panic_addr, PanicMsg); - - // Then send_to returns an error (actor has been cleaned up and removed) - assert!( - result.is_err(), - "send_to cleaned-up actor should return error" - ); - - // And the original panic was recorded - let s = rt.stats(); - let panics: u64 = s.workers.iter().map(|w| w.panics).sum(); - assert_eq!(panics, 1, "poisoned actor should have recorded one panic"); -} - -#[test] -fn tiny_buffer_delivers_all_messages_in_order() { - // Given a runtime with channel_buffer_size=1 - let rt = std_runtime(RuntimeConfig { - channel_buffer_size: 1, - ..Default::default() - }); - let addr = rt.spawn(CounterActor { count: 0 }).unwrap(); - let inbox = rt.new_inbox::().unwrap(); - - // When I send 50 messages - for _ in 0..50 { - rt.send_to(addr, Increment { reply_to: *inbox.addr() }).unwrap(); - } - - // Then all 50 arrive and in FIFO order - let replies = tick_and_drain(&rt, &inbox, 100); - assert_eq!(replies.len(), 50, "all messages should arrive despite tiny buffer"); - assert_eq!( - replies.last(), - Some(&Count(50)), - "messages should maintain FIFO order through overflow queue" - ); -} - -#[test] -fn empty_runtime_tick_and_stats_are_safe() { - // Given a runtime with no actors at all - let rt = std_runtime(RuntimeConfig::default()); - - // When I tick and check stats - for _ in 0..10 { - rt.tick(); - } - let s = rt.stats(); - - // Then everything reports zeros without panicking - assert_eq!(s.actors.len(), 0); - assert_eq!(s.num_workers, 1); - let total: u64 = s.workers.iter().map(|w| w.messages_processed).sum(); - assert_eq!(total, 0); -} - -#[test] -fn stats_stable_after_idle_ticks() { - // Given an actor that processes a message - let rt = std_runtime(RuntimeConfig::default()); - let addr = rt.spawn(CounterActor { count: 0 }).unwrap(); - let inbox = rt.new_inbox::().unwrap(); - rt.send_to(addr, Increment { reply_to: *inbox.addr() }).unwrap(); - for _ in 0..5 { - rt.tick(); - } - let _ = inbox.try_recv(); - let s1 = rt.stats(); - - // When I tick 100 more times with no messages - for _ in 0..100 { - rt.tick(); - } - let s2 = rt.stats(); - - // Then messages_processed doesn't grow during idle ticks - let total1: u64 = s1.workers.iter().map(|w| w.messages_processed).sum(); - let total2: u64 = s2.workers.iter().map(|w| w.messages_processed).sum(); - assert_eq!( - total1, total2, - "idle ticks must not inflate messages_processed" - ); -} - -#[test] -fn deep_spawn_chain_completes() { - // Given a 100-level chain - let rt = std_runtime(RuntimeConfig { - max_actors: 2000, - ..Default::default() - }); - let addr = rt.spawn(ChainActor).unwrap(); - let inbox = rt.new_inbox::().unwrap(); - - // When chain of depth 100 is triggered - rt.send_to( - addr, - ChainMsg { remaining: 100, depth: 0, reply_to: *inbox.addr() }, - ).unwrap(); - - // Then the leaf at depth 100 replies - let reply = tick_until_recv(&rt, &inbox, 500); - assert_eq!( - reply, - Some(Done(100)), - "100-level chain should complete" - ); -} - -#[test] -fn all_spawned_addresses_are_unique() { - let rt = std_runtime(RuntimeConfig { - max_actors: 10_000, - ..Default::default() - }); - let mut addrs: Vec = (0..1000) - .map(|_| rt.spawn(PingPongActor).unwrap()) - .collect(); - - addrs.sort_by_key(|a| a.0); - let before = addrs.len(); - addrs.dedup_by_key(|a| a.0); - assert_eq!(addrs.len(), before, "all 1000 addresses should be unique"); -} - -#[test] -fn inbox_empty_before_any_tick() { - // Given a sent message that hasn't been ticked - let rt = std_runtime(RuntimeConfig::default()); - let addr = rt.spawn(PingPongActor).unwrap(); - let inbox = rt.new_inbox::().unwrap(); - rt.send_to(addr, Ping { reply_to: *inbox.addr() }).unwrap(); - - // Then inbox is empty — no processing without tick - assert!(inbox.try_recv().is_none()); -} - -#[test] -fn interleaved_spawn_and_send_in_handler_all_complete() { - // Given a FanOutActor that spawns 20 children with interleaved spawn+send - let rt = std_runtime(RuntimeConfig::default()); - let addr = rt.spawn(FanOutActor).unwrap(); - let inbox = rt.new_inbox::().unwrap(); - - rt.send_to(addr, FanOut { count: 20, reply_to: *inbox.addr() }).unwrap(); - - let replies = tick_and_drain(&rt, &inbox, 50); - assert_eq!( - replies.len(), - 20, - "all 20 children spawned+messaged in same handler should reply" - ); -} - -#[test] -fn multiple_inbox_types_coexist() { - // Given two inboxes of different types on the same runtime - let rt = std_runtime(RuntimeConfig::default()); - let counter = rt.spawn(CounterActor { count: 0 }).unwrap(); - let pinger = rt.spawn(PingPongActor).unwrap(); - let count_inbox = rt.new_inbox::().unwrap(); - let pong_inbox = rt.new_inbox::().unwrap(); - - // When both actors reply to their respective inboxes - rt.send_to(counter, Increment { reply_to: *count_inbox.addr() }).unwrap(); - rt.send_to(pinger, Ping { reply_to: *pong_inbox.addr() }).unwrap(); - for _ in 0..10 { - rt.tick(); - } - - // Then each inbox gets its correct type — no cross-contamination - assert_eq!(count_inbox.try_recv(), Some(Count(1))); - assert_eq!(pong_inbox.try_recv(), Some(Pong)); -} - -#[test] -fn poisoned_actor_messages_not_counted_as_processed() { - // Given a poisoned actor that has been cleaned up - let rt = std_runtime(RuntimeConfig::default()); - let panic_addr = rt.spawn(PanicActor).unwrap(); - rt.send_to(panic_addr, PanicMsg).unwrap(); - for _ in 0..5 { - rt.tick(); - } - let s1 = rt.stats(); - let processed_before: u64 = s1.workers.iter().map(|w| w.messages_processed).sum(); - - // When I try to send 10 messages to the cleaned-up actor - let mut send_failures = 0; - for _ in 0..10 { - if rt.send_to(panic_addr, PanicMsg).is_err() { - send_failures += 1; - } - } - for _ in 0..20 { - rt.tick(); - } - let s2 = rt.stats(); - let processed_after: u64 = s2.workers.iter().map(|w| w.messages_processed).sum(); - - // Then sends fail (actor cleaned up) and processed count unchanged - assert_eq!(send_failures, 10, "all sends should fail to cleaned-up actor"); - assert_eq!( - processed_before, processed_after, - "no additional messages should be processed after cleanup" - ); -} - -#[test] -fn rapid_spawn_and_immediate_send() { - // Given a runtime, spawn an actor and immediately send before any tick - let rt = std_runtime(RuntimeConfig::default()); - let inbox = rt.new_inbox::().unwrap(); - - // When I spawn + send in rapid succession, 50 times - let mut addrs = Vec::new(); - for _ in 0..50 { - let addr = rt.spawn(PingPongActor).unwrap(); - rt.send_to(addr, Ping { reply_to: *inbox.addr() }).unwrap(); - addrs.push(addr); - } - - // Then all 50 replies eventually arrive - let replies = tick_and_drain(&rt, &inbox, 50); - assert_eq!(replies.len(), 50, "all spawn+send pairs should complete"); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Configuration -// ═══════════════════════════════════════════════════════════════════════════ - -#[test] -fn default_config_works_out_of_the_box() { - // Given the default config — no tuning needed - let rt = std_runtime(RuntimeConfig::default()); - let addr = rt.spawn(PingPongActor).unwrap(); - let inbox = rt.new_inbox::().unwrap(); - - // When I do the simplest possible thing - rt.send_to(addr, Ping { reply_to: *inbox.addr() }).unwrap(); - - // Then it just works - let reply = tick_until_recv(&rt, &inbox, 10); - assert!(reply.is_some(), "default config should work without tuning"); -} - -#[test] -fn custom_thread_count_respected() { - // Given a config requesting 4 threads - let rt = std_runtime(RuntimeConfig { num_threads: 4, ..Default::default() }); - // Spawn an actor so the runtime has something to report - rt.spawn(PingPongActor).unwrap(); - let handle = rt.run().unwrap(); - - // When I check stats - let s = handle.runtime.stats(); - - handle.shutdown(); - handle.join(); - - // Then the runtime created the requested number of workers - assert_eq!(s.num_workers, 4, "runtime should respect the requested thread count"); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Fairness (message budget) -// ═══════════════════════════════════════════════════════════════════════════ - -#[test] -fn hot_actor_does_not_starve_cold_actor() { - // Given: one "hot" actor with 1000 queued messages and one "cold" actor with 1 message - let rt = std_runtime(RuntimeConfig::default()); - let hot_counter = Arc::new(AtomicUsize::new(0)); - let cold_inbox = rt.new_inbox::().unwrap(); - - let hot_addr = rt.spawn(CountingPingActor { counter: hot_counter.clone() }).unwrap(); - let cold_addr = rt.spawn(PingPongActor).unwrap(); - - // Load the hot actor with 1000 messages - let dummy = rt.new_inbox::().unwrap(); - for _ in 0..1000 { - rt.send_to(hot_addr, Ping { reply_to: *dummy.addr() }).unwrap(); - } - // Send one message to the cold actor - rt.send_to(cold_addr, Ping { reply_to: *cold_inbox.addr() }).unwrap(); - - // When: we tick a limited number of times (default budget = 64 msgs/actor/tick) - rt.tick(); - - // Then: the cold actor replied even though the hot actor had 1000 queued messages - let cold_reply = cold_inbox.try_recv(); - assert!( - cold_reply.is_some(), - "cold actor must not be starved by hot actor; message budget should enforce fairness" - ); - // And the hot actor only processed its budget, not all 1000 - let hot_processed = hot_counter.load(Ordering::SeqCst); - assert!( - hot_processed <= 64, - "hot actor should process at most the budget (64) per tick, got {hot_processed}" - ); -} - -#[test] -fn unlimited_budget_drains_all_messages() { - // Given: a runtime with unlimited budget (0) - let rt = std_runtime(RuntimeConfig { - actor_message_budget: 0, - ..Default::default() - }); - let counter = Arc::new(AtomicUsize::new(0)); - let dummy = rt.new_inbox::().unwrap(); - let addr = rt.spawn(CountingPingActor { counter: counter.clone() }).unwrap(); - - // When: 500 messages are queued and we tick once - for _ in 0..500 { - rt.send_to(addr, Ping { reply_to: *dummy.addr() }).unwrap(); - } - rt.tick(); - rt.tick(); - - // Then: all 500 are processed in a single pass (no budget limit) - let processed = counter.load(Ordering::SeqCst); - assert_eq!(processed, 500, "unlimited budget should drain all messages"); -} - -#[test] -fn budget_messages_drain_across_multiple_ticks() { - // Given: an actor with more messages than the budget - let rt = std_runtime(RuntimeConfig::default()); // budget=64 - let counter = Arc::new(AtomicUsize::new(0)); - let dummy = rt.new_inbox::().unwrap(); - let addr = rt.spawn(CountingPingActor { counter: counter.clone() }).unwrap(); - - // When: 200 messages are queued - for _ in 0..200 { - rt.send_to(addr, Ping { reply_to: *dummy.addr() }).unwrap(); - } - - // Then: it takes multiple ticks to drain them all - for _ in 0..10 { - rt.tick(); - } - let processed = counter.load(Ordering::SeqCst); - assert_eq!(processed, 200, "all messages should eventually be processed across ticks"); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Stress Tests -// ═══════════════════════════════════════════════════════════════════════════ - -#[test] -fn message_ordering_preserved_under_budget() { - // Given: a CounterActor processing messages with a small budget - let rt = std_runtime(RuntimeConfig { - actor_message_budget: 8, - ..Default::default() - }); - let addr = rt.spawn(CounterActor { count: 0 }).unwrap(); - let inbox = rt.new_inbox::().unwrap(); - - // When: 100 messages are sent and processed across many ticks - for _ in 0..100 { - rt.send_to(addr, Increment { reply_to: *inbox.addr() }).unwrap(); - } - for _ in 0..50 { - rt.tick(); - } - - // Then: replies arrive in FIFO order - let replies: Vec<_> = std::iter::from_fn(|| inbox.try_recv()).collect(); - assert_eq!(replies.len(), 100, "all 100 messages should be delivered"); - for (i, reply) in replies.iter().enumerate() { - assert_eq!( - *reply, - Count(i + 1), - "message ordering must be preserved under budget; expected Count({}) at position {i}", - i + 1 - ); - } -} - -#[test] -fn mt_stress_many_senders_one_receiver() { - // Given: 4 threads, 50 senders each sending 100 messages to one receiver - let rt = std_runtime(RuntimeConfig { - num_threads: 4, - max_actors: 5_000, - channel_buffer_size: 10_000, - ..Default::default() - }); - let total_senders = 50; - let msgs_per_sender = 100; - let total_expected = total_senders * msgs_per_sender; - - let counter = Arc::new(AtomicUsize::new(0)); - let inbox = rt.new_inbox::().unwrap(); - let receiver = rt.spawn(CountingPingActor { counter: counter.clone() }).unwrap(); - - // Spawn senders and send messages - for _ in 0..total_senders { - for _ in 0..msgs_per_sender { - rt.send_to(receiver, Ping { reply_to: *inbox.addr() }).unwrap(); - } - } - - // When: runtime runs in background - let handle = rt.run().unwrap(); - - // Then: all messages are eventually processed - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - loop { - let processed = counter.load(Ordering::SeqCst); - if processed >= total_expected { - break; - } - if std::time::Instant::now() > deadline { - let processed = counter.load(Ordering::SeqCst); - handle.shutdown(); - handle.join(); - panic!( - "Timed out: only {processed}/{total_expected} messages processed in 5s" - ); - } - std::thread::sleep(std::time::Duration::from_millis(10)); - } - - handle.shutdown(); - handle.join(); - let final_count = counter.load(Ordering::SeqCst); - assert_eq!( - final_count, total_expected, - "all {total_expected} messages should be processed" - ); -} - -#[test] -fn mt_stress_concurrent_spawn_and_send() { - // Given: a multi-threaded runtime - let rt = std_runtime(RuntimeConfig { - num_threads: 4, - max_actors: 5_000, - channel_buffer_size: 10_000, - ..Default::default() - }); - let inbox = rt.new_inbox::().unwrap(); - let inbox_addr = *inbox.addr(); - - // Spawn 200 actors and immediately send them messages before any ticks - let mut addrs = Vec::new(); - for _ in 0..200 { - let addr = rt.spawn(PingPongActor).unwrap(); - rt.send_to(addr, Ping { reply_to: inbox_addr }).unwrap(); - addrs.push(addr); - } - - // When: runtime processes in background - let handle = rt.run().unwrap(); - - // Then: all 200 replies arrive - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - let mut received = 0; - while received < 200 { - if inbox.try_recv().is_some() { - received += 1; - } else if std::time::Instant::now() > deadline { - handle.shutdown(); - handle.join(); - panic!("Timed out: only {received}/200 replies received in 5s"); - } else { - std::thread::sleep(std::time::Duration::from_millis(1)); - } - } - - handle.shutdown(); - handle.join(); - assert_eq!(received, 200, "all 200 concurrent spawn+send pairs should complete"); -} - -#[test] -fn mt_chain_spawning_under_load() { - // Given: a multi-threaded runtime with a chain actor - 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::().unwrap(); - - // When: we trigger a 50-level chain - rt.send_to( - addr, - ChainMsg { remaining: 50, depth: 0, reply_to: *inbox.addr() }, - ) - .unwrap(); - let handle = rt.run().unwrap(); - - // Then: the chain completes despite actors being on different workers - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - let mut reply = None; - while reply.is_none() { - if let Some(msg) = inbox.try_recv() { - reply = Some(msg); - } else if std::time::Instant::now() > deadline { - handle.shutdown(); - handle.join(); - panic!("Timed out waiting for chain completion"); - } else { - std::thread::sleep(std::time::Duration::from_millis(1)); - } - } - - handle.shutdown(); - handle.join(); - assert_eq!( - reply, - Some(Done(50)), - "50-level chain should complete across multiple workers" - ); -} - -#[test] -fn mt_panic_isolation_under_load() { - // Given: a 4-thread runtime with panicking and healthy actors - 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::().unwrap(); - - // Spawn 10 panicking actors and 10 healthy counting actors - 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 send 100 messages to each healthy actor - 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(); - } - } - - // When: runtime runs - let handle = rt.run().unwrap(); - - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - let expected = 10 * 100; - loop { - let processed = counter.load(Ordering::SeqCst); - if processed >= expected { - break; - } - if std::time::Instant::now() > deadline { - let processed = counter.load(Ordering::SeqCst); - handle.shutdown(); - handle.join(); - panic!("Timed out: only {processed}/{expected} healthy messages processed"); - } - std::thread::sleep(std::time::Duration::from_millis(10)); - } - - handle.shutdown(); - handle.join(); - - // Then: all healthy actors processed all their messages despite panicking peers - let final_count = counter.load(Ordering::SeqCst); - assert_eq!( - final_count, expected, - "panicking actors should not affect healthy actors on other workers" - ); -} - -#[test] -fn sustained_throughput_does_not_drop_messages() { - // Given: a runtime processing messages in batches, simulating sustained load - let rt = std_runtime(RuntimeConfig::default()); - let counter = Arc::new(AtomicUsize::new(0)); - let dummy = rt.new_inbox::().unwrap(); - let addr = rt.spawn(CountingPingActor { counter: counter.clone() }).unwrap(); - - // When: we send 10 batches of 100 messages, ticking between batches - for batch in 0..10 { - for _ in 0..100 { - rt.send_to(addr, Ping { reply_to: *dummy.addr() }).unwrap(); - } - // Tick enough to process one budget worth per batch - for _ in 0..5 { - rt.tick(); - } - // Verify progress is being made (not stuck) - let processed = counter.load(Ordering::SeqCst); - assert!( - processed > batch * 50, - "batch {batch}: should have made progress, only {processed} processed" - ); - } - - // Drain remaining - for _ in 0..100 { - rt.tick(); - } - - // Then: all 1000 messages are eventually processed - let total = counter.load(Ordering::SeqCst); - assert_eq!(total, 1000, "sustained load should not drop any messages"); -} - -#[test] -fn mt_parked_worker_wakes_on_send() { - // Given: a 2-thread runtime that has been idle (workers are parked) - let rt = std_runtime(RuntimeConfig { - num_threads: 2, - ..Default::default() - }); - let addr = rt.spawn(PingPongActor).unwrap(); - let inbox = rt.new_inbox::().unwrap(); - - let handle = rt.run().unwrap(); - - // Let workers park (idle for a while) - std::thread::sleep(std::time::Duration::from_millis(50)); - - // When: we send a message to a parked worker - let before = std::time::Instant::now(); - handle.runtime.send_to(addr, Ping { reply_to: *inbox.addr() }).unwrap(); - - // Then: the worker wakes up and processes the message quickly - let mut received = false; - for _ in 0..1000 { - if inbox.try_recv().is_some() { - received = true; - break; - } - std::thread::sleep(std::time::Duration::from_millis(1)); - } - let latency = before.elapsed(); - - handle.shutdown(); - handle.join(); - - assert!(received, "parked worker should wake up and process the message"); - assert!( - latency.as_millis() < 100, - "wake-from-park latency should be low, was {:?}", - latency - ); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Competitor Bug-Inspired Tests -// ═══════════════════════════════════════════════════════════════════════════ - -#[test] -fn stats_snapshot_is_read_only() { - let rt = std_runtime(RuntimeConfig::default()); - let _addr = rt.spawn(PingPongActor).unwrap(); - rt.tick(); - - let s1 = rt.stats(); - let s2 = rt.stats(); - let s3 = rt.stats(); - - assert_eq!(s1.actors.len(), s2.actors.len(), "stats() should not mutate state"); - assert_eq!(s2.actors.len(), s3.actors.len(), "repeated stats() calls must be idempotent"); - assert!(s1.actors.len() >= 1, "should report at least 1 actor"); -} - -#[test] -fn stats_under_load_do_not_interfere_with_processing() { - let rt = std_runtime(RuntimeConfig::default()); - let counter = Arc::new(AtomicUsize::new(0)); - let dummy = rt.new_inbox::().unwrap(); - let addr = rt.spawn(CountingPingActor { counter: counter.clone() }).unwrap(); - - for _ in 0..100 { - rt.send_to(addr, Ping { reply_to: *dummy.addr() }).unwrap(); - } - - // Interleave stats calls with ticks - for _ in 0..20 { - rt.tick(); - let _s = rt.stats(); - } - - let processed = counter.load(Ordering::SeqCst); - assert_eq!(processed, 100, "stats() calls must not interfere with message processing"); -} - -#[test] -fn shutdown_wakes_parked_workers_immediately() { - let rt = std_runtime(RuntimeConfig { - num_threads: 4, - ..Default::default() - }); - let handle = rt.run().unwrap(); - - // Let workers park - std::thread::sleep(std::time::Duration::from_millis(50)); - - // Shutdown should wake all parked workers - let before = std::time::Instant::now(); - handle.shutdown(); - handle.join(); - let shutdown_time = before.elapsed(); - - assert!( - shutdown_time.as_millis() < 500, - "shutdown should complete quickly with parked workers, took {:?}", - shutdown_time - ); -} - -#[test] -fn mt_send_after_run_delivers_to_running_actors() { - let rt = std_runtime(RuntimeConfig { - num_threads: 2, - ..Default::default() - }); - let addr = rt.spawn(PingPongActor).unwrap(); - let inbox = rt.new_inbox::().unwrap(); - - // Start the runtime FIRST, then send - let handle = rt.run().unwrap(); - - // Give workers a moment to start - std::thread::sleep(std::time::Duration::from_millis(10)); - - // Send after run() - handle.runtime.send_to(addr, Ping { reply_to: *inbox.addr() }).unwrap(); - - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - let mut received = false; - while !received { - if inbox.try_recv().is_some() { - received = true; - } else if std::time::Instant::now() > deadline { - handle.shutdown(); - handle.join(); - panic!("Message sent after run() was not delivered"); - } else { - std::thread::sleep(std::time::Duration::from_millis(1)); - } - } - - handle.shutdown(); - handle.join(); - assert!(received, "messages sent after run() must be delivered"); -} - -#[test] -fn budget_respected_even_with_self_sends() { - let rt = std_runtime(RuntimeConfig { - actor_message_budget: 4, - ..Default::default() - }); - let addr = rt.spawn(SelfSendActor).unwrap(); - let inbox = rt.new_inbox::().unwrap(); - - // remaining=20 means 20 self-sends before replying Done(0) - rt.send_to(addr, Countdown { remaining: 20, reply_to: *inbox.addr() }).unwrap(); - - // With budget=4, each tick processes at most 4 messages per actor. - for _ in 0..30 { - rt.tick(); - } - - let reply = inbox.try_recv(); - assert_eq!( - reply, - Some(Done(0)), - "self-send chain should complete despite message budget" - ); -} diff --git a/tests/runtime_mechanics.rs b/tests/runtime_mechanics.rs deleted file mode 100644 index 786cd8d..0000000 --- a/tests/runtime_mechanics.rs +++ /dev/null @@ -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::>() - ); -} - -/// 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::().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::().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::().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::().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::().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)"); -} diff --git a/tests/runtime_registry.rs b/tests/runtime_registry.rs deleted file mode 100644 index de14df1..0000000 --- a/tests/runtime_registry.rs +++ /dev/null @@ -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::().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::().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::().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::().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::().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, -} - -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::().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::().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::().unwrap(); - let inbox2 = rt.new_inbox::().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, -} - -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::().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::().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::().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::().unwrap(); - let inbox2 = rt.new_inbox::().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::().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::().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::().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::(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::(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::(actor, |reply_to| Ping { reply_to }).unwrap(); - let addr = *ask.reply_addr(); - // The address should be valid (non-zero) - assert_ne!(addr, ActorAddress::default()); -} diff --git a/tests/runtime_stress.rs b/tests/runtime_stress.rs new file mode 100644 index 0000000..9060efe --- /dev/null +++ b/tests/runtime_stress.rs @@ -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( + inbox: &Inbox, + timeout: Duration, +) -> Option { + 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::().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::().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::().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::().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::().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::().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::().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::().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::().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::().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::>() + ); + + // ── 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); +} diff --git a/tests/runtime_supervision.rs b/tests/runtime_supervision.rs deleted file mode 100644 index 67d8a0b..0000000 --- a/tests/runtime_supervision.rs +++ /dev/null @@ -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, -} - -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, - 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::().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::().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>> = - Arc::new(std::sync::Mutex::new(None)); - - let rt = std_runtime(RuntimeConfig::default()); - let inbox = rt.new_inbox::().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::().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::().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::().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::().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::().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>>); - #[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::::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); - #[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::::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>>); - #[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::::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::::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::::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::::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); - #[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::::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); -} diff --git a/tests/runtime_timers.rs b/tests/runtime_timers.rs deleted file mode 100644 index 4434314..0000000 --- a/tests/runtime_timers.rs +++ /dev/null @@ -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::().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::().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::().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::().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::().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::().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"); -} diff --git a/tests/std_extension.rs b/tests/std_extension.rs new file mode 100644 index 0000000..cb8f254 --- /dev/null +++ b/tests/std_extension.rs @@ -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, +} + +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::().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::().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::().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::().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::().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::(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::(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::().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::().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::().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::().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::().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::().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>>); + #[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::::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); + #[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::::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>>); + #[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::::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::::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::::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::::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"); +} diff --git a/tests/watch_api.rs b/tests/watch_api.rs deleted file mode 100644 index 27b75c9..0000000 --- a/tests/watch_api.rs +++ /dev/null @@ -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, - last_reason: Arc>>, - last_addr: Arc>>, -} - -#[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, - last_reason: Arc>>, - last_addr: Arc>>, -} - -impl WatcherState { - fn count(&self) -> usize { - self.exit_count.load(Ordering::SeqCst) - } - fn last_reason(&self) -> Option { - self.last_reason.lock().unwrap().clone() - } - fn last_addr(&self) -> Option { - *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, - } - - #[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"); -}