diff --git a/benches/runtime_benchmarks.rs b/benches/runtime_benchmarks.rs index 57fa122..b308c04 100644 --- a/benches/runtime_benchmarks.rs +++ b/benches/runtime_benchmarks.rs @@ -722,6 +722,66 @@ fn registry_benchmarks(c: &mut Criterion) { group.finish(); } +// --------------------------------------------------------------------------- +// Allocation decomposition — where does send_to time go? +// --------------------------------------------------------------------------- + +fn allocation_benchmarks(c: &mut Criterion) { + let mut group = c.benchmark_group("allocation"); + + // D1 — Bare Box allocation + type erasure (no runtime, no channels) + for size in [0usize, 64, 256, 1024, 4096] { + let label = if size == 0 { "zero".to_string() } else { format!("{size}B") }; + group.bench_with_input( + BenchmarkId::new("box_alloc_erase", &label), + &size, + |b, &size| { + b.iter(|| { + let msg: Box = if size == 0 { + Box::new(NoopMessage) + } else { + Box::new(SizedMessage { _payload: vec![0u8; size] }) + }; + std::hint::black_box(msg); + }); + }, + ); + } + + // D2 — Full send_to for comparison (same sizes as D1) + for size in [0usize, 64, 256, 1024, 4096] { + let label = if size == 0 { "zero".to_string() } else { format!("{size}B") }; + group.bench_with_input( + BenchmarkId::new("full_send_to", &label), + &size, + |b, &size| { + b.iter_batched( + || { + let rt = Runtime::new(make_config(100, 100_000)); + let addr = if size == 0 { + rt.spawn(NoopActor).unwrap() + } else { + rt.spawn(SizedSinkActor).unwrap() + }; + rt.tick(); + (rt, addr, size) + }, + |(rt, addr, sz)| { + if sz == 0 { + rt.send_to(addr, NoopMessage).unwrap(); + } else { + rt.send_to(addr, SizedMessage { _payload: vec![0u8; sz] }).unwrap(); + } + }, + BatchSize::SmallInput, + ); + }, + ); + } + + group.finish(); +} + criterion_group!( benches, latency_benchmarks, @@ -731,5 +791,6 @@ criterion_group!( contention_benchmarks, placement_benchmarks, registry_benchmarks, + allocation_benchmarks, ); criterion_main!(benches); diff --git a/crates/runtime-dashboard/tests/dashboard_core.rs b/crates/runtime-dashboard/tests/dashboard_core.rs new file mode 100644 index 0000000..4e36344 --- /dev/null +++ b/crates/runtime-dashboard/tests/dashboard_core.rs @@ -0,0 +1,293 @@ +use std::sync::Arc; + +use runtime_dashboard::collector::StatsCollector; +use runtime_dashboard::layer::{DashboardEvent, EventStore}; +use runtime_dashboard::trace::RuntimeTrace; +use swactor::actor::ActorAddress; +use swactor::stats::{ActorSnapshot, StatsHook}; + +fn make_event(message: &str) -> DashboardEvent { + DashboardEvent { + seq: 0, // filled by EventStore::push + timestamp_ms: 1000, + level: "INFO".into(), + message: message.into(), + worker_id: None, + fields: serde_json::Map::new(), + } +} + +// ── EventStore: Streaming cursor semantics ────────────────────────────── + +/// Scenario: Two clients consume the same event stream at different rates. +/// A fast client reads every event; a slow client joins late and catches up. +/// Both eventually see the same final event. +#[test] +fn two_clients_consuming_at_different_rates() { + let store = EventStore::new(100, false, 0); + + // Fast client starts at cursor 0 + let mut fast_cursor: u64 = 0; + + // Push 5 events + for i in 0..5 { + store.push(make_event(&format!("event-{i}"))); + } + + // Fast client reads all 5 + let (batch, new_cursor) = store.read_from(fast_cursor); + assert_eq!(batch.len(), 5); + assert_eq!(batch[0].message, "event-0"); + assert_eq!(batch[4].message, "event-4"); + fast_cursor = new_cursor; + + // Push 3 more + for i in 5..8 { + store.push(make_event(&format!("event-{i}"))); + } + + // Fast client sees only new 3 + let (batch, new_cursor) = store.read_from(fast_cursor); + assert_eq!(batch.len(), 3); + assert_eq!(batch[0].message, "event-5"); + fast_cursor = new_cursor; + + // Slow client joins now at cursor 0 — sees all 8 + let (slow_batch, slow_cursor) = store.read_from(0); + assert_eq!(slow_batch.len(), 8); + assert_eq!(slow_batch[7].message, "event-7"); + + // Both cursors now agree + assert_eq!(fast_cursor, slow_cursor); +} + +/// Scenario: The event stream overflows the ring buffer. +/// A client that fell behind loses old events but gets the most recent window. +#[test] +fn ring_buffer_overflow_caps_old_cursors() { + let store = EventStore::new(10, false, 0); + + // Push 25 events into a 10-capacity ring + for i in 0..25 { + store.push(make_event(&format!("event-{i}"))); + } + + // A client at cursor 0 gets only the most recent 10 + let (batch, cursor) = store.read_from(0); + assert_eq!(batch.len(), 10); + assert_eq!(batch[0].message, "event-15"); + assert_eq!(batch[9].message, "event-24"); + assert_eq!(cursor, 25); + + // A client already caught up gets nothing + let (batch, _) = store.read_from(cursor); + assert!(batch.is_empty()); +} + +/// Scenario: Client has a cursor beyond the latest event (future cursor). +/// This can happen if events were trimmed. The client should get nothing, not panic. +#[test] +fn future_cursor_returns_empty() { + let store = EventStore::new(10, false, 0); + store.push(make_event("only-one")); + + let (batch, cursor) = store.read_from(999); + assert!(batch.is_empty()); + assert_eq!(cursor, 999); // cursor unchanged +} + +/// Scenario: Empty store — no events ever pushed. +#[test] +fn empty_store_returns_nothing() { + let store = EventStore::new(10, false, 0); + let (batch, cursor) = store.read_from(0); + assert!(batch.is_empty()); + assert_eq!(cursor, 0); +} + +// ── EventStore: Recording pipeline ────────────────────────────────────── + +/// Scenario: A monitoring session records events and saves a valid trace file. +/// Given: recording enabled, events flowing through the store +/// When: all_events() is called +/// Then: every event is available and the data round-trips through JSON +#[test] +fn recording_session_produces_replayable_trace() { + let store = EventStore::new(5, true, 100); + + // Simulate a burst of runtime events + for i in 0..20 { + let mut ev = make_event(&format!("tick-{i}")); + ev.worker_id = Some(i % 3); + store.push(ev); + } + + // Drain the recording log + let events = store.all_events().expect("recording should be enabled"); + assert_eq!(events.len(), 20, "all 20 events should be in the recording"); + + // Build a trace and round-trip through JSON + let trace = RuntimeTrace { + events, + stats_timeline: Vec::new(), + }; + let json = serde_json::to_string(&trace).unwrap(); + let restored: RuntimeTrace = serde_json::from_str(&json).unwrap(); + + assert_eq!(restored.events.len(), 20); + assert_eq!(restored.events[0].message, "tick-0"); + assert_eq!(restored.events[19].message, "tick-19"); + assert_eq!(restored.events[1].worker_id, Some(1)); +} + +/// Scenario: Recording disabled — all_events returns None. +#[test] +fn no_recording_means_no_full_log() { + let store = EventStore::new(10, false, 0); + store.push(make_event("hello")); + assert!(store.all_events().is_none()); +} + +/// Scenario: all_events() is destructive — second call gets an empty vec. +#[test] +fn recording_drain_is_destructive() { + let store = EventStore::new(5, true, 100); + store.push(make_event("one")); + store.push(make_event("two")); + + let first = store.all_events().unwrap(); + assert_eq!(first.len(), 2); + + let second = store.all_events().unwrap(); + assert!(second.is_empty(), "second drain should get nothing"); +} + +// ── StatsCollector: Multi-worker snapshot aggregation ─────────────────── + +/// Scenario: Three workers each report actor snapshots independently. +/// The dashboard merges all workers' data into a single view. +#[test] +fn three_workers_report_independently_merged_view_is_complete() { + let collector = StatsCollector::new(3); + + let addr_a = ActorAddress::new_random(); + let addr_b = ActorAddress::new_random(); + let addr_c = ActorAddress::new_random(); + + // Worker 0 reports 1 actor + collector.on_tick(0, &[ActorSnapshot { + address: addr_a, + mailbox_depth: 5, + last_msg_type: Some("Ping"), + messages_processed: 100, + poisoned: false, + }]); + + // Worker 1 reports 1 actor + collector.on_tick(1, &[ActorSnapshot { + address: addr_b, + mailbox_depth: 0, + last_msg_type: None, + messages_processed: 50, + poisoned: false, + }]); + + // Worker 2 reports 1 actor (poisoned) + collector.on_tick(2, &[ActorSnapshot { + address: addr_c, + mailbox_depth: 3, + last_msg_type: Some("BadMsg"), + messages_processed: 10, + poisoned: true, + }]); + + // Dashboard reads merged view + let details = collector.actor_details(); + assert_eq!(details.len(), 3, "all 3 actors from 3 workers"); + + let info_a = details.iter().find(|d| d.address == addr_a).unwrap(); + assert_eq!(info_a.worker_id, 0); + assert_eq!(info_a.mailbox_depth, 5); + assert_eq!(info_a.messages_processed, 100); + + let info_c = details.iter().find(|d| d.address == addr_c).unwrap(); + assert!(info_c.poisoned); + assert_eq!(info_c.worker_id, 2); +} + +/// Scenario: A worker updates its snapshots — old data is replaced, not accumulated. +#[test] +fn worker_update_replaces_stale_snapshot() { + let collector = StatsCollector::new(1); + let addr = ActorAddress::new_random(); + + // First tick: 1 actor with 10 messages + collector.on_tick(0, &[ActorSnapshot { + address: addr, + mailbox_depth: 5, + last_msg_type: None, + messages_processed: 10, + poisoned: false, + }]); + + assert_eq!(collector.actor_details().len(), 1); + assert_eq!(collector.actor_details()[0].messages_processed, 10); + + // Second tick: same actor now has 25 messages + collector.on_tick(0, &[ActorSnapshot { + address: addr, + mailbox_depth: 2, + last_msg_type: Some("Update"), + messages_processed: 25, + poisoned: false, + }]); + + let details = collector.actor_details(); + assert_eq!(details.len(), 1, "still 1 actor, not 2"); + assert_eq!(details[0].messages_processed, 25); + assert_eq!(details[0].mailbox_depth, 2); +} + +/// Scenario: A worker reports zero actors (all stopped). Dashboard reflects empty. +#[test] +fn worker_reports_empty_after_all_actors_stop() { + let collector = StatsCollector::new(2); + let addr = ActorAddress::new_random(); + + // Worker 0 has actors + collector.on_tick(0, &[ActorSnapshot { + address: addr, + mailbox_depth: 0, + last_msg_type: None, + messages_processed: 5, + poisoned: false, + }]); + assert_eq!(collector.actor_details().len(), 1); + + // Worker 0 reports empty (all actors stopped) + collector.on_tick(0, &[]); + assert!(collector.actor_details().is_empty()); +} + +// ── Integration: EventStore sequences are monotonically increasing ────── + +/// Scenario: Push events from "multiple sources" — sequences never have gaps or duplicates. +#[test] +fn event_sequences_are_gap_free_and_monotonic() { + let store = Arc::new(EventStore::new(50, false, 0)); + + // Simulate interleaved pushes + for i in 0..30 { + let mut ev = make_event(&format!("source-{}-event", i % 3)); + ev.worker_id = Some(i % 3); + store.push(ev); + } + + let (batch, _) = store.read_from(0); + assert_eq!(batch.len(), 30); + + // Verify monotonic sequences with no gaps + for (i, ev) in batch.iter().enumerate() { + assert_eq!(ev.seq, i as u64, "seq should be monotonically increasing"); + } +} diff --git a/crates/std/src/ctx_ext.rs b/crates/std/src/ctx_ext.rs index 4db2b15..fe83613 100644 --- a/crates/std/src/ctx_ext.rs +++ b/crates/std/src/ctx_ext.rs @@ -2,6 +2,7 @@ use swactor::actor::{ActorAddress, ActorInterface, Ctx, Message, MonitorRef}; use swactor::Error; use crate::StdExtension; +use crate::timer_wheel::{CloneMsg, TimerRequest}; fn get_ext<'a>(ctx: &'a Ctx) -> &'a StdExtension { ctx.extension() @@ -68,6 +69,71 @@ impl CtxNaming for Ctx<'_> { } } +/// Watching extension for [`Ctx`]. +/// +/// Provides `watch` / `unwatch` via the [`StdExtension`] watch registry. +/// When a watched actor dies, the watcher receives an [`ActorExited`] message +/// delivered to its `on_actor_exit()` callback. +pub trait CtxWatching { + /// Watch another actor's liveness. If the target dies, this actor + /// receives an `ActorExited` message. + /// + /// Calling watch() multiple times on the same target is idempotent — + /// only one notification is delivered. + fn watch(&self, target: ActorAddress); + + /// Stop watching an actor. No notification will be delivered if the + /// target subsequently dies. + fn unwatch(&self, target: ActorAddress); +} + +impl CtxWatching for Ctx<'_> { + fn watch(&self, target: ActorAddress) { + get_ext(self).watch_registry.watch(self.self_addr(), target); + } + + fn unwatch(&self, target: ActorAddress) { + get_ext(self).watch_registry.unwatch(self.self_addr(), target); + } +} + +/// Timer extension for [`Ctx`]. +/// +/// Provides `send_after_ticks` / `send_interval_ticks` via the per-worker +/// [`TimerWheel`](crate::timer_wheel::TimerWheel). +pub trait CtxTimers { + /// Schedule a one-shot timer: deliver `msg` to `addr` after `ticks` worker ticks. + /// + /// The message is delivered as a normal mailbox message during the fire tick, + /// before `tick_all` processes messages. The timer is tick-counted (deterministic), + /// not wall-clock based. + fn send_after_ticks(&self, addr: ActorAddress, msg: M, ticks: u64); + + /// Schedule a repeating timer: deliver a clone of `msg` to `addr` every `period` ticks. + /// + /// The first delivery happens after `period` ticks. The message is cloned for each + /// delivery. The timer continues until the target actor is stopped/poisoned. + fn send_interval_ticks(&self, addr: ActorAddress, msg: M, period: u64); +} + +impl CtxTimers for Ctx<'_> { + fn send_after_ticks(&self, addr: ActorAddress, msg: M, ticks: u64) { + self.raw_inner().post_worker_request(Box::new(TimerRequest::Once { + dest: addr, + msg: Box::new(msg), + ticks, + })); + } + + fn send_interval_ticks(&self, addr: ActorAddress, msg: M, period: u64) { + self.raw_inner().post_worker_request(Box::new(TimerRequest::Interval { + dest: addr, + msg: Box::new(msg) as Box, + period, + })); + } +} + /// Group extension for [`Ctx`]. /// /// Provides `join_group`, `leave_group`, `publish`, and `group_members` via diff --git a/crates/std/src/extension.rs b/crates/std/src/extension.rs index fa7d20c..713495e 100644 --- a/crates/std/src/extension.rs +++ b/crates/std/src/extension.rs @@ -1,18 +1,21 @@ use std::any::Any; -use swactor::actor::{ActorAddress, Down, StopReason}; -use swactor::extension::RuntimeExtension; +use swactor::actor::{ActorAddress, Down, ExitReason, StopReason}; +use swactor::extension::{RuntimeExtension, WorkerExtension}; use crate::group_registry::GroupRegistry; use crate::monitor_registry::MonitorRegistry; use crate::name_registry::NameRegistry; +use crate::timer_wheel::TimerWheel; +use crate::watch_registry::WatchRegistry; -/// Standard library extension — provides naming, monitoring, and group registries. +/// Standard library extension — provides naming, monitoring, watching, and group registries. /// /// Install on a `Runtime` via `runtime.with_extension(Arc::new(StdExtension::new()))`. pub struct StdExtension { pub(crate) name_registry: NameRegistry, pub(crate) monitor_registry: MonitorRegistry, + pub(crate) watch_registry: WatchRegistry, pub(crate) group_registry: GroupRegistry, } @@ -21,6 +24,7 @@ impl StdExtension { Self { name_registry: NameRegistry::new(), monitor_registry: MonitorRegistry::new(), + watch_registry: WatchRegistry::new(), group_registry: GroupRegistry::new(), } } @@ -32,19 +36,36 @@ impl Default for StdExtension { } } +/// Map StopReason → ExitReason for watch notifications. +fn stop_to_exit(reason: StopReason) -> ExitReason { + match reason { + StopReason::Normal => ExitReason::Stopped, + StopReason::Panicked => ExitReason::Panicked, + } +} + impl RuntimeExtension for StdExtension { fn on_actor_death( &self, dead: &[(ActorAddress, StopReason)], ) -> Vec<(ActorAddress, Box)> { let mut notifications = Vec::new(); + for &(addr, reason) in dead { + // Monitor notifications (Down) let watchers = self.monitor_registry.take_monitors(&addr); for (_mref, watcher) in watchers { let down = Down { addr, reason }; notifications.push((watcher, Box::new(down) as Box)); } + + // Watch notifications (ActorExited) + let watch_notifications = self.watch_registry.notify_death(addr, stop_to_exit(reason)); + for (watcher, exited) in watch_notifications { + notifications.push((watcher, Box::new(exited) as Box)); + } } + notifications } @@ -53,10 +74,15 @@ impl RuntimeExtension for StdExtension { self.name_registry.unregister_by_addr(addr); self.group_registry.cleanup(addr); self.monitor_registry.remove_watcher(addr); + self.watch_registry.cleanup_watcher(addr); } } fn as_any(&self) -> &dyn Any { self } + + fn create_worker_extension(&self) -> Option> { + Some(Box::new(TimerWheel::new())) + } } diff --git a/crates/std/src/lib.rs b/crates/std/src/lib.rs index 3481957..98df904 100644 --- a/crates/std/src/lib.rs +++ b/crates/std/src/lib.rs @@ -2,7 +2,9 @@ mod supervisor; mod router; pub mod name_registry; pub mod monitor_registry; +pub mod watch_registry; pub mod group_registry; +pub(crate) mod timer_wheel; mod extension; mod ctx_ext; mod runtime_ext; @@ -10,5 +12,5 @@ mod runtime_ext; pub use supervisor::{ChildSpec, RestartPolicy, Supervisor, SupervisorStrategy}; pub use router::{Router, RoutingStrategy}; pub use extension::StdExtension; -pub use ctx_ext::{CtxMonitoring, CtxNaming, CtxGroups}; -pub use runtime_ext::{RuntimeNaming, RuntimeGroups}; +pub use ctx_ext::{CtxMonitoring, CtxNaming, CtxGroups, CtxWatching, CtxTimers}; +pub use runtime_ext::{RuntimeNaming, RuntimeGroups, RuntimeWatching}; diff --git a/crates/std/src/runtime_ext.rs b/crates/std/src/runtime_ext.rs index f75b0d7..3cd72c6 100644 --- a/crates/std/src/runtime_ext.rs +++ b/crates/std/src/runtime_ext.rs @@ -61,6 +61,27 @@ impl RuntimeNaming for Runtime { } } +/// Watching extension for [`Runtime`]. +/// +/// Provides `watch` / `unwatch` via the [`StdExtension`] watch registry. +pub trait RuntimeWatching { + /// Register a watch: `watcher` receives `ActorExited` when `target` dies. + fn watch(&self, watcher: ActorAddress, target: ActorAddress); + + /// Cancel a watch. + fn unwatch(&self, watcher: ActorAddress, target: ActorAddress); +} + +impl RuntimeWatching for Runtime { + fn watch(&self, watcher: ActorAddress, target: ActorAddress) { + get_ext(self).watch_registry.watch(watcher, target); + } + + fn unwatch(&self, watcher: ActorAddress, target: ActorAddress) { + get_ext(self).watch_registry.unwatch(watcher, target); + } +} + /// Group extension for [`Runtime`]. /// /// Provides `join_group`, `leave_group`, `publish_to`, `group_members`, diff --git a/crates/std/src/timer_wheel.rs b/crates/std/src/timer_wheel.rs new file mode 100644 index 0000000..6ca4e94 --- /dev/null +++ b/crates/std/src/timer_wheel.rs @@ -0,0 +1,148 @@ +use std::any::Any; + +use swactor::actor::{ActorAddress, Message}; +use swactor::extension::WorkerExtension; + +// ─── Cloneable Message Trait ──────────────────────────────────────────────── + +/// Type-erased cloneable message for interval timers. +/// Since `Message: Clone`, all actor messages implement this. +pub(crate) trait CloneMsg: Send { + fn clone_boxed(&self) -> Box; +} + +impl CloneMsg for M { + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } +} + +// ─── Timer Request ────────────────────────────────────────────────────────── + +/// Timer request from a handler, queued for processing after tick_all. +pub(crate) enum TimerRequest { + /// One-shot: deliver `msg` to `dest` after `ticks` worker ticks. + Once { + dest: ActorAddress, + msg: Box, + ticks: u64, + }, + /// Repeating: deliver a clone of `msg` to `dest` every `period` ticks. + Interval { + dest: ActorAddress, + msg: Box, + period: u64, + }, +} + +// ─── Timer Wheel ──────────────────────────────────────────────────────────── + +struct OnceTimer { + fire_at: u64, + dest: ActorAddress, + msg: Box, +} + +struct IntervalTimer { + next_fire: u64, + period: u64, + dest: ActorAddress, + msg: Box, +} + +/// Per-worker tick-counting timer wheel. +/// +/// Timers are deterministic (tick-counted, not wall-clock). One-shot timers +/// fire once and are consumed; interval timers fire repeatedly every N ticks. +pub struct TimerWheel { + current_tick: u64, + once_timers: Vec, + interval_timers: Vec, +} + +impl TimerWheel { + pub fn new() -> Self { + Self { + current_tick: 0, + once_timers: Vec::new(), + interval_timers: Vec::new(), + } + } + + /// Advance the tick counter and collect all due timer messages. + fn fire(&mut self) -> Vec<(ActorAddress, Box)> { + self.current_tick += 1; + let tick = self.current_tick; + let mut result = Vec::new(); + + // Fire one-shot timers (swap-remove for O(1) removal) + let mut i = 0; + while i < self.once_timers.len() { + if self.once_timers[i].fire_at <= tick { + let timer = self.once_timers.swap_remove(i); + result.push((timer.dest, timer.msg)); + } else { + i += 1; + } + } + + // Fire interval timers + for timer in &mut self.interval_timers { + if timer.next_fire <= tick { + let msg = timer.msg.clone_boxed(); + result.push((timer.dest, msg)); + timer.next_fire = tick + timer.period; + } + } + + result + } + + /// Remove interval timers whose target was just removed from the worker. + fn gc_dead_intervals(&mut self, dead: &[ActorAddress]) { + if dead.is_empty() { + return; + } + self.interval_timers + .retain(|t| !dead.iter().any(|d| *d == t.dest)); + } + + fn add_once(&mut self, dest: ActorAddress, msg: Box, ticks: u64) { + self.once_timers.push(OnceTimer { + fire_at: self.current_tick + ticks, + dest, + msg, + }); + } + + fn add_interval(&mut self, dest: ActorAddress, msg: Box, period: u64) { + let period = period.max(1); // prevent zero-period infinite loop + self.interval_timers.push(IntervalTimer { + next_fire: self.current_tick + period, + period, + dest, + msg, + }); + } +} + +impl WorkerExtension for TimerWheel { + fn on_tick(&mut self) -> Vec<(ActorAddress, Box)> { + self.fire() + } + + fn handle_request(&mut self, request: Box) { + if let Ok(req) = request.downcast::() { + match *req { + TimerRequest::Once { dest, msg, ticks } => self.add_once(dest, msg, ticks), + TimerRequest::Interval { dest, msg, period } => { + self.add_interval(dest, msg, period) + } + } + } + } + + fn gc_dead(&mut self, dead: &[ActorAddress]) { + self.gc_dead_intervals(dead); + } +} diff --git a/crates/std/src/watch_registry.rs b/crates/std/src/watch_registry.rs new file mode 100644 index 0000000..4ebaf51 --- /dev/null +++ b/crates/std/src/watch_registry.rs @@ -0,0 +1,95 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::Mutex; + +use swactor::actor::{ActorAddress, ActorExited, ExitReason}; + +/// Tracks watch relationships between actors. +/// +/// Thread-safe via interior `Mutex`. Watch/unwatch operations are rare +/// relative to message sends, so contention is negligible. +pub struct WatchRegistry { + inner: Mutex, +} + +struct WatchState { + /// target → set of watchers awaiting death notification + watchers: HashMap>, + /// watcher → set of targets it's watching (reverse index for cleanup) + watching: HashMap>, +} + +impl WatchRegistry { + pub fn new() -> Self { + Self { + inner: Mutex::new(WatchState { + watchers: HashMap::new(), + watching: HashMap::new(), + }), + } + } + + pub fn watch(&self, watcher: ActorAddress, target: ActorAddress) { + let mut state = self.inner.lock().unwrap(); + state.watchers.entry(target).or_default().insert(watcher); + state.watching.entry(watcher).or_default().insert(target); + } + + pub fn unwatch(&self, watcher: ActorAddress, target: ActorAddress) { + let mut state = self.inner.lock().unwrap(); + if let Some(set) = state.watchers.get_mut(&target) { + set.remove(&watcher); + if set.is_empty() { + state.watchers.remove(&target); + } + } + if let Some(set) = state.watching.get_mut(&watcher) { + set.remove(&target); + if set.is_empty() { + state.watching.remove(&watcher); + } + } + } + + /// Called when an actor dies. Returns (watcher_addr, ActorExited) pairs. + pub fn notify_death( + &self, + target: ActorAddress, + reason: ExitReason, + ) -> Vec<(ActorAddress, ActorExited)> { + let mut state = self.inner.lock().unwrap(); + let notification = ActorExited { + addr: target, + reason, + }; + let mut result = Vec::new(); + + if let Some(watcher_set) = state.watchers.remove(&target) { + for watcher in &watcher_set { + result.push((*watcher, notification.clone())); + if let Some(set) = state.watching.get_mut(watcher) { + set.remove(&target); + if set.is_empty() { + state.watching.remove(watcher); + } + } + } + } + + result + } + + /// Called when a watcher itself dies. Cleans up all its watching entries. + pub fn cleanup_watcher(&self, watcher: &ActorAddress) { + let mut state = self.inner.lock().unwrap(); + if let Some(targets) = state.watching.remove(watcher) { + for target in targets { + if let Some(set) = state.watchers.get_mut(&target) { + set.remove(watcher); + if set.is_empty() { + state.watchers.remove(&target); + } + } + } + } + } +} diff --git a/docs/runtime/runtime.md b/docs/runtime/runtime.md index 05655fe..02ff659 100644 --- a/docs/runtime/runtime.md +++ b/docs/runtime/runtime.md @@ -16,11 +16,11 @@ messages. │ │ │ │ │ │ address_map: Arc -- actor -> worker lookup │ │ │ │ inbox_registry: Arc -- external inbox delivery │ │ -│ │ name_registry: Arc -- name -> address lookup │ │ -│ │ monitor_registry: Arc -- death watch subscripts │ │ -│ │ group_registry: Arc -- pub-sub actor groups │ │ │ │ placement: Placement -- load-aware worker picker │ │ │ │ worker_stats: Vec> -- atomic stat counters │ │ +│ │ extension: Option> │ │ +│ │ (StdExtension holds: NameRegistry, MonitorRegistry, │ │ +│ │ GroupRegistry, WatchRegistry) │ │ │ │ │ │ │ └─────────────────────────────────────────────────────────────────────┘ │ │ │ @@ -75,35 +75,42 @@ only way for actors to interact with the outside world. │ inner: &dyn ContextInner -- polymorphic dispatch │ │ self_addr: ActorAddress -- address of the current actor │ │ │ -│ ┌─ Public API ────────────────────────────────────────────────────────┐ │ +│ ┌─ Core API ─────────────────────────────────────────────────────────┐ │ │ │ │ │ │ │ ctx.self_addr() -> ActorAddress │ │ │ │ ctx.send(addr, msg) -> Result<(), Error> │ │ │ │ ctx.spawn(actor) -> Result │ │ -│ │ ctx.spawn_named(name, actor) -> Result │ │ -│ │ ctx.spawn_restartable(a, f, max) -> Result │ │ │ │ ctx.stop_self() │ │ │ │ ctx.stop_actor(addr) -> Result<(), Error> │ │ -│ │ ctx.where_is(name) -> Option │ │ -│ │ ctx.monitor(target) -> MonitorRef │ │ -│ │ ctx.demonitor(mref) │ │ -│ │ ctx.join_group(group) │ │ -│ │ ctx.leave_group(group) │ │ -│ │ ctx.publish(group, msg) -> usize │ │ -│ │ ctx.group_members(group) -> Vec │ │ -│ │ ctx.send_after_ticks(addr, msg, n) │ │ -│ │ ctx.send_interval_ticks(addr, msg, period) │ │ +│ │ ctx.extension() -> Option<&dyn RuntimeExtension> │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Extension Traits (swactor-std) ──────────────────────────────────┐ │ +│ │ │ │ +│ │ CtxNaming: spawn_named, where_is │ │ +│ │ CtxMonitoring: monitor, demonitor │ │ +│ │ CtxWatching: watch, unwatch │ │ +│ │ CtxGroups: join_group, leave_group, publish, group_members │ │ +│ │ CtxTimers: send_after_ticks, send_interval_ticks │ │ +│ │ │ │ +│ │ These use ctx.extension() + downcast to StdExtension. │ │ +│ │ Also: spawn_restartable (via CtxNaming) │ │ │ │ │ │ │ └─────────────────────────────────────────────────────────────────────┘ │ │ │ │ ┌─ ContextInner dispatch ─────────────────────────────────────────────┐ │ │ │ │ │ +│ │ Five methods: send_any, spawn_any, request_stop, │ │ +│ │ post_worker_request, extension │ │ +│ │ │ │ │ │ In single-threaded mode: inner = &Runtime │ │ │ │ send → transfer_txs[wid], spawn → spawn_txs[wid] │ │ │ │ │ │ │ │ In multi-threaded mode: inner = &WorkerContext │ │ │ │ send → pending_local (same worker) or transfer_txs (cross) │ │ │ │ spawn → spawn_txs[target_wid] │ │ +│ │ post_worker_request → worker_requests (drained phase 5.5) │ │ │ │ │ │ │ └─────────────────────────────────────────────────────────────────────┘ │ │ │ @@ -287,15 +294,22 @@ Actors can stop other actors from handlers: ctx.stop_actor(other_addr)?; // PoisonPill semantics — queued after existing msgs ``` -## Per-Worker Timers +## Per-Worker Timers (swactor-std) -Deterministic tick-counting timers (not wall-clock): +Deterministic tick-counting timers (not wall-clock). Requires `StdExtension` +and the `CtxTimers` extension trait: ``` + use swactor_std::CtxTimers; + ctx.send_after_ticks(addr, msg, 5); // one-shot: fires after 5 ticks ctx.send_interval_ticks(addr, msg, 10); // repeating: every 10 ticks ``` +The `TimerWheel` lives as a per-worker extension (`WorkerExtension`), +created by `StdExtension::create_worker_extension()`. Timer requests are +dispatched via `ctx.post_worker_request()` and processed in phase 5.5. + ## RuntimeHandle Returned by `run()`. Holds `Arc` and the thread `JoinHandle`s. diff --git a/docs/runtime/worker-thread.md b/docs/runtime/worker-thread.md index 3916f9e..9b6ab3c 100644 --- a/docs/runtime/worker-thread.md +++ b/docs/runtime/worker-thread.md @@ -48,10 +48,12 @@ │ │ │ │ │ └──────────────────────────────────────────────────────────────────┘ │ │ │ -│ ┌─ TimerWheel ────────────────────────────────────────────────────┐ │ -│ │ current_tick: u64 │ │ -│ │ once_timers: Vec -- fire_at, dest, msg │ │ -│ │ interval_timers: Vec -- period, dest, clone_msg │ │ +│ ┌─ worker_ext: Option> ─────────────────┐ │ +│ │ Per-worker extension state, created by RuntimeExtension │ │ +│ │ factory. StdExtension provides a TimerWheel here. │ │ +│ │ on_tick() → fire due messages (phase 2.5) │ │ +│ │ handle_request() → schedule timers etc. (phase 5.5) │ │ +│ │ gc_dead() → clean up dead actor state (phase 7) │ │ │ └──────────────────────────────────────────────────────────────────┘ │ │ │ └────────────────────────────────────────────────────────────────────────┘ @@ -69,11 +71,9 @@ Lives on `Arc`, shared read-only across all worker threads. │ spawn_txs: &[Sender] -- one Sender per worker │ │ placement: &Placement -- load-aware worker picker │ │ inbox_registry: &InboxRegistry -- external Inbox receivers │ -│ name_registry: &NameRegistry -- String -> ActorAddress │ -│ monitor_registry: &MonitorRegistry -- death watch subscriptions │ -│ group_registry: &GroupRegistry -- pub-sub actor groups │ │ config: &RuntimeConfig -- budget, backoff, etc. │ -│ stats_hook: Option<&dyn Hook> -- per-tick stats callback │ +│ extension: Option<&dyn RuntimeExtension> -- shared ext │ +│ stats_hook: Option<&dyn StatsHook> -- per-tick stats callback │ │ worker_threads: &[OnceLock] -- for unpark on send/spawn │ │ │ └────────────────────────────────────────────────────────────────────────┘ @@ -147,19 +147,16 @@ Lives on `Arc`, shared read-only across all worker threads. │ └────────────────────────────────────────────────────────────────────┘│ │ │ │ │ v │ -│ PHASE 2.5 --- Fire Due Timers │ +│ PHASE 2.5 --- Fire Per-Worker Extension │ │ ┌────────────────────────────────────────────────────────────────────┐│ │ │ ││ -│ │ timers.fire() (advances tick counter, collects due messages) ││ +│ │ worker_ext.on_tick() → Vec<(dest, msg)> ││ +│ │ (StdExtension provides TimerWheel: advances tick, fires due) ││ │ │ │ ││ │ │ v ││ -│ │ for (dest, msg) in timer_msgs: ││ -│ │ ┌──────────────┬──────────────┬─────────────────┐ ││ -│ │ │ local actor │ other worker │ inbox/unknown │ ││ -│ │ │ │ │ │ ││ -│ │ │ pool.deliver │ transfer_tx │ inbox_registry │ ││ -│ │ │ │ + unpark │ .try_deliver() │ ││ -│ │ └──────────────┴──────────────┴─────────────────┘ ││ +│ │ for (dest, msg) in ext_msgs: ││ +│ │ route_to_pool_or_remote(pool, tc, dest, msg) ││ +│ │ local → pool.deliver | cross → transfer_tx | → inbox_registry ││ │ │ ││ │ └────────────────────────────────────────────────────────────────────┘│ │ │ │ @@ -169,9 +166,9 @@ Lives on `Arc`, shared read-only across all worker threads. │ │ ││ │ │ ┌─ WorkerContext (on stack) ─────────────────────────────────┐ ││ │ │ │ implements ContextInner │ ││ -│ │ │ pending_local: RefCell)>> │ ││ -│ │ │ stop_requests: RefCell> │ ││ -│ │ │ timer_requests: RefCell> │ ││ +│ │ │ pending_local: RefCell)>> │ ││ +│ │ │ stop_requests: RefCell> │ ││ +│ │ │ worker_requests: RefCell>> │ ││ │ │ └────────────────────────────────────────────────────────────┘ ││ │ │ ││ │ │ for each (addr, slot) in pool: ││ @@ -214,11 +211,9 @@ Lives on `Arc`, shared read-only across all worker threads. │ │ │ address_map.insert(addr, wid) │ ││ │ │ │ spawn_txs[wid].send((addr, actor)) + unpark │ ││ │ │ │ │ ││ -│ │ │ request_stop(addr): → stop_requests.push(addr) │ ││ -│ │ │ schedule_timer(req): → timer_requests.push(req) │ ││ -│ │ │ where_is(name): → name_registry.lookup(name) │ ││ -│ │ │ monitor(w, t): → monitor_registry.register(w, t) │ ││ -│ │ │ join_group(a, g): → group_registry.join(g, a) │ ││ +│ │ │ request_stop(addr): → stop_requests.push(addr) │ ││ +│ │ │ post_worker_request(r): → worker_requests.push(r) │ ││ +│ │ │ extension(): → tc.extension │ ││ │ │ │ │ ││ │ │ └────────────────────────────────────────────────────────────┘ ││ │ │ ││ @@ -242,12 +237,12 @@ Lives on `Arc`, shared read-only across all worker threads. │ └────────────────────────────────────────────────────────────────────┘│ │ │ │ │ v │ -│ PHASE 5.5 --- Drain Timer Requests │ +│ PHASE 5.5 --- Drain Worker Extension Requests │ │ ┌────────────────────────────────────────────────────────────────────┐│ │ │ ││ -│ │ for request in timer_requests: ││ -│ │ Once { dest, msg, ticks } → timers.add_once(dest, msg, ticks) ││ -│ │ Interval { dest, msg, p } → timers.add_interval(dest, msg, p) ││ +│ │ for request in worker_requests: ││ +│ │ worker_ext.handle_request(request) ││ +│ │ (StdExtension: downcasts to TimerRequest, schedules timers) ││ │ │ ││ │ └────────────────────────────────────────────────────────────────────┘│ │ │ │ @@ -272,23 +267,20 @@ Lives on `Arc`, shared read-only across all worker threads. │ │ stopping actors: call on_stop(&ctx) before removal ││ │ │ poisoned actors: skip on_stop (state may be corrupt) ││ │ │ ││ -│ │ for each dead (addr, reason): ││ +│ │ for each dead addr: ││ │ │ address_map.remove(&addr) ││ -│ │ name_registry.unregister_by_addr(&addr) ││ -│ │ group_registry.cleanup(&addr) ││ +│ │ ││ +│ │ if extension installed: ││ +│ │ notifications = ext.on_actor_death(&dead) ││ +│ │ (StdExtension: emits Down/ActorExited, unregisters names, ││ +│ │ removes from groups, takes monitors) ││ +│ │ ext.cleanup_dead(&dead_addrs) ││ +│ │ route notifications via route_to_pool_or_remote() ││ │ │ ││ │ │ deliver any messages sent during on_stop callbacks ││ │ │ ││ -│ │ emit Down notifications for monitored dead actors: ││ -│ │ for (addr, reason) in dead: ││ -│ │ watchers = monitor_registry.take_monitors(&addr) ││ -│ │ for each watcher: route Down { addr, reason } ││ -│ │ same-worker → pool.deliver ││ -│ │ cross-worker → transfer_tx + unpark ││ -│ │ inbox → inbox_registry.try_deliver ││ -│ │ monitor_registry.remove_watcher(&addr) ││ -│ │ ││ -│ │ timers.gc_dead_intervals(dead_addrs) ││ +│ │ worker_ext.gc_dead(&dead_addrs) ││ +│ │ (StdExtension: removes orphaned interval timers) ││ │ │ ││ │ └────────────────────────────────────────────────────────────────────┘│ │ │ @@ -510,13 +502,12 @@ Who holds what: │ │ addr->wid │ │ load-aware │ │ addr->Sender│ │ AtomicBool │ │ │ └─────┬─────┘ └──────┬─────┘ └──────┬──────┘ └──────┬─────┘ │ │ │ │ │ │ │ -│ ┌─────────────┐ ┌──────────────┐ ┌─────────────┐ │ -│ │NameRegistry │ │MonitorRegist.│ │GroupRegistry │ │ -│ │ name->addr │ │ watched-> │ │ group->addrs │ │ -│ │ addr->name │ │ watchers │ │ addr->groups │ │ -│ └─────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ -│ │ │ │ │ -│ ┌─────┴───────────────┴──────────────┴───────────────────────────────┐ │ +│ ┌─ extension: Arc ──────────────────────────┐ │ +│ │ StdExtension holds: NameRegistry, MonitorRegistry, │ │ +│ │ GroupRegistry, WatchRegistry (accessed via downcast) │ │ +│ └─────────────────────────────────┬───────────────────────────────┘ │ +│ │ │ +│ ┌─────────────────────────────────┴──────────────────────────────────┐ │ │ │ TickContext (borrows all above) │ │ │ └──────────────────────────┬──────────────────────────────────────────┘ │ │ │ │ diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index a43cf35..c9a04d2 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -11,6 +11,7 @@ cargo-fuzz = true libfuzzer-sys = { version = "0.4", features = ["arbitrary-derive"] } arbitrary = { version = "1", features = ["derive"] } swactor = { path = "..", default-features = true } +swactor-std = { path = "../crates/std" } # Prevent this from interfering with workspaces [workspace] diff --git a/fuzz/fuzz_targets/fuzz_runtime.rs b/fuzz/fuzz_targets/fuzz_runtime.rs index 54349ee..6b3e303 100644 --- a/fuzz/fuzz_targets/fuzz_runtime.rs +++ b/fuzz/fuzz_targets/fuzz_runtime.rs @@ -7,9 +7,12 @@ use std::sync::OnceLock; use arbitrary::Arbitrary; use libfuzzer_sys::fuzz_target; +use std::sync::Arc; + use swactor::actor::{ActorAddress, ActorInterface}; use swactor::config::RuntimeConfig; use swactor::runtime::{Ctx, Inbox, Runtime}; +use swactor_std::{CtxTimers, StdExtension}; // ─── Run Logging ──────────────────────────────────────────────────────────── // FUZZ_LOG=1 → trace every run @@ -881,7 +884,8 @@ fuzz_target!(|input: FuzzInput| { num_threads: 1, ..Default::default() }; - let rt = Runtime::new(config); + let rt = Runtime::new(config) + .with_extension(Arc::new(StdExtension::new())); let mut state = FuzzState::new(rt, tracing); let scenario_limit = input.scenarios.len().min(64); diff --git a/src/actor.rs b/src/actor.rs index 64b620c..29c4a9f 100644 --- a/src/actor.rs +++ b/src/actor.rs @@ -199,53 +199,21 @@ pub struct Down { /// Not a `Message` — intercepted in `tick_all` before reaching `handle_any`. pub(crate) struct StopSignal; -/// Type-erased cloneable message for interval timers. -/// Since `Message: Clone`, all actor messages can implement this. -pub(crate) trait CloneMsg: Send { - fn clone_boxed(&self) -> Box; -} - -impl CloneMsg for M { - fn clone_boxed(&self) -> Box { - Box::new(self.clone()) - } -} - -/// Timer request from a handler, queued for processing after tick_all. -pub(crate) enum TimerRequest { - /// One-shot: deliver `msg` to `dest` after `ticks` worker ticks. - Once { - dest: ActorAddress, - msg: Box, - ticks: u64, - }, - /// Repeating: deliver a clone of `msg` to `dest` every `period` ticks. - Interval { - dest: ActorAddress, - msg: Box, - period: u64, - }, -} - /// Object-safe inner trait for sending type-erased messages. /// -/// Minimal core interface: send, spawn, stop, timers, and extension access. -/// Registry methods (naming, monitoring, groups) are provided by extension -/// traits in `swactor-std`. +/// Minimal core interface: send, spawn, stop, and extension access. +/// Registry methods (naming, monitoring, groups) and timer scheduling +/// are provided by extension traits in `swactor-std`. #[allow(private_interfaces)] pub trait ContextInner { fn send_any(&self, addr: ActorAddress, msg: Box) -> Result<(), Error>; fn spawn_any(&self, addr: ActorAddress, actor: Box); /// Request graceful stop for an actor. Takes effect after the current message. fn request_stop(&self, addr: ActorAddress); - /// Schedule a timer (one-shot or interval). - fn schedule_timer(&self, request: TimerRequest); + /// Post a request to the per-worker extension (e.g., timer scheduling). + fn post_worker_request(&self, request: Box); /// Access the runtime extension (if installed). fn extension(&self) -> Option<&dyn crate::extension::RuntimeExtension>; - /// Register a watch: watcher receives ActorExited when target dies. - fn watch(&self, watcher: ActorAddress, target: ActorAddress); - /// Cancel a watch. - fn unwatch(&self, watcher: ActorAddress, target: ActorAddress); } /// Actor syscall interface — passed to `ActorInterface::handle()`. @@ -307,46 +275,4 @@ impl<'a> Ctx<'a> { self.inner.send_any(addr, Box::new(StopSignal)) } - /// Schedule a one-shot timer: deliver `msg` to `addr` after `ticks` worker ticks. - /// - /// The message is delivered as a normal mailbox message during the fire tick, - /// before `tick_all` processes messages. The timer is tick-counted (deterministic), - /// not wall-clock based. - pub fn send_after_ticks(&self, addr: ActorAddress, msg: M, ticks: u64) { - self.inner.schedule_timer(TimerRequest::Once { - dest: addr, - msg: Box::new(msg), - ticks, - }); - } - - /// Schedule a repeating timer: deliver a clone of `msg` to `addr` every `period` ticks. - /// - /// The first delivery happens after `period` ticks. The message is cloned for each - /// delivery. The timer continues until the target actor is stopped/poisoned. - pub fn send_interval_ticks(&self, addr: ActorAddress, msg: M, period: u64) { - self.inner.schedule_timer(TimerRequest::Interval { - dest: addr, - msg: Box::new(msg), - period, - }); - } - - /// Watch another actor's liveness. If the target dies, this actor - /// receives an `ActorExited` message in its mailbox. - /// - /// Watching an already-dead or non-existent actor delivers - /// `ActorExited { reason: Stopped }` on the next tick. - /// - /// Calling watch() multiple times on the same target is idempotent — - /// only one notification is delivered. - pub fn watch(&self, target: ActorAddress) { - self.inner.watch(self.self_addr, target); - } - - /// Stop watching an actor. No notification will be delivered if the - /// target subsequently dies. - pub fn unwatch(&self, target: ActorAddress) { - self.inner.unwatch(self.self_addr, target); - } } diff --git a/src/delivery.rs b/src/delivery.rs index b21bcc2..b1f3aeb 100644 --- a/src/delivery.rs +++ b/src/delivery.rs @@ -2,14 +2,13 @@ use std::any::Any; use std::collections::{HashMap, HashSet}; use std::hash::{BuildHasher, Hasher}; use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex, OnceLock, RwLock}; +use std::sync::{Arc, OnceLock, RwLock}; use std::thread::Thread; use crate::actor::{ActorAddress, AnyActor, Message}; use crate::channel::Sender; use crate::config::RuntimeConfig; use crate::stats::WorkerStats; -use crate::worker::WatchRegistry; use crate::Error; // ─── Identity Hasher for ActorAddress ─────────────────────────────────────── @@ -244,7 +243,6 @@ pub(crate) struct TickContext<'a> { pub(crate) stats_hook: Option<&'a dyn crate::stats::StatsHook>, /// Thread handles for waking parked workers on cross-worker sends. pub(crate) worker_threads: &'a [OnceLock], - pub(crate) watch_registry: Option<&'a Arc>>, #[cfg(feature = "transport")] pub(crate) codec_registry: Option<&'a crate::transport::CodecRegistry>, #[cfg(feature = "transport")] diff --git a/src/extension.rs b/src/extension.rs index 7028bd2..51681ba 100644 --- a/src/extension.rs +++ b/src/extension.rs @@ -25,4 +25,29 @@ pub trait RuntimeExtension: Send + Sync { /// Downcast support for Ctx extension traits. fn as_any(&self) -> &dyn Any; + + /// Create a per-worker extension instance. Called once per worker during init. + /// + /// Unlike `RuntimeExtension` (shared across all workers), each worker owns + /// its own `WorkerExtension` instance for per-worker state like timer wheels. + fn create_worker_extension(&self) -> Option> { + None + } +} + +/// Per-worker extension state, created by [`RuntimeExtension::create_worker_extension`]. +/// +/// Each worker owns its own instance. Core calls these methods during tick phases: +/// - `on_tick`: phase 2.5 — before tick_all, returns messages to deliver +/// - `handle_request`: phase 5.5 — processes deferred requests from handlers +/// - `gc_dead`: after cleanup_dead — removes state for dead actors +pub trait WorkerExtension: Send { + /// Called each tick before tick_all. Returns messages to deliver. + fn on_tick(&mut self) -> Vec<(ActorAddress, Box)>; + + /// Process a deferred request posted during handle() via `post_worker_request`. + fn handle_request(&mut self, request: Box); + + /// Clean up state for dead actors. + fn gc_dead(&mut self, dead: &[ActorAddress]); } diff --git a/src/runtime.rs b/src/runtime.rs index a412f91..ce04231 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -1,13 +1,13 @@ use std::any::Any; use std::cell::RefCell; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex, OnceLock}; +use std::sync::{Arc, OnceLock}; #[cfg(not(target_arch = "wasm32"))] use std::thread::{self, JoinHandle}; use std::thread::Thread; use crate::Instant; -use crate::actor::{Actor, ActorAddress, ActorExited, ActorInterface, AnyActor, ExitReason, Message, StopSignal, TimerRequest}; +use crate::actor::{Actor, ActorAddress, ActorInterface, AnyActor, Message, StopSignal}; use crate::channel::{Receiver, Sender}; // Re-export config types so existing code using `runtime::RuntimeConfig` still works pub use crate::config::{BackoffPolicy, MailboxOverflow, RuntimeConfig}; @@ -16,7 +16,7 @@ use crate::extension::RuntimeExtension; use crate::stats::{StatsHook, WorkerStats}; // Re-export stats types so existing code using `runtime::*` still works pub use crate::stats::{RuntimeStats, WorkerInfo}; -use crate::worker::{WatchRegistry, Worker}; +use crate::worker::Worker; use crate::Error; /// Generic message inbox for receiving messages outside of the runtime. @@ -107,7 +107,6 @@ pub struct Runtime { is_running: AtomicBool, worker_stats: Vec>, stats_hook: Option>, - watch_registry: Arc>, /// Workers available for tick(). run() drains this and moves workers to threads. tick_workers: RefCell>, /// Thread handles for waking parked workers. Set by workers on startup via OnceLock. @@ -203,7 +202,6 @@ impl Runtime { is_running: AtomicBool::new(false), worker_stats, stats_hook: None, - watch_registry: Arc::new(Mutex::new(WatchRegistry::new())), tick_workers: RefCell::new(workers), worker_threads, created_at: Instant::now(), @@ -247,6 +245,12 @@ impl Runtime { /// /// Must be called before `run()` or `tick()`. pub fn with_extension(mut self, ext: Arc) -> Self { + // Create per-worker extensions (e.g., timer wheels) + for worker in self.tick_workers.get_mut().iter_mut() { + if let Some(wext) = ext.create_worker_extension() { + worker.worker_ext = Some(wext); + } + } self.extension = Some(ext); self } @@ -310,7 +314,6 @@ impl Runtime { extension: self.extension.as_deref(), stats_hook: self.stats_hook.as_deref(), worker_threads: &self.worker_threads, - watch_registry: Some(&self.watch_registry), #[cfg(feature = "transport")] codec_registry: self.codec_registry.as_deref(), #[cfg(feature = "transport")] @@ -506,35 +509,13 @@ impl ContextInner for Runtime { } } - fn schedule_timer(&self, _request: TimerRequest) { - // Timers are per-worker and tick-counted; scheduling from outside + fn post_worker_request(&self, _request: Box) { + // Worker requests (e.g., timers) are per-worker; posting from outside // a worker context (e.g., rt.spawn() callback) is not supported. - // Use rt.send_to() with a delay loop instead. - eprintln!("swactor: schedule_timer called outside worker context — ignored"); + eprintln!("swactor: post_worker_request called outside worker context — ignored"); } fn extension(&self) -> Option<&dyn RuntimeExtension> { self.extension.as_deref() } - - fn watch(&self, watcher: ActorAddress, target: ActorAddress) { - if self.address_map.lookup(&target).is_some() { - self.watch_registry.lock().unwrap().watch(watcher, target); - } else { - // Target not found — deliver ActorExited immediately. - let msg = ActorExited { - addr: target, - reason: ExitReason::Stopped, - }; - // Route to watcher via transfer queue - if let Some(wid) = self.address_map.lookup(&watcher) { - self.transfer_txs[wid.as_usize()] - .send(Envelope::new(watcher, Box::new(msg))); - } - } - } - - fn unwatch(&self, watcher: ActorAddress, target: ActorAddress) { - self.watch_registry.lock().unwrap().unwatch(watcher, target); - } } diff --git a/src/worker.rs b/src/worker.rs index acaa33b..197e86d 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -1,199 +1,40 @@ use std::any::Any; use std::cell::RefCell; -use std::collections::{HashMap, HashSet, VecDeque}; +use std::collections::{HashMap, VecDeque}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::thread; use crate::Instant; -use crate::actor::{ActorAddress, ActorExited, AnyActor, CloneMsg, ContextInner, Ctx, ExitReason, StopReason, StopSignal, TimerRequest}; +use crate::actor::{ActorAddress, AnyActor, ContextInner, Ctx, StopReason, StopSignal}; use crate::channel::Receiver; use crate::config::MailboxOverflow; use crate::delivery::{AddrBuildHasher, AddrMap, Envelope, TickContext, WorkerId}; use crate::stats::{ActorSnapshot, TickTiming, WorkerStats}; use crate::Error; -// ─── Per-Worker Timer Wheel ───────────────────────────────────────────────── +use crate::extension::WorkerExtension; -struct OnceTimer { - fire_at: u64, +/// Route a message: try local pool first, then address_map for cross-worker, +/// then inbox_registry for external receivers. +fn route_to_pool_or_remote( + pool: &mut ActorPool, + tc: &TickContext, dest: ActorAddress, msg: Box, -} - -struct IntervalTimer { - next_fire: u64, - period: u64, - dest: ActorAddress, - msg: Box, -} - -/// Per-worker tick-counting timer wheel. -/// -/// Timers are deterministic (tick-counted, not wall-clock). One-shot timers -/// fire once and are consumed; interval timers fire repeatedly every N ticks. -struct TimerWheel { - current_tick: u64, - once_timers: Vec, - interval_timers: Vec, -} - -impl TimerWheel { - fn new() -> Self { - Self { - current_tick: 0, - once_timers: Vec::new(), - interval_timers: Vec::new(), - } - } - - /// Advance the tick counter and collect all due timer messages. - /// Returns the messages to be routed by the caller (may target local or remote actors/inboxes). - fn fire(&mut self) -> Vec<(ActorAddress, Box)> { - self.current_tick += 1; - let tick = self.current_tick; - let mut result = Vec::new(); - - // Fire one-shot timers (swap-remove for O(1) removal) - let mut i = 0; - while i < self.once_timers.len() { - if self.once_timers[i].fire_at <= tick { - let timer = self.once_timers.swap_remove(i); - result.push((timer.dest, timer.msg)); - } else { - i += 1; +) { + if pool.contains(&dest) { + pool.deliver(&dest, msg); + } else { + match tc.address_map.lookup(&dest) { + Some(wid) => { + tc.transfer_txs[wid.as_usize()].send(Envelope::new(dest, msg)); + crate::runtime::notify_worker(tc.worker_threads, wid.as_usize()); + } + None => { + let _ = tc.inbox_registry.try_deliver(dest, msg); } } - - // Fire interval timers - for timer in &mut self.interval_timers { - if timer.next_fire <= tick { - let msg = timer.msg.clone_boxed(); - result.push((timer.dest, msg)); - timer.next_fire = tick + timer.period; - } - } - - result - } - - /// Remove interval timers whose target was just removed from the worker. - /// Only GCs timers for addresses in `dead` — inboxes and cross-worker actors - /// are not in the local pool but are still valid targets. - fn gc_dead_intervals(&mut self, dead: &[ActorAddress]) { - if dead.is_empty() { - return; - } - self.interval_timers.retain(|t| !dead.iter().any(|d| *d == t.dest)); - } - - /// Add a one-shot timer. - fn add_once(&mut self, dest: ActorAddress, msg: Box, ticks: u64) { - self.once_timers.push(OnceTimer { - fire_at: self.current_tick + ticks, - dest, - msg, - }); - } - - /// Add an interval timer. First fire is after `period` ticks. - fn add_interval(&mut self, dest: ActorAddress, msg: Box, period: u64) { - let period = period.max(1); // prevent zero-period infinite loop - self.interval_timers.push(IntervalTimer { - next_fire: self.current_tick + period, - period, - dest, - msg, - }); - } -} - -// ─── Watch Registry ───────────────────────────────────────────────────────── - -/// Tracks watch relationships between actors. -/// -/// Shared across workers via `Arc>`. Contention is negligible -/// because watch/unwatch operations are rare relative to message sends. -pub(crate) struct WatchRegistry { - /// target → set of watchers awaiting death notification - watchers: HashMap>, - /// watcher → set of targets it's watching (reverse index for cleanup) - watching: HashMap>, -} - -impl WatchRegistry { - pub fn new() -> Self { - Self { - watchers: HashMap::new(), - watching: HashMap::new(), - } - } - - pub fn watch(&mut self, watcher: ActorAddress, target: ActorAddress) { - self.watchers.entry(target).or_default().insert(watcher); - self.watching.entry(watcher).or_default().insert(target); - } - - pub fn unwatch(&mut self, watcher: ActorAddress, target: ActorAddress) { - if let Some(set) = self.watchers.get_mut(&target) { - set.remove(&watcher); - if set.is_empty() { - self.watchers.remove(&target); - } - } - if let Some(set) = self.watching.get_mut(&watcher) { - set.remove(&target); - if set.is_empty() { - self.watching.remove(&watcher); - } - } - } - - /// Called when an actor dies. Returns (watcher_addr, ActorExited) pairs. - pub fn notify_death( - &mut self, - target: ActorAddress, - reason: ExitReason, - ) -> Vec<(ActorAddress, ActorExited)> { - let notification = ActorExited { - addr: target, - reason, - }; - let mut result = Vec::new(); - - if let Some(watcher_set) = self.watchers.remove(&target) { - for watcher in &watcher_set { - result.push((*watcher, notification.clone())); - // clean up reverse index - if let Some(set) = self.watching.get_mut(watcher) { - set.remove(&target); - if set.is_empty() { - self.watching.remove(watcher); - } - } - } - } - - result - } - - /// Called when a watcher itself dies. Cleans up all its watching entries. - pub fn cleanup_watcher(&mut self, watcher: &ActorAddress) { - if let Some(targets) = self.watching.remove(watcher) { - for target in targets { - if let Some(set) = self.watchers.get_mut(&target) { - set.remove(watcher); - if set.is_empty() { - self.watchers.remove(&target); - } - } - } - } - } - - /// Check if a target has any watchers registered. - pub fn has_watchers(&self, target: &ActorAddress) -> bool { - self.watchers.get(target).is_some_and(|s| !s.is_empty()) } } @@ -208,8 +49,8 @@ pub(crate) struct Worker { stats: Arc, /// Reusable scratch buffer for building per-actor snapshots. snapshot_buf: Vec, - /// Per-worker tick-counting timer wheel. - timers: TimerWheel, + /// Per-worker extension (e.g., timer wheel). Created by RuntimeExtension factory. + pub(crate) worker_ext: Option>, } impl Worker { @@ -228,19 +69,15 @@ impl Worker { spawn_rx, stats, snapshot_buf: Vec::new(), - timers: TimerWheel::new(), + worker_ext: None, } } /// Run one iteration of the worker loop. Returns `true` if any work was done. - pub(crate) fn tick_once(&mut self, tc: &TickContext) -> bool { - #[cfg(feature = "tracing")] - let _span = tracing::trace_span!("worker.tick", worker_id = self.id.0).entered(); - + /// Drain the spawn queue, inserting new actors into the pool. + /// Used in phases 1 and 4 of tick_once. + fn drain_spawns(&mut self) -> bool { let mut did_work = false; - let t0 = Instant::now(); - - // 1. Drain spawn queue → add actors to pool #[cfg(feature = "tracing")] let mut spawn_count: usize = 0; while let Some((addr, actor)) = self.spawn_rx.try_recv() { @@ -253,6 +90,68 @@ impl Worker { if spawn_count > 0 { tracing::debug!(worker_id = self.id.0, count = spawn_count, "worker.spawns_drained"); } + did_work + } + + /// Phase 7: clean up dead actors, deliver death notifications, GC extension state. + fn cleanup_dead_actors(&mut self, tc: &TickContext) -> bool { + let cleanup_pending: RefCell)>> = + RefCell::new(Vec::new()); + let cleanup_stops: RefCell> = RefCell::new(Vec::new()); + let cleanup_requests: RefCell>> = RefCell::new(Vec::new()); + let dead = { + let cleanup_ctx = WorkerContext { + worker_id: self.id, + tc, + pending_local: &cleanup_pending, + stop_requests: &cleanup_stops, + worker_requests: &cleanup_requests, + stats: &self.stats, + }; + self.pool.cleanup_dead(&cleanup_ctx) + }; + + let had_dead = !dead.is_empty(); + if had_dead { + for &(addr, _) in &dead { + tc.address_map.remove(&addr); + } + + if let Some(ext) = tc.extension { + let notifications = ext.on_actor_death(&dead); + let dead_addrs: Vec<_> = dead.iter().map(|(a, _)| *a).collect(); + ext.cleanup_dead(&dead_addrs); + for (dest, msg) in notifications { + route_to_pool_or_remote(&mut self.pool, tc, dest, msg); + } + } + + self.stats.num_actors.store(self.pool.len(), Ordering::Relaxed); + } + + // Deliver any messages sent during on_stop callbacks + for (addr, msg) in cleanup_pending.into_inner() { + self.pool.deliver(&addr, msg); + } + + // GC per-worker extension state for dead actors + if let Some(ext) = &mut self.worker_ext { + let dead_addrs: Vec = dead.iter().map(|(a, _)| *a).collect(); + ext.gc_dead(&dead_addrs); + } + + had_dead + } + + pub(crate) fn tick_once(&mut self, tc: &TickContext) -> bool { + #[cfg(feature = "tracing")] + let _span = tracing::trace_span!("worker.tick", worker_id = self.id.0).entered(); + + let mut did_work = false; + let t0 = Instant::now(); + + // 1. Drain spawn queue → add actors to pool + did_work |= self.drain_spawns(); let t1 = Instant::now(); // 2. Drain transfer queue → deliver envelopes to actors @@ -264,24 +163,12 @@ impl Worker { } let t2 = Instant::now(); - // 2.5. Fire due timers → deliver to mailboxes before tick_all - let timer_msgs = self.timers.fire(); - for (dest, msg) in timer_msgs { - if self.pool.contains(&dest) { - // Same-worker: deliver directly to actor's mailbox - self.pool.deliver(&dest, msg); - } else { - // Inbox or cross-worker: route through address map / inbox registry - match tc.address_map.lookup(&dest) { - Some(wid) => { - tc.transfer_txs[wid.as_usize()].send(Envelope::new(dest, msg)); - crate::runtime::notify_worker(tc.worker_threads, wid.as_usize()); - } - None => { - let _ = tc.inbox_registry.try_deliver(dest, msg); - } - } - } + // 2.5. Fire per-worker extension (e.g., timers) → deliver before tick_all + let ext_msgs: Vec<_> = self.worker_ext.as_mut() + .map(|ext| ext.on_tick()) + .unwrap_or_default(); + for (dest, msg) in ext_msgs { + route_to_pool_or_remote(&mut self.pool, tc, dest, msg); did_work = true; } @@ -289,20 +176,19 @@ impl Worker { let pending_local: RefCell)>> = RefCell::new(Vec::new()); let stop_requests: RefCell> = RefCell::new(Vec::new()); - let timer_requests: RefCell> = RefCell::new(Vec::new()); + let worker_requests: RefCell>> = RefCell::new(Vec::new()); let processed; - let deaths; { let worker_ctx = WorkerContext { worker_id: self.id, tc, pending_local: &pending_local, stop_requests: &stop_requests, - timer_requests: &timer_requests, + worker_requests: &worker_requests, stats: &self.stats, }; - (processed, deaths) = self.pool.tick_all(&worker_ctx, &self.stats, tc.config.actor_message_budget, &stop_requests); + processed = self.pool.tick_all(&worker_ctx, &self.stats, tc.config.actor_message_budget, &stop_requests); if processed > 0 { did_work = true; } @@ -320,10 +206,7 @@ impl Worker { // 4. Drain spawn queue again — actors spawned during step 3 // must be in the pool before pending_local delivery. - while let Some((addr, actor)) = self.spawn_rx.try_recv() { - self.pool.insert(addr, actor); - did_work = true; - } + did_work |= self.drain_spawns(); let t4 = Instant::now(); // 5. Drain pending_local buffer → deliver to local actors @@ -335,48 +218,10 @@ impl Worker { self.pool.deliver(&addr, msg); } - // 5.5. Process timer requests from handlers - for request in timer_requests.into_inner() { - match request { - TimerRequest::Once { dest, msg, ticks } => { - self.timers.add_once(dest, msg, ticks); - } - TimerRequest::Interval { dest, msg, period } => { - self.timers.add_interval(dest, msg, period); - } - } - } - - // 5b. Process actor deaths → deliver ActorExited to watchers - if !deaths.is_empty() { - did_work = true; - if let Some(registry) = &tc.watch_registry { - let mut reg = registry.lock().unwrap(); - for (dead_addr, reason) in deaths { - let notifications = reg.notify_death(dead_addr, reason); - for (watcher_addr, msg) in notifications { - // Deliver ActorExited as a normal message via the address map - match tc.address_map.lookup(&watcher_addr) { - Some(wid) if wid == self.id => { - self.pool.deliver(&watcher_addr, Box::new(msg)); - } - Some(wid) => { - tc.transfer_txs[wid.as_usize()] - .send(Envelope::new(watcher_addr, Box::new(msg))); - } - None => { - // Watcher not in address map — may be an inbox or remote. - // Try inbox registry as best effort. - let _ = tc.inbox_registry.try_deliver( - watcher_addr, - Box::new(msg), - ); - } - } - } - // Clean up the dead actor's own watches (things it was watching) - reg.cleanup_watcher(&dead_addr); - } + // 5.5. Process worker extension requests from handlers (e.g., timer scheduling) + if let Some(ext) = &mut self.worker_ext { + for request in worker_requests.into_inner() { + ext.handle_request(request); } } @@ -427,67 +272,7 @@ impl Worker { } // 7. Clean up poisoned and stopping actors - // on_stop() may send messages, so provide a fresh pending_local buffer. - let cleanup_pending: RefCell)>> = - RefCell::new(Vec::new()); - let cleanup_stops: RefCell> = RefCell::new(Vec::new()); - let cleanup_timers: RefCell> = RefCell::new(Vec::new()); - let dead = { - let cleanup_ctx = WorkerContext { - worker_id: self.id, - tc, - pending_local: &cleanup_pending, - stop_requests: &cleanup_stops, - timer_requests: &cleanup_timers, - stats: &self.stats, - }; - let dead = self.pool.cleanup_dead(&cleanup_ctx); - if !dead.is_empty() { - for &(addr, _) in &dead { - tc.address_map.remove(&addr); - } - - if let Some(ext) = tc.extension { - // Get death notifications (monitors) before cleaning up state - let notifications = ext.on_actor_death(&dead); - - // Clean up extension state (names, groups, dead watcher monitors) - let dead_addrs: Vec<_> = dead.iter().map(|(a, _)| *a).collect(); - ext.cleanup_dead(&dead_addrs); - - // Deliver Down notifications through normal routing - for (dest, msg) in notifications { - if self.pool.contains(&dest) { - self.pool.deliver(&dest, msg); - } else { - match tc.address_map.lookup(&dest) { - Some(wid) => { - tc.transfer_txs[wid.as_usize()] - .send(Envelope::new(dest, msg)); - crate::runtime::notify_worker(tc.worker_threads, wid.as_usize()); - } - None => { - let _ = tc.inbox_registry.try_deliver(dest, msg); - } - } - } - } - } - - // Re-publish num_actors after cleanup so stats reflect removal - self.stats.num_actors.store(self.pool.len(), Ordering::Relaxed); - did_work = true; - } - dead - }; - // Deliver any messages sent during on_stop callbacks - for (addr, msg) in cleanup_pending.into_inner() { - self.pool.deliver(&addr, msg); - } - - // GC orphaned interval timers for actors that were just removed - let dead_addrs: Vec = dead.iter().map(|(a, _)| *a).collect(); - self.timers.gc_dead_intervals(&dead_addrs); + did_work |= self.cleanup_dead_actors(tc); did_work } @@ -531,7 +316,7 @@ struct WorkerContext<'a> { tc: &'a TickContext<'a>, pending_local: &'a RefCell)>>, stop_requests: &'a RefCell>, - timer_requests: &'a RefCell>, + worker_requests: &'a RefCell>>, stats: &'a WorkerStats, } @@ -568,36 +353,13 @@ impl ContextInner for WorkerContext<'_> { self.stop_requests.borrow_mut().push(addr); } - fn schedule_timer(&self, request: TimerRequest) { - self.timer_requests.borrow_mut().push(request); + fn post_worker_request(&self, request: Box) { + self.worker_requests.borrow_mut().push(request); } fn extension(&self) -> Option<&dyn crate::extension::RuntimeExtension> { self.tc.extension } - - fn watch(&self, watcher: ActorAddress, target: ActorAddress) { - if let Some(registry) = &self.tc.watch_registry { - // Check if target exists in the address map - if self.tc.address_map.lookup(&target).is_some() { - registry.lock().unwrap().watch(watcher, target); - } else { - // Target not found — deliver ActorExited { reason: Stopped } immediately. - // Buffer in pending_local so it arrives on next tick. - let msg = ActorExited { - addr: target, - reason: ExitReason::Stopped, - }; - self.pending_local.borrow_mut().push((watcher, Box::new(msg))); - } - } - } - - fn unwatch(&self, watcher: ActorAddress, target: ActorAddress) { - if let Some(registry) = &self.tc.watch_registry { - registry.lock().unwrap().unwatch(watcher, target); - } - } } struct ActorSlot { @@ -681,7 +443,7 @@ impl ActorPool { std::mem::replace(&mut self.drops_this_tick, 0) } - /// Tick all actors in the pool. Returns (messages_processed, newly_dead_actors). + /// Tick all actors in the pool. Returns the number of messages processed. /// /// Each actor processes up to `budget` messages per tick (0 = unlimited). /// This prevents a single hot actor from starving others on the same worker. @@ -691,9 +453,8 @@ impl ActorPool { stats: &WorkerStats, budget: usize, stop_requests: &RefCell>, - ) -> (usize, Vec<(ActorAddress, ExitReason)>) { + ) -> usize { let mut count = 0; - let mut deaths = Vec::new(); for (&addr, slot) in self.actors.iter_mut() { if slot.poisoned || slot.stopping { // Discard all messages for poisoned/stopping actors @@ -741,7 +502,6 @@ impl ActorPool { slot.stopping = true; stats.stops.fetch_add(1, Ordering::Relaxed); slot.mailbox.clear(); - deaths.push((addr, ExitReason::Stopped)); #[cfg(feature = "tracing")] tracing::info!(actor_addr = %addr, "actor.stop_requested"); break; @@ -762,7 +522,6 @@ impl ActorPool { tracing::error!(actor_addr = %addr, "actor.panicked"); slot.poisoned = true; slot.mailbox.clear(); - deaths.push((addr, ExitReason::Panicked)); break; } Ok(Some(type_name)) => { @@ -785,7 +544,6 @@ impl ActorPool { slot.stopping = true; stats.stops.fetch_add(1, Ordering::Relaxed); slot.mailbox.clear(); - deaths.push((addr, ExitReason::Stopped)); break; } } @@ -795,7 +553,7 @@ impl ActorPool { } } } - (count, deaths) + count } pub fn len(&self) -> usize { 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 new file mode 100644 index 0000000..e794262 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,255 @@ +// Shared types and helpers for runtime test files. + +#![allow(dead_code, unused_imports)] + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +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, CtxWatching, RestartPolicy, Router, + RoutingStrategy, RuntimeGroups, RuntimeNaming, RuntimeWatching, StdExtension, Supervisor, + SupervisorStrategy, +}; + +// ── Messages ──────────────────────────────────────────────────────────────── + +#[derive(Clone)] +pub struct Ping { + pub reply_to: ActorAddress, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Pong; + +#[derive(Clone)] +pub struct Increment { + pub reply_to: ActorAddress, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Count(pub usize); + +#[derive(Clone)] +pub struct Forward { + pub value: usize, + pub reply_to: ActorAddress, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Done(pub usize); + +/// Ask an actor for its own address. +#[derive(Clone)] +pub struct WhoAreYou { + pub reply_to: ActorAddress, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct MyAddr(pub ActorAddress); + +#[derive(Clone)] +pub struct PanicMsg; + +/// Tells FanOutActor to distribute work. +#[derive(Clone)] +pub struct FanOut { + pub count: usize, + pub reply_to: ActorAddress, +} + +/// Message used in the chain test -- carries remaining hops and final reply address. +#[derive(Clone)] +pub struct ChainMsg { + pub remaining: usize, + pub depth: usize, + pub reply_to: ActorAddress, +} + +// ── Actors ────────────────────────────────────────────────────────────────── + +/// Replies Pong to every Ping. Stateless. +pub struct PingPongActor; + +impl ActorInterface for PingPongActor { + type Incoming = Ping; + type Response = Pong; + fn handle(&mut self, ctx: &Ctx, msg: Ping) { + let _ = ctx.send(msg.reply_to, Pong); + } +} + +/// Counts Increment messages, replies Count(n) after each. +pub struct CounterActor { + pub count: usize, +} + +impl ActorInterface for CounterActor { + type Incoming = Increment; + type Response = Count; + fn handle(&mut self, ctx: &Ctx, msg: Increment) { + self.count += 1; + let _ = ctx.send(msg.reply_to, Count(self.count)); + } +} + +/// Replies Done(value * 2). +pub struct DoubleActor; + +impl ActorInterface for DoubleActor { + type Incoming = Forward; + type Response = Done; + fn handle(&mut self, ctx: &Ctx, msg: Forward) { + let _ = ctx.send(msg.reply_to, Done(msg.value * 2)); + } +} + +/// Spawns a DoubleActor child and forwards the work to it. +pub struct DelegatorActor; + +impl ActorInterface for DelegatorActor { + 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 }); + } +} + +/// Spawns a child chain: each level spawns the next until remaining == 0, +/// then the leaf replies Done(depth). +pub struct ChainActor; + +impl ActorInterface for ChainActor { + type Incoming = ChainMsg; + type Response = Done; + fn handle(&mut self, ctx: &Ctx, msg: ChainMsg) { + if msg.remaining == 0 { + let _ = ctx.send(msg.reply_to, Done(msg.depth)); + } else { + let child = ctx.spawn(ChainActor).unwrap(); + let _ = ctx.send( + child, + ChainMsg { + remaining: msg.remaining - 1, + depth: msg.depth + 1, + reply_to: msg.reply_to, + }, + ); + } + } +} + +/// Spawns N DoubleActor children, sends Forward { value: i, reply_to } to each. +pub struct FanOutActor; + +impl ActorInterface for FanOutActor { + type Incoming = FanOut; + type Response = (); + fn handle(&mut self, ctx: &Ctx, msg: FanOut) { + for i in 1..=msg.count { + let child = ctx.spawn(DoubleActor).unwrap(); + let _ = ctx.send(child, Forward { value: i, reply_to: msg.reply_to }); + } + } +} + +/// Replies with its own address. +pub struct SelfAddrActor; + +impl ActorInterface for SelfAddrActor { + type Incoming = WhoAreYou; + type Response = MyAddr; + fn handle(&mut self, ctx: &Ctx, msg: WhoAreYou) { + let _ = ctx.send(msg.reply_to, MyAddr(ctx.self_addr())); + } +} + +/// Panics on every message. Used to test panic isolation. +pub struct PanicActor; + +impl ActorInterface for PanicActor { + type Incoming = PanicMsg; + type Response = (); + fn handle(&mut self, _ctx: &Ctx, _msg: PanicMsg) { + panic!("intentional test panic"); + } +} + +/// Increments a shared counter on each Ping. Used to observe processing from outside. +pub struct CountingPingActor { + pub counter: Arc, +} + +impl ActorInterface for CountingPingActor { + type Incoming = Ping; + type Response = Pong; + fn handle(&mut self, ctx: &Ctx, msg: Ping) { + self.counter.fetch_add(1, Ordering::SeqCst); + let _ = ctx.send(msg.reply_to, Pong); + } +} + +/// Null actor that accepts Ping but does nothing visible. +pub struct NullActor; + +impl ActorInterface for NullActor { + type Incoming = Ping; + type Response = (); + fn handle(&mut self, _ctx: &Ctx, _msg: Ping) {} +} + +/// Actor that replies with its inbox address. +pub struct InboxReplyActor; + +impl ActorInterface for InboxReplyActor { + type Incoming = Ping; + type Response = Pong; + fn handle(&mut self, ctx: &Ctx, msg: Ping) { + let _ = ctx.send(msg.reply_to, Pong); + } +} + +// ── Helpers ───────────────────────────────────────────────────────────────── + +/// Helper: construct a Runtime with StdExtension installed. +pub fn std_runtime(config: RuntimeConfig) -> Runtime { + Runtime::new(config).with_extension(Arc::new(StdExtension::new())) +} + +/// Tick up to `max` times, returning as soon as `inbox` has a message. +pub fn tick_until_recv( + rt: &Runtime, + inbox: &Inbox, + max: usize, +) -> Option { + for _ in 0..max { + rt.tick(); + if let Some(msg) = inbox.try_recv() { + return Some(msg); + } + } + 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, + inbox: &Inbox, + ticks: usize, +) -> Vec { + for _ in 0..ticks { + rt.tick(); + } + std::iter::from_fn(|| inbox.try_recv()).collect() +} 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/proptest_runtime.rs b/tests/proptest_runtime.rs index 832f9b0..96cacbe 100644 --- a/tests/proptest_runtime.rs +++ b/tests/proptest_runtime.rs @@ -8,9 +8,12 @@ use std::collections::HashMap; use proptest::prelude::*; use proptest_state_machine::{prop_state_machine, ReferenceStateMachine, StateMachineTest}; +use std::sync::Arc; + use swactor::actor::{ActorAddress, ActorInterface}; use swactor::config::{MailboxOverflow, RuntimeConfig}; use swactor::runtime::{Ctx, Inbox, Runtime}; +use swactor_std::{CtxTimers, StdExtension}; // ─── Shared Actor Types ──────────────────────────────────────────────────── @@ -141,7 +144,8 @@ proptest! { /// One-shot timer fires at exactly the right tick for any delay. #[test] fn one_shot_timer_fires_at_correct_tick(delay in 1u64..20) { - let rt = Runtime::new(RuntimeConfig::default()); + let rt = Runtime::new(RuntimeConfig::default()) + .with_extension(Arc::new(StdExtension::new())); let inbox = rt.new_inbox::().unwrap(); struct TimerActor { target: ActorAddress, delay: u64 } @@ -175,7 +179,8 @@ proptest! { /// Interval timer fires at correct periodic ticks for any period. #[test] fn interval_timer_fires_at_correct_period(period in 1u64..10) { - let rt = Runtime::new(RuntimeConfig::default()); + let rt = Runtime::new(RuntimeConfig::default()) + .with_extension(Arc::new(StdExtension::new())); let inbox = rt.new_inbox::().unwrap(); struct IntervalActor { target: ActorAddress, period: u64 } diff --git a/tests/runtime_api.rs b/tests/runtime_api.rs deleted file mode 100644 index 0a85827..0000000 --- a/tests/runtime_api.rs +++ /dev/null @@ -1,4622 +0,0 @@ -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::Arc; - -use swactor::actor::{ActorAddress, ActorInterface, Down, MonitorRef, StopReason}; -use swactor_std::{ - ChildSpec, CtxGroups, CtxMonitoring, CtxNaming, RestartPolicy, Router, RoutingStrategy, - RuntimeGroups, RuntimeNaming, StdExtension, Supervisor, SupervisorStrategy, -}; -use swactor::runtime::{Ctx, Inbox, MailboxOverflow, Runtime, RuntimeConfig}; - -/// Helper: construct a Runtime with StdExtension installed. -fn std_runtime(config: RuntimeConfig) -> Runtime { - Runtime::new(config).with_extension(Arc::new(StdExtension::new())) -} - -// ── Messages ──────────────────────────────────────────────────────────────── - -#[derive(Clone)] -struct Ping { - reply_to: ActorAddress, -} - -#[derive(Clone, Debug, PartialEq)] -struct Pong; - -#[derive(Clone)] -struct Increment { - reply_to: ActorAddress, -} - -#[derive(Clone, Debug, PartialEq)] -struct Count(usize); - -#[derive(Clone)] -struct Forward { - value: usize, - reply_to: ActorAddress, -} - -#[derive(Clone, Debug, PartialEq)] -struct Done(usize); - -/// Ask an actor for its own address. -#[derive(Clone)] -struct WhoAreYou { - reply_to: ActorAddress, -} - -#[derive(Clone, Debug, PartialEq)] -struct MyAddr(ActorAddress); - -#[derive(Clone)] -struct PanicMsg; - -/// Tells FanOutActor to distribute work. -#[derive(Clone)] -struct FanOut { - count: usize, - reply_to: ActorAddress, -} - -/// Message used in the chain test — carries remaining hops and final reply address. -#[derive(Clone)] -struct ChainMsg { - remaining: usize, - depth: usize, - reply_to: ActorAddress, -} - -// ── Actors ────────────────────────────────────────────────────────────────── - -/// Replies Pong to every Ping. Stateless. -struct PingPongActor; - -impl ActorInterface for PingPongActor { - type Incoming = Ping; - type Response = Pong; - fn handle(&mut self, ctx: &Ctx, msg: Ping) { - let _ = ctx.send(msg.reply_to, Pong); - } -} - -/// Counts Increment messages, replies Count(n) after each. -struct CounterActor { - count: usize, -} - -impl ActorInterface for CounterActor { - type Incoming = Increment; - type Response = Count; - fn handle(&mut self, ctx: &Ctx, msg: Increment) { - self.count += 1; - let _ = ctx.send(msg.reply_to, Count(self.count)); - } -} - -/// Replies Done(value * 2). -struct DoubleActor; - -impl ActorInterface for DoubleActor { - type Incoming = Forward; - type Response = Done; - fn handle(&mut self, ctx: &Ctx, msg: Forward) { - let _ = ctx.send(msg.reply_to, Done(msg.value * 2)); - } -} - -/// Spawns a DoubleActor child and forwards the work to it. -struct DelegatorActor; - -impl ActorInterface for DelegatorActor { - 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 }); - } -} - -/// Spawns a child chain: each level spawns the next until remaining == 0, -/// then the leaf replies Done(depth). -struct ChainActor; - -impl ActorInterface for ChainActor { - type Incoming = ChainMsg; - type Response = Done; - fn handle(&mut self, ctx: &Ctx, msg: ChainMsg) { - if msg.remaining == 0 { - let _ = ctx.send(msg.reply_to, Done(msg.depth)); - } else { - let child = ctx.spawn(ChainActor).unwrap(); - let _ = ctx.send( - child, - ChainMsg { - remaining: msg.remaining - 1, - depth: msg.depth + 1, - reply_to: msg.reply_to, - }, - ); - } - } -} - -/// Spawns N DoubleActor children, sends Forward { value: i, reply_to } to each. -struct FanOutActor; - -impl ActorInterface for FanOutActor { - type Incoming = FanOut; - type Response = (); - fn handle(&mut self, ctx: &Ctx, msg: FanOut) { - for i in 1..=msg.count { - let child = ctx.spawn(DoubleActor).unwrap(); - let _ = ctx.send(child, Forward { value: i, reply_to: msg.reply_to }); - } - } -} - -/// Replies with its own address. -struct SelfAddrActor; - -impl ActorInterface for SelfAddrActor { - type Incoming = WhoAreYou; - type Response = MyAddr; - fn handle(&mut self, ctx: &Ctx, msg: WhoAreYou) { - let _ = ctx.send(msg.reply_to, MyAddr(ctx.self_addr())); - } -} - -/// Panics on every message. Used to test panic isolation. -struct PanicActor; - -impl ActorInterface for PanicActor { - type Incoming = PanicMsg; - type Response = (); - fn handle(&mut self, _ctx: &Ctx, _msg: PanicMsg) { - panic!("intentional test panic"); - } -} - -/// Increments a shared counter on each Ping. Used to observe processing from outside. -struct CountingPingActor { - counter: Arc, -} - -impl ActorInterface for CountingPingActor { - type Incoming = Ping; - type Response = Pong; - fn handle(&mut self, ctx: &Ctx, msg: Ping) { - self.counter.fetch_add(1, Ordering::SeqCst); - let _ = ctx.send(msg.reply_to, Pong); - } -} - -// ── Helpers ───────────────────────────────────────────────────────────────── - -/// Tick up to `max` times, returning as soon as `inbox` has a message. -fn tick_until_recv( - rt: &Runtime, - inbox: &Inbox, - max: usize, -) -> Option { - for _ in 0..max { - rt.tick(); - if let Some(msg) = inbox.try_recv() { - return Some(msg); - } - } - None -} - -/// Tick `n` times, then drain all messages from the inbox. -fn tick_and_drain( - rt: &Runtime, - inbox: &Inbox, - ticks: usize, -) -> Vec { - for _ in 0..ticks { - rt.tick(); - } - std::iter::from_fn(|| inbox.try_recv()).collect() -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 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() { - // Given a CounterActor that receives 3 messages: Increment, PanicMsg, Increment - // We need an actor that can handle both — so we use PanicActor for the panic - // and a separate CounterActor that continues working. - // - // Specifically: a PanicActor receives one PanicMsg, panics, then future - // PanicMsgs should be silently discarded (actor is poisoned). - 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 -// ═══════════════════════════════════════════════════════════════════════════ - -// ── 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"); - } -} - -// ── 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 - // (the message was dequeued and attempted — it "went through" the system) - 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. - // Each self-send goes through pending_local → next tick's mailbox, - // so it needs at least 11 ticks (1 initial + 10 bounces). - 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. No test is needed. It is not obvious what to do -// about failed messages. Because this is going to be distributed, we cannot rely on delivery always -// succeeeding. -#[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 (overflow on every 2nd message) - 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 (almost all hit the overflow queue) - 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 (tests no stack overflow from recursive tick_all) - 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 - // (sends will fail because actor is removed from address map) - 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 (spawn queue drained before transfer) - 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 (needs a dummy inbox for replies) - 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) - // After 1 tick: hot actor processes 64, cold actor processes 1 - 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 (Count(1), Count(2), ..., Count(100)) - 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 that will spawn actors across workers - 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"); - // With park_timeout + unpark, the latency should be well under 100ms - // (old sleep-based approach could have up to 1ms delay per the default max) - assert!( - latency.as_millis() < 100, - "wake-from-park latency should be low, was {:?}", - latency - ); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Competitor Bug-Inspired Tests -// (from analyzing ractor, actix, kameo bug histories) -// ═══════════════════════════════════════════════════════════════════════════ - -#[test] -fn stats_snapshot_is_read_only() { - // Inspired by ractor #310: get_children() was destructive (cleared on read). - // Verify that calling stats() multiple times returns consistent data. - 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(); - - // All three snapshots should report the same actor count - 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() { - // Verify that taking stats snapshots doesn't slow down or break message 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(); // should not affect processing - } - - 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() { - // Verify that shutdown unparks all workers so they exit promptly. - 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(); - - // Workers should exit quickly (well under 1 second) - 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() { - // Inspired by kameo #185: messages not delivered during startup. - // Verify that send_to works correctly after run() is called. - 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() { - // Inspired by actix #515: send bypassing mailbox size. - // Verify that self-sends (pending_local) don't bypass the message budget. - // The SelfSendActor sends to itself; each self-send goes through pending_local - // and appears in the mailbox on the next tick. The budget should still apply. - 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. - // The self-send chain should take several ticks to complete. - 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" - ); -} - -// ── 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. - // We can't assert exact distribution due to timing, but verify - // actors are distributed across workers (not all on one). - 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). - // Round-robin gives exactly 25 each. - 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). - // With DropOldest and capacity 5, messages 0-4 should be dropped as 5-9 arrive. - 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. -/// count resets to 0 on fresh construction, so restarts reset the counter. -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)"); -} - -// ── 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); - } -} - -// ── 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"); -} - -// ── 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) - // Timer fires when current_tick >= fire_at, so after tick 4 completes - 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. - // No crash, no leak — just verifying it doesn't panic. - 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"); -} - -// ── 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(); - - // Set up a monitor from an actor that forwards Down to the inbox. - // The watcher is an actor, but the final recipient is the inbox. - 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 different reply_to for each — but since it's cloned, - // all members get the same message. Use 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 (because the published Ping had inbox1 as reply_to) - 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 - // Broadcaster also gets the Ping but it expects BroadcastCmd, so type mismatch (silent) - - // 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 (message goes to transfer queue) or fail (addr removed) - // Either way, no response will come - 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()); -} - -// ─── Supervisor Tests ────────────────────────────────────────────────────── - -/// 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 - - // Send message to child — need to discover child address. - // We'll use the address map 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 (1 initial + 2 restarts = max, 3rd restart triggers meltdown) - for _ in 0..3 { - // Find current child - 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(); } // wait for stops, Downs, restarts, on_starts - - // All 3 children should be alive with NEW addresses (old ones are dead) - let stats = rt.stats(); - assert_eq!(stats.workers[0].num_actors, 4); // sup + 3 new children - - // The old addresses for b and c should be gone (they were stopped and re-created) - // New names should be re-registered - 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 (children after b in spec order). -/// 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 - // supervisor: Down(b) → RestForOne → stops c (rest after b), then restarts b+c - 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 (not affected by RestForOne) - 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 (it was stopped and re-created) - 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 (not reverse). -#[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 (graceful stop triggers OneForAll) - 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: stop → Down → supervisor stops other → Down → restart all - 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() { - // Given a round-robin router with 3 workers - // When we send 6 messages - // Then each worker should receive exactly 2 messages - 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() { - // Given a broadcast router with 3 workers - // When we send 1 message - // Then all 3 workers should receive it - 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() { - // Given a random router with 3 workers - // When we send 30 messages - // Then at least 2 different workers should have received messages - 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(); - // With 30 messages across 3 workers, probability of all going to 1 is vanishingly small - assert!(unique.len() >= 2, "expected at least 2 workers used, got {}", unique.len()); -} - -#[test] -fn router_replaces_dead_worker() { - // Given a router with 3 workers - // When one worker panics - // Then the router should spawn a replacement and messages continue to be delivered - 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); - // Only the first worker panics on its first message - 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 (round-robin starts at 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 — stats should show router + 3 workers - assert_eq!(rt.stats().workers[0].num_actors, 4); -} - -#[test] -fn router_meltdown_after_max_restarts() { - // Given a router with max_restarts=2 - // When 3 workers die in succession - // Then the router should stop itself - 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() { - // Given a running router with 3 workers - // When the router is stopped - // Then all workers should also be stopped - 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() { - // Given a broadcast router - // When we send 5 messages to 3 workers - // Then total received = 5 * 3 = 15 - 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); -} - -// ── 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 - // (each tick processes one hop via pending_local delivery) - 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_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/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 0ee45ce..0000000 --- a/tests/watch_api.rs +++ /dev/null @@ -1,378 +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}; - -// ── 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 single_thread_config() -> RuntimeConfig { - RuntimeConfig { - num_threads: 1, - ..RuntimeConfig::default() - } -} - -// ── 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 = Runtime::new(single_thread_config()); - 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 = Runtime::new(single_thread_config()); - 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 watch on an address that was never spawned, -/// then the watcher receives ActorExited { reason: Stopped }. -#[test] -fn watch_nonexistent_actor_delivers_stopped() { - let rt = Runtime::new(single_thread_config()); - let (watcher_actor, state) = ExitWatcher::new(); - - let watcher = rt.spawn(watcher_actor).unwrap(); - - let nonexistent = ActorAddress::new_random(); - rt.send_to(watcher, WatcherCmd::WatchThis(nonexistent)).unwrap(); - tick_n(&rt, 5); - - assert_eq!(state.count(), 1, "should receive ActorExited for non-existent target"); - assert_eq!(state.last_reason(), Some(ExitReason::Stopped)); - assert_eq!(state.last_addr(), Some(nonexistent)); -} - -/// 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 = Runtime::new(single_thread_config()); - - let target = rt.spawn(PanicOnCommand).unwrap(); - let (watcher_actor, _state) = ExitWatcher::new(); - let watcher = rt.spawn(watcher_actor).unwrap(); - - // Watch - rt.send_to(watcher, WatcherCmd::WatchThis(target)).unwrap(); - tick_n(&rt, 3); - - // Kill the watcher first (send it a type-mismatched panic msg directly) - // Actually, ExitWatcher doesn't panic. Use Runtime-level watch + PanicOnCommand. - let rt2 = Runtime::new(single_thread_config()); - let target2 = rt2.spawn(PanicOnCommand).unwrap(); - let watcher2 = rt2.spawn(PanicOnCommand).unwrap(); - - use swactor::actor::ContextInner; - rt2.watch(watcher2, target2); - tick_n(&rt2, 3); - - // Kill watcher first - rt2.send_to(watcher2, PanicMsg).unwrap(); - tick_n(&rt2, 5); - - // Kill target — should not crash - rt2.send_to(target2, PanicMsg).unwrap(); - tick_n(&rt2, 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 = Runtime::new(single_thread_config()); - 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 = Runtime::new(single_thread_config()); - 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 = Runtime::new(single_thread_config()); - 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 = Runtime::new(single_thread_config()); - 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 - - use swactor::actor::ContextInner; - 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)); -} - -/// Runtime-level watch on non-existent address delivers Stopped. -#[test] -fn runtime_level_watch_nonexistent_delivers_stopped() { - let rt = Runtime::new(single_thread_config()); - let (watcher_actor, state) = ExitWatcher::new(); - - let watcher = rt.spawn(watcher_actor).unwrap(); - tick_n(&rt, 2); - - let fake = ActorAddress::new_random(); - use swactor::actor::ContextInner; - rt.watch(watcher, fake); - - tick_n(&rt, 5); - - assert_eq!(state.count(), 1, "watching non-existent from runtime should deliver Stopped"); - assert_eq!(state.last_reason(), Some(ExitReason::Stopped)); -} - -/// 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 = Runtime::new(single_thread_config()); - 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"); -} diff --git a/tools/fn_complexity.py b/tools/fn_complexity.py new file mode 100755 index 0000000..84212cb --- /dev/null +++ b/tools/fn_complexity.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""Analyze per-function complexity metrics for Rust source files. + +Reports: function name, line count, max nesting depth, and file location. +Sorted by line count (descending) to surface the largest functions first. + +Usage: + python3 tools/fn_complexity.py src/worker.rs + python3 tools/fn_complexity.py src/ # recurse into directory + python3 tools/fn_complexity.py src/ --json # JSON output + python3 tools/fn_complexity.py src/ --min-lines 20 # filter small fns +""" + +import argparse +import json +import os +import re +import sys +from dataclasses import dataclass, asdict +from pathlib import Path + + +@dataclass +class FnMetric: + file: str + name: str + start_line: int + end_line: int + lines: int + max_depth: int + has_unsafe: bool + + @property + def location(self) -> str: + return f"{self.file}:{self.start_line}" + + +# Matches fn declarations (free functions, methods, trait impls) +FN_PATTERN = re.compile( + r'^\s*(?:pub(?:\(crate\))?\s+)?(?:async\s+)?fn\s+(\w+)' +) + +# Matches impl blocks to qualify method names +IMPL_PATTERN = re.compile( + r'^\s*impl(?:<[^>]*>)?\s+(?:(\w+(?:<[^>]*>)?)\s+for\s+)?(\w+)' +) + + +def analyze_file(path: str) -> list[FnMetric]: + """Parse a single Rust file and extract function metrics.""" + with open(path) as f: + lines = f.readlines() + + metrics = [] + current_impl = None + brace_depth = 0 + fn_stack: list[tuple[str, int, int, bool]] = [] # (name, start_line, start_depth, has_unsafe) + + for i, line in enumerate(lines, 1): + stripped = line.rstrip() + + # Track impl blocks for method qualification + impl_match = IMPL_PATTERN.match(stripped) + if impl_match and '{' in stripped: + trait_name = impl_match.group(1) + type_name = impl_match.group(2) + if trait_name: + current_impl = f"{trait_name} for {type_name}" + else: + current_impl = type_name + + # Detect function start + fn_match = FN_PATTERN.match(stripped) + if fn_match and '{' in stripped: + fn_name = fn_match.group(1) + if current_impl: + fn_name = f"{current_impl}::{fn_name}" + has_unsafe = 'unsafe' in stripped + fn_stack.append((fn_name, i, brace_depth, has_unsafe)) + + # Track brace depth + # Simple brace counting (ignores braces in strings/comments, good enough) + opens = stripped.count('{') + closes = stripped.count('}') + brace_depth += opens - closes + + # Check for unsafe blocks within functions + if fn_stack and 'unsafe' in stripped and fn_match is None: + name, start, depth, _ = fn_stack[-1] + fn_stack[-1] = (name, start, depth, True) + + # When a function's brace depth returns to entry level, it's done + while fn_stack and brace_depth <= fn_stack[-1][2]: + fn_name, start_line, _, has_unsafe = fn_stack.pop() + end_line = i + fn_lines = end_line - start_line + 1 + + # Calculate max nesting depth within this function + max_depth = 0 + local_depth = 0 + for j in range(start_line - 1, end_line): + if j < len(lines): + local_depth += lines[j].count('{') - lines[j].count('}') + max_depth = max(max_depth, local_depth) + + # Reset impl context if we've left the impl block + if brace_depth == 0: + current_impl = None + + metrics.append(FnMetric( + file=path, + name=fn_name, + start_line=start_line, + end_line=end_line, + lines=fn_lines, + max_depth=max_depth, + has_unsafe=has_unsafe, + )) + + return metrics + + +def collect_files(path: str) -> list[str]: + """Collect .rs files from a path (file or directory).""" + p = Path(path) + if p.is_file(): + return [str(p)] + elif p.is_dir(): + return sorted(str(f) for f in p.rglob('*.rs')) + else: + print(f"Error: {path} is not a file or directory", file=sys.stderr) + sys.exit(1) + + +def main(): + parser = argparse.ArgumentParser(description='Rust function complexity analyzer') + parser.add_argument('paths', nargs='+', help='Rust source files or directories') + parser.add_argument('--json', action='store_true', help='Output as JSON') + parser.add_argument('--min-lines', type=int, default=0, + help='Only show functions with at least N lines') + parser.add_argument('--top', type=int, default=0, + help='Show only the top N largest functions') + args = parser.parse_args() + + all_metrics: list[FnMetric] = [] + for path in args.paths: + for file in collect_files(path): + all_metrics.extend(analyze_file(file)) + + # Filter and sort + if args.min_lines: + all_metrics = [m for m in all_metrics if m.lines >= args.min_lines] + all_metrics.sort(key=lambda m: m.lines, reverse=True) + if args.top: + all_metrics = all_metrics[:args.top] + + if args.json: + output = [asdict(m) for m in all_metrics] + print(json.dumps(output, indent=2)) + else: + # Summary stats + if all_metrics: + total_fns = len(all_metrics) + avg_lines = sum(m.lines for m in all_metrics) / total_fns + max_fn = all_metrics[0] + + print(f"Functions: {total_fns}, avg lines: {avg_lines:.1f}, " + f"largest: {max_fn.name} ({max_fn.lines} lines)") + print() + + # Table output + print(f"{'Lines':>5} {'Depth':>5} {'Location':<45} {'Function'}") + print(f"{'─'*5} {'─'*5} {'─'*45} {'─'*40}") + for m in all_metrics: + loc = f"{m.file}:{m.start_line}" + unsafe_marker = " [unsafe]" if m.has_unsafe else "" + print(f"{m.lines:>5} {m.max_depth:>5} {loc:<45} {m.name}{unsafe_marker}") + + +if __name__ == '__main__': + main() diff --git a/tools/loc_analysis.py b/tools/loc_analysis.py new file mode 100755 index 0000000..c7e6de2 --- /dev/null +++ b/tools/loc_analysis.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Analyze line-of-code breakdown for Rust source files. + +Reports: logic, comments, blank, and string-literal lines per file. +Helps track code reduction progress and identify embedded content. + +Usage: + python3 tools/loc_analysis.py src/ + python3 tools/loc_analysis.py src/ crates/std/src/ --json + python3 tools/loc_analysis.py src/ --sort-by logic +""" + +import argparse +import json +import re +import sys +from dataclasses import dataclass, asdict +from pathlib import Path + + +@dataclass +class FileMetrics: + file: str + total: int + logic: int + comment: int + blank: int + string_literal: int + + @property + def logic_pct(self) -> float: + return (self.logic / self.total * 100) if self.total else 0.0 + + +def analyze_file(path: str) -> FileMetrics: + """Count line types in a Rust source file.""" + with open(path) as f: + lines = f.readlines() + + total = len(lines) + blank = 0 + comment = 0 + string_lit = 0 + logic = 0 + + in_block_comment = False + in_raw_string = False + + for line in lines: + stripped = line.strip() + + if not stripped: + blank += 1 + continue + + # Track block comments + if in_block_comment: + comment += 1 + if '*/' in stripped: + in_block_comment = False + continue + + if stripped.startswith('/*'): + comment += 1 + if '*/' not in stripped: + in_block_comment = True + continue + + # Line comments + if stripped.startswith('//'): + comment += 1 + continue + + # Raw string literals (r#"..."#, r##"..."##, etc.) and regular strings + # Heuristic: line is predominantly a string if it's inside a raw string + # or contains a long string literal (>60 chars of quoted content) + if in_raw_string: + string_lit += 1 + if '"#' in stripped or '"##' in stripped: + in_raw_string = False + continue + + if 'r#"' in stripped or 'r##"' in stripped: + if '"#' not in stripped.split('r#"', 1)[-1] if 'r#"' in stripped else True: + in_raw_string = True + string_lit += 1 + continue + + # Heuristic: if the line has a long string literal, count it + string_content = re.findall(r'"([^"]*)"', stripped) + total_string_chars = sum(len(s) for s in string_content) + if total_string_chars > 60: + string_lit += 1 + else: + logic += 1 + + return FileMetrics( + file=path, + total=total, + logic=logic, + comment=comment, + blank=blank, + string_literal=string_lit, + ) + + +def collect_files(path: str) -> list[str]: + """Collect .rs files from a path (file or directory).""" + p = Path(path) + if p.is_file(): + return [str(p)] + elif p.is_dir(): + return sorted(str(f) for f in p.rglob('*.rs')) + else: + print(f"Error: {path} is not a file or directory", file=sys.stderr) + sys.exit(1) + + +def main(): + parser = argparse.ArgumentParser(description='Rust LOC breakdown analyzer') + parser.add_argument('paths', nargs='+', help='Rust source files or directories') + parser.add_argument('--json', action='store_true', help='Output as JSON') + parser.add_argument('--sort-by', choices=['total', 'logic', 'comment', 'string_literal'], + default='total', help='Sort column') + args = parser.parse_args() + + all_metrics: list[FileMetrics] = [] + for path in args.paths: + for file in collect_files(path): + all_metrics.extend([analyze_file(file)]) + + all_metrics.sort(key=lambda m: getattr(m, args.sort_by), reverse=True) + + if args.json: + print(json.dumps([asdict(m) for m in all_metrics], indent=2)) + else: + # Summary + totals = FileMetrics( + file="TOTAL", + total=sum(m.total for m in all_metrics), + logic=sum(m.logic for m in all_metrics), + comment=sum(m.comment for m in all_metrics), + blank=sum(m.blank for m in all_metrics), + string_literal=sum(m.string_literal for m in all_metrics), + ) + + print(f"Files: {len(all_metrics)}, Total: {totals.total}, " + f"Logic: {totals.logic} ({totals.logic_pct:.1f}%), " + f"Comment: {totals.comment}, Blank: {totals.blank}, " + f"Strings: {totals.string_literal}") + print() + + print(f"{'Total':>6} {'Logic':>6} {'Cmt':>5} {'Blank':>5} {'Str':>5} {'%Logic':>6} {'File'}") + print(f"{'─'*6} {'─'*6} {'─'*5} {'─'*5} {'─'*5} {'─'*6} {'─'*45}") + for m in all_metrics: + print(f"{m.total:>6} {m.logic:>6} {m.comment:>5} {m.blank:>5} " + f"{m.string_literal:>5} {m.logic_pct:>5.1f}% {m.file}") + print(f"{'─'*6} {'─'*6} {'─'*5} {'─'*5} {'─'*5} {'─'*6} {'─'*45}") + print(f"{totals.total:>6} {totals.logic:>6} {totals.comment:>5} {totals.blank:>5} " + f"{totals.string_literal:>5} {totals.logic_pct:>5.1f}% TOTAL") + + +if __name__ == '__main__': + main()