cfuzz #37

Merged
zacheryasc merged 13 commits from cfuzz into master 2026-02-13 15:00:54 +00:00
27 changed files with 3937 additions and 5537 deletions

View file

@ -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<dyn std::any::Any + Send> = 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);

View file

@ -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");
}
}

View file

@ -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<M: Message>(&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<M: Message>(&self, addr: ActorAddress, msg: M, period: u64);
}
impl CtxTimers for Ctx<'_> {
fn send_after_ticks<M: Message>(&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<M: Message>(&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<dyn CloneMsg>,
period,
}));
}
}
/// Group extension for [`Ctx`].
///
/// Provides `join_group`, `leave_group`, `publish`, and `group_members` via

View file

@ -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<dyn Any + Send>)> {
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<dyn Any + Send>));
}
// 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<dyn Any + Send>));
}
}
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<Box<dyn WorkerExtension>> {
Some(Box::new(TimerWheel::new()))
}
}

View file

@ -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};

View file

@ -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`,

View file

@ -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<dyn Any + Send>;
}
impl<M: Message> CloneMsg for M {
fn clone_boxed(&self) -> Box<dyn Any + Send> {
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<dyn Any + Send>,
ticks: u64,
},
/// Repeating: deliver a clone of `msg` to `dest` every `period` ticks.
Interval {
dest: ActorAddress,
msg: Box<dyn CloneMsg>,
period: u64,
},
}
// ─── Timer Wheel ────────────────────────────────────────────────────────────
struct OnceTimer {
fire_at: u64,
dest: ActorAddress,
msg: Box<dyn Any + Send>,
}
struct IntervalTimer {
next_fire: u64,
period: u64,
dest: ActorAddress,
msg: Box<dyn CloneMsg>,
}
/// 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<OnceTimer>,
interval_timers: Vec<IntervalTimer>,
}
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<dyn Any + Send>)> {
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<dyn Any + Send>, ticks: u64) {
self.once_timers.push(OnceTimer {
fire_at: self.current_tick + ticks,
dest,
msg,
});
}
fn add_interval(&mut self, dest: ActorAddress, msg: Box<dyn CloneMsg>, 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<dyn Any + Send>)> {
self.fire()
}
fn handle_request(&mut self, request: Box<dyn Any + Send>) {
if let Ok(req) = request.downcast::<TimerRequest>() {
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);
}
}

View file

@ -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<WatchState>,
}
struct WatchState {
/// target → set of watchers awaiting death notification
watchers: HashMap<ActorAddress, HashSet<ActorAddress>>,
/// watcher → set of targets it's watching (reverse index for cleanup)
watching: HashMap<ActorAddress, HashSet<ActorAddress>>,
}
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);
}
}
}
}
}
}

View file

@ -16,11 +16,11 @@ messages.
│ │ │ │
│ │ address_map: Arc<AddressMap> -- actor -> worker lookup │ │
│ │ inbox_registry: Arc<InboxRegistry> -- external inbox delivery │ │
│ │ name_registry: Arc<NameRegistry> -- name -> address lookup │ │
│ │ monitor_registry: Arc<MonitorRegistry> -- death watch subscripts │ │
│ │ group_registry: Arc<GroupRegistry> -- pub-sub actor groups │ │
│ │ placement: Placement -- load-aware worker picker │ │
│ │ worker_stats: Vec<Arc<WorkerStats>> -- atomic stat counters │ │
│ │ extension: Option<Arc<dyn RuntimeExtension>> │ │
│ │ (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<ActorAddress, Error> │ │
│ │ ctx.spawn_named(name, actor) -> Result<ActorAddress, Error> │ │
│ │ ctx.spawn_restartable(a, f, max) -> Result<ActorAddress, Error> │ │
│ │ ctx.stop_self() │ │
│ │ ctx.stop_actor(addr) -> Result<(), Error> │ │
│ │ ctx.where_is(name) -> Option<ActorAddress> │ │
│ │ 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<ActorAddress> │ │
│ │ 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<Runtime>` and the thread `JoinHandle`s.

View file

@ -48,10 +48,12 @@
│ │ │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ TimerWheel ────────────────────────────────────────────────────┐ │
│ │ current_tick: u64 │ │
│ │ once_timers: Vec<OnceTimer> -- fire_at, dest, msg │ │
│ │ interval_timers: Vec<IntervalTimer> -- period, dest, clone_msg │ │
│ ┌─ worker_ext: Option<Box<dyn WorkerExtension>> ─────────────────┐ │
│ │ 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<Runtime>`, 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<M> 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<Thread>] -- for unpark on send/spawn │
│ │
└────────────────────────────────────────────────────────────────────────┘
@ -147,19 +147,16 @@ Lives on `Arc<Runtime>`, 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<Runtime>`, shared read-only across all worker threads.
│ │ ││
│ │ ┌─ WorkerContext (on stack) ─────────────────────────────────┐ ││
│ │ │ implements ContextInner │ ││
│ │ │ pending_local: RefCell<Vec<(Addr, Box<Any>)>> │ ││
│ │ │ stop_requests: RefCell<Vec<ActorAddress>> │ ││
│ │ │ timer_requests: RefCell<Vec<TimerRequest>> │ ││
│ │ │ pending_local: RefCell<Vec<(Addr, Box<Any>)>> │ ││
│ │ │ stop_requests: RefCell<Vec<ActorAddress>> │ ││
│ │ │ worker_requests: RefCell<Vec<Box<dyn Any + Send>>> │ ││
│ │ └────────────────────────────────────────────────────────────┘ ││
│ │ ││
│ │ for each (addr, slot) in pool: ││
@ -214,11 +211,9 @@ Lives on `Arc<Runtime>`, 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<Runtime>`, 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<Runtime>`, 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<dyn RuntimeExtension> ──────────────────────────┐ │
│ │ StdExtension holds: NameRegistry, MonitorRegistry, │ │
│ │ GroupRegistry, WatchRegistry (accessed via downcast) │ │
│ └─────────────────────────────────┬───────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────────┴──────────────────────────────────┐ │
│ │ TickContext (borrows all above) │ │
│ └──────────────────────────┬──────────────────────────────────────────┘ │
│ │ │

View file

@ -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]

View file

@ -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);

View file

@ -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<dyn Any + Send>;
}
impl<M: Message> CloneMsg for M {
fn clone_boxed(&self) -> Box<dyn Any + Send> {
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<dyn Any + Send>,
ticks: u64,
},
/// Repeating: deliver a clone of `msg` to `dest` every `period` ticks.
Interval {
dest: ActorAddress,
msg: Box<dyn CloneMsg>,
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<dyn Any + Send>) -> Result<(), Error>;
fn spawn_any(&self, addr: ActorAddress, actor: Box<dyn AnyActor>);
/// 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<dyn Any + Send>);
/// 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<M: Message>(&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<M: Message>(&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);
}
}

View file

@ -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<Thread>],
pub(crate) watch_registry: Option<&'a Arc<Mutex<WatchRegistry>>>,
#[cfg(feature = "transport")]
pub(crate) codec_registry: Option<&'a crate::transport::CodecRegistry>,
#[cfg(feature = "transport")]

View file

@ -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<Box<dyn WorkerExtension>> {
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<dyn Any + Send>)>;
/// Process a deferred request posted during handle() via `post_worker_request`.
fn handle_request(&mut self, request: Box<dyn Any + Send>);
/// Clean up state for dead actors.
fn gc_dead(&mut self, dead: &[ActorAddress]);
}

View file

@ -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<Arc<WorkerStats>>,
stats_hook: Option<Arc<dyn StatsHook>>,
watch_registry: Arc<Mutex<WatchRegistry>>,
/// Workers available for tick(). run() drains this and moves workers to threads.
tick_workers: RefCell<Vec<Worker>>,
/// 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<dyn RuntimeExtension>) -> 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<dyn Any + Send>) {
// 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);
}
}

View file

@ -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<dyn Any + Send>,
}
struct IntervalTimer {
next_fire: u64,
period: u64,
dest: ActorAddress,
msg: Box<dyn CloneMsg>,
}
/// 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<OnceTimer>,
interval_timers: Vec<IntervalTimer>,
}
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<dyn Any + Send>)> {
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<dyn Any + Send>, 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<dyn CloneMsg>, 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<Mutex<_>>`. 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<ActorAddress, HashSet<ActorAddress>>,
/// watcher → set of targets it's watching (reverse index for cleanup)
watching: HashMap<ActorAddress, HashSet<ActorAddress>>,
}
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<WorkerStats>,
/// Reusable scratch buffer for building per-actor snapshots.
snapshot_buf: Vec<ActorSnapshot>,
/// Per-worker tick-counting timer wheel.
timers: TimerWheel,
/// Per-worker extension (e.g., timer wheel). Created by RuntimeExtension factory.
pub(crate) worker_ext: Option<Box<dyn WorkerExtension>>,
}
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<Vec<(ActorAddress, Box<dyn Any + Send>)>> =
RefCell::new(Vec::new());
let cleanup_stops: RefCell<Vec<ActorAddress>> = RefCell::new(Vec::new());
let cleanup_requests: RefCell<Vec<Box<dyn Any + Send>>> = 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<ActorAddress> = 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<Vec<(ActorAddress, Box<dyn Any + Send>)>> =
RefCell::new(Vec::new());
let stop_requests: RefCell<Vec<ActorAddress>> = RefCell::new(Vec::new());
let timer_requests: RefCell<Vec<TimerRequest>> = RefCell::new(Vec::new());
let worker_requests: RefCell<Vec<Box<dyn Any + Send>>> = 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<Vec<(ActorAddress, Box<dyn Any + Send>)>> =
RefCell::new(Vec::new());
let cleanup_stops: RefCell<Vec<ActorAddress>> = RefCell::new(Vec::new());
let cleanup_timers: RefCell<Vec<TimerRequest>> = 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<ActorAddress> = 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<Vec<(ActorAddress, Box<dyn Any + Send>)>>,
stop_requests: &'a RefCell<Vec<ActorAddress>>,
timer_requests: &'a RefCell<Vec<TimerRequest>>,
worker_requests: &'a RefCell<Vec<Box<dyn Any + Send>>>,
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<dyn Any + Send>) {
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<Vec<ActorAddress>>,
) -> (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 {

790
tests/actor_lifecycle.rs Normal file
View file

@ -0,0 +1,790 @@
//! Actor Lifecycle Tests — birth, life, death of individual actors.
//!
//! Covers: spawning, on_start, parent-child delegation, graceful stop,
//! panic isolation, dead actor cleanup, watching (ActorExited), and
//! monitoring (Down notifications).
mod common;
use common::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
// ── Local actors ────────────────────────────────────────────────────────────
/// Records lifecycle events to shared counters.
struct LifecycleActor {
started: Arc<AtomicUsize>,
stopped: Arc<AtomicUsize>,
handled: Arc<AtomicUsize>,
}
impl ActorInterface for LifecycleActor {
type Incoming = Ping;
type Response = Pong;
fn on_start(&mut self, _ctx: &Ctx) {
self.started.fetch_add(1, Ordering::Relaxed);
}
fn on_stop(&mut self, _ctx: &Ctx) {
self.stopped.fetch_add(1, Ordering::Relaxed);
}
fn handle(&mut self, ctx: &Ctx, msg: Ping) {
self.handled.fetch_add(1, Ordering::Relaxed);
let _ = ctx.send(msg.reply_to, Pong);
}
}
/// Stops itself after processing `stop_after` messages.
struct SelfStopActor {
count: usize,
stop_after: usize,
stopped: Arc<AtomicUsize>,
}
impl ActorInterface for SelfStopActor {
type Incoming = Forward;
type Response = Done;
fn on_stop(&mut self, _ctx: &Ctx) {
self.stopped.fetch_add(1, Ordering::Relaxed);
}
fn handle(&mut self, ctx: &Ctx, msg: Forward) {
self.count += 1;
let _ = ctx.send(msg.reply_to, Done(msg.value));
if self.count >= self.stop_after {
ctx.stop_self();
}
}
}
/// Sends a farewell Pong in on_stop.
struct FarewellActor {
farewell_to: ActorAddress,
}
impl ActorInterface for FarewellActor {
type Incoming = Ping;
type Response = Pong;
fn on_stop(&mut self, ctx: &Ctx) {
let _ = ctx.send(self.farewell_to, Pong);
}
fn handle(&mut self, ctx: &Ctx, msg: Ping) {
let _ = ctx.send(msg.reply_to, Pong);
}
}
/// Panics in on_start.
struct PanicOnStartActor {
handled: Arc<AtomicUsize>,
}
impl ActorInterface for PanicOnStartActor {
type Incoming = Ping;
type Response = Pong;
fn on_start(&mut self, _ctx: &Ctx) {
panic!("on_start panic");
}
fn handle(&mut self, _ctx: &Ctx, _msg: Ping) {
self.handled.fetch_add(1, Ordering::Relaxed);
}
}
/// Spawns a DoubleActor child, sends it work, then panics.
struct SpawnThenPanicActor;
impl ActorInterface for SpawnThenPanicActor {
type Incoming = Forward;
type Response = ();
fn handle(&mut self, ctx: &Ctx, msg: Forward) {
let child = ctx.spawn(DoubleActor).unwrap();
let _ = ctx.send(child, Forward { value: msg.value, reply_to: msg.reply_to });
panic!("intentional panic after spawn+send");
}
}
/// Sends a Pong reply, then panics.
struct SendThenPanicActor;
impl ActorInterface for SendThenPanicActor {
type Incoming = Ping;
type Response = Pong;
fn handle(&mut self, ctx: &Ctx, msg: Ping) {
let _ = ctx.send(msg.reply_to, Pong);
panic!("intentional panic after send");
}
}
/// Processes `remaining_good` messages then panics.
struct PanicAfterNActor {
remaining_good: usize,
counter: Arc<AtomicUsize>,
}
impl ActorInterface for PanicAfterNActor {
type Incoming = Ping;
type Response = ();
fn handle(&mut self, _ctx: &Ctx, _msg: Ping) {
if self.remaining_good == 0 {
panic!("intentional delayed panic");
}
self.remaining_good -= 1;
self.counter.fetch_add(1, Ordering::SeqCst);
}
}
/// Stops on a trigger message.
struct StopOnTrigger(Arc<AtomicUsize>);
#[derive(Clone)]
struct Trigger(bool);
impl ActorInterface for StopOnTrigger {
type Incoming = Trigger;
type Response = ();
fn handle(&mut self, ctx: &Ctx, msg: Trigger) {
self.0.fetch_add(1, Ordering::Relaxed);
if msg.0 {
ctx.stop_self();
}
}
}
/// Watches targets and counts exit notifications via on_actor_exit.
struct ExitWatcher {
exit_count: Arc<AtomicUsize>,
last_reason: Arc<std::sync::Mutex<Option<ExitReason>>>,
last_addr: Arc<std::sync::Mutex<Option<ActorAddress>>>,
}
#[derive(Clone)]
enum WatcherCmd {
WatchThis(ActorAddress),
UnwatchThis(ActorAddress),
}
impl ActorInterface for ExitWatcher {
type Incoming = WatcherCmd;
type Response = ();
fn handle(&mut self, ctx: &Ctx, msg: WatcherCmd) {
match msg {
WatcherCmd::WatchThis(target) => ctx.watch(target),
WatcherCmd::UnwatchThis(target) => ctx.unwatch(target),
}
}
fn on_actor_exit(&mut self, _ctx: &Ctx, exited: ActorExited) {
self.exit_count.fetch_add(1, Ordering::SeqCst);
*self.last_reason.lock().unwrap() = Some(exited.reason);
*self.last_addr.lock().unwrap() = Some(exited.addr);
}
}
struct WatcherState {
exit_count: Arc<AtomicUsize>,
last_reason: Arc<std::sync::Mutex<Option<ExitReason>>>,
}
impl WatcherState {
fn count(&self) -> usize {
self.exit_count.load(Ordering::SeqCst)
}
fn last_reason(&self) -> Option<ExitReason> {
self.last_reason.lock().unwrap().clone()
}
}
fn new_exit_watcher() -> (ExitWatcher, WatcherState) {
let exit_count = Arc::new(AtomicUsize::new(0));
let last_reason = Arc::new(std::sync::Mutex::new(None));
let last_addr = Arc::new(std::sync::Mutex::new(None));
let state = WatcherState {
exit_count: exit_count.clone(),
last_reason: last_reason.clone(),
};
(
ExitWatcher { exit_count, last_reason, last_addr },
state,
)
}
/// Monitors a target and forwards Down to a reply address.
struct MonitorWatcherActor {
watch_target: ActorAddress,
reply_to: ActorAddress,
mref: Option<MonitorRef>,
}
impl ActorInterface for MonitorWatcherActor {
type Incoming = Down;
type Response = ();
fn on_start(&mut self, ctx: &Ctx) {
self.mref = Some(ctx.monitor(self.watch_target));
}
fn handle(&mut self, ctx: &Ctx, msg: Down) {
ctx.send(self.reply_to, msg).unwrap();
}
}
/// Demonitors on Ping.
struct DemonitorActor {
watch_target: ActorAddress,
mref: Option<MonitorRef>,
}
impl ActorInterface for DemonitorActor {
type Incoming = Ping;
type Response = ();
fn on_start(&mut self, ctx: &Ctx) {
self.mref = Some(ctx.monitor(self.watch_target));
}
fn handle(&mut self, ctx: &Ctx, _msg: Ping) {
if let Some(mref) = self.mref.take() {
ctx.demonitor(mref);
}
}
}
/// A silent actor that does nothing (target for watching tests).
struct Sleeper;
#[derive(Clone)]
struct Noop;
impl ActorInterface for Sleeper {
type Incoming = Noop;
type Response = ();
fn handle(&mut self, _ctx: &Ctx, _msg: Noop) {}
}
// ═══════════════════════════════════════════════════════════════════════════
// Tests
// ═══════════════════════════════════════════════════════════════════════════
/// Actors are spawned, on_start fires exactly once per instance before any
/// message, then state accumulates across messages.
#[test]
fn actor_from_birth_to_first_message() {
let started = Arc::new(AtomicUsize::new(0));
let stopped = Arc::new(AtomicUsize::new(0));
let handled = Arc::new(AtomicUsize::new(0));
let rt = std_runtime(RuntimeConfig::default());
let inbox = rt.new_inbox::<Pong>().unwrap();
// Spawn one tracked actor + 4 more sharing the same counters
let addr = rt.spawn(LifecycleActor {
started: started.clone(),
stopped: stopped.clone(),
handled: handled.clone(),
}).unwrap();
for _ in 0..4 {
rt.spawn(LifecycleActor {
started: started.clone(),
stopped: stopped.clone(),
handled: handled.clone(),
}).unwrap();
}
// First tick: all 5 on_start fire, no messages processed yet
rt.tick();
assert_eq!(started.load(Ordering::Relaxed), 5, "on_start per instance");
assert_eq!(handled.load(Ordering::Relaxed), 0, "no messages before first send");
// Send 3 Increments to a CounterActor to verify state accumulation
let counter_addr = rt.spawn(CounterActor { count: 0 }).unwrap();
let count_inbox = rt.new_inbox::<Count>().unwrap();
for _ in 0..3 {
rt.send_to(counter_addr, Increment { reply_to: *count_inbox.addr() }).unwrap();
}
let replies = tick_and_drain(&rt, &count_inbox, 10);
assert_eq!(replies, vec![Count(1), Count(2), Count(3)], "state accumulates");
// on_start must not fire again on subsequent ticks
rt.tick();
rt.tick();
assert_eq!(started.load(Ordering::Relaxed), 5, "on_start not repeated");
// Verify the first actor still responds normally
rt.send_to(addr, Ping { reply_to: *inbox.addr() }).unwrap();
let reply = tick_until_recv(&rt, &inbox, 10);
assert!(reply.is_some(), "actor handles messages after on_start");
}
/// Delegation chains: parent spawns child, child spawns grandchild, fan-out
/// distributes work. Spawn+send interleaving in a single handler works.
#[test]
fn parent_child_delegation_and_spawn_chains() {
let rt = std_runtime(RuntimeConfig {
max_actors: 2000,
..Default::default()
});
// Act 1: DelegatorActor spawns child, forwards value 7 → Done(14)
let delegator = rt.spawn(DelegatorActor).unwrap();
let inbox = rt.new_inbox::<Done>().unwrap();
rt.send_to(delegator, Forward { value: 7, reply_to: *inbox.addr() }).unwrap();
let reply = tick_until_recv(&rt, &inbox, 20);
assert_eq!(reply, Some(Done(14)), "delegator child doubles value");
// Act 2: Chain of depth 20
let chain = rt.spawn(ChainActor).unwrap();
rt.send_to(chain, ChainMsg { remaining: 20, depth: 0, reply_to: *inbox.addr() }).unwrap();
let reply = tick_until_recv(&rt, &inbox, 200);
assert_eq!(reply, Some(Done(20)), "chain reaches depth 20");
// Act 3: Fan-out to 20 children
let fan = rt.spawn(FanOutActor).unwrap();
rt.send_to(fan, FanOut { count: 20, reply_to: *inbox.addr() }).unwrap();
let replies = tick_and_drain(&rt, &inbox, 50);
assert_eq!(replies.len(), 20, "all 20 fan-out children reply");
}
/// The full graceful-stop story: self-stop with on_stop, farewell messages,
/// external stop ordering vs pending messages, mid-mailbox stop trigger.
#[test]
fn graceful_stop_lifecycle() {
// --- Part A: SelfStopActor ---
let stopped = Arc::new(AtomicUsize::new(0));
let rt = std_runtime(RuntimeConfig::default());
let inbox = rt.new_inbox::<Done>().unwrap();
let addr = rt.spawn(SelfStopActor {
count: 0,
stop_after: 3,
stopped: stopped.clone(),
}).unwrap();
for i in 0..5 {
let _ = rt.send_to(addr, Forward { value: i, reply_to: *inbox.addr() });
}
tick_n(&rt, 10);
let mut replies = Vec::new();
while let Some(Done(v)) = inbox.try_recv() {
replies.push(v);
}
assert_eq!(replies.len(), 3, "only 3 messages processed before self-stop");
assert_eq!(stopped.load(Ordering::Relaxed), 1, "on_stop fired");
assert!(rt.send_to(addr, Forward { value: 99, reply_to: *inbox.addr() }).is_err(),
"send to stopped actor fails");
// --- Part B: FarewellActor sends farewell in on_stop ---
let rt = std_runtime(RuntimeConfig::default());
let inbox = rt.new_inbox::<Pong>().unwrap();
let addr = rt.spawn(FarewellActor { farewell_to: *inbox.addr() }).unwrap();
rt.tick();
rt.stop_actor(addr).unwrap();
tick_n(&rt, 5);
assert_eq!(inbox.try_recv(), Some(Pong), "farewell message delivered from on_stop");
// --- Part C: External stop after pending messages ---
let stopped = Arc::new(AtomicUsize::new(0));
let handled = Arc::new(AtomicUsize::new(0));
let rt = std_runtime(RuntimeConfig::default());
let inbox = rt.new_inbox::<Pong>().unwrap();
let addr = rt.spawn(LifecycleActor {
started: Arc::new(AtomicUsize::new(0)),
stopped: stopped.clone(),
handled: handled.clone(),
}).unwrap();
for _ in 0..10 {
let _ = rt.send_to(addr, Ping { reply_to: *inbox.addr() });
}
rt.stop_actor(addr).unwrap();
tick_n(&rt, 10);
assert_eq!(handled.load(Ordering::Relaxed), 10, "all pending messages processed before stop");
assert_eq!(stopped.load(Ordering::Relaxed), 1, "on_stop fires after messages");
// --- Part D: External stop before messages → 0 processed ---
let handled = Arc::new(AtomicUsize::new(0));
let rt = std_runtime(RuntimeConfig::default());
let inbox = rt.new_inbox::<Pong>().unwrap();
let addr = rt.spawn(LifecycleActor {
started: Arc::new(AtomicUsize::new(0)),
stopped: Arc::new(AtomicUsize::new(0)),
handled: handled.clone(),
}).unwrap();
rt.tick(); // on_start
rt.stop_actor(addr).unwrap();
for _ in 0..5 {
let _ = rt.send_to(addr, Ping { reply_to: *inbox.addr() });
}
tick_n(&rt, 10);
assert_eq!(handled.load(Ordering::Relaxed), 0, "stop before messages prevents processing");
// --- Part E: Mid-mailbox stop trigger ---
let processed = Arc::new(AtomicUsize::new(0));
let rt = std_runtime(RuntimeConfig::default());
let addr = rt.spawn(StopOnTrigger(processed.clone())).unwrap();
rt.tick();
rt.send_to(addr, Trigger(false)).unwrap();
rt.send_to(addr, Trigger(false)).unwrap();
rt.send_to(addr, Trigger(true)).unwrap(); // stop trigger
rt.send_to(addr, Trigger(false)).unwrap();
rt.send_to(addr, Trigger(false)).unwrap();
tick_n(&rt, 5);
assert_eq!(processed.load(Ordering::Relaxed), 3,
"only messages up to and including stop trigger processed");
assert!(rt.send_to(addr, Trigger(false)).is_err());
}
/// Panics are caught: healthy siblings survive, panicked actors are poisoned
/// and cleaned from stats/address map, mid-batch panic discards remaining,
/// child spawned before parent panic survives, message sent before panic is
/// delivered, bulk cleanup, on_start panic also poisons.
#[test]
fn panic_isolation_and_cleanup() {
let rt = std_runtime(RuntimeConfig::default());
let inbox = rt.new_inbox::<Pong>().unwrap();
let count_inbox = rt.new_inbox::<Count>().unwrap();
// Spawn a healthy counter, a PanicActor, and a PanicOnStartActor
let good = rt.spawn(CounterActor { count: 0 }).unwrap();
let bad = rt.spawn(PanicActor).unwrap();
let bad_start_handled = Arc::new(AtomicUsize::new(0));
let bad_start = rt.spawn(PanicOnStartActor { handled: bad_start_handled.clone() }).unwrap();
// Trigger panics
rt.send_to(bad, PanicMsg).unwrap();
let _ = rt.send_to(bad_start, Ping { reply_to: *inbox.addr() });
tick_n(&rt, 10);
// Healthy actor still works
rt.send_to(good, Increment { reply_to: *count_inbox.addr() }).unwrap();
rt.send_to(good, Increment { reply_to: *count_inbox.addr() }).unwrap();
let replies = tick_and_drain(&rt, &count_inbox, 10);
assert_eq!(replies, vec![Count(1), Count(2)], "healthy actor unaffected by peer panics");
// Poisoned actors are cleaned from address map
assert!(rt.send_to(bad, PanicMsg).is_err(), "send to cleaned-up actor fails");
assert_eq!(bad_start_handled.load(Ordering::Relaxed), 0, "on_start panic prevents messages");
// Stats track panics vs stops separately
let stats = rt.stats();
let panics: u64 = stats.workers.iter().map(|w| w.panics).sum();
assert!(panics >= 2, "at least 2 panics recorded (PanicActor + PanicOnStartActor)");
// --- Mid-batch panic discards remaining ---
let counter = Arc::new(AtomicUsize::new(0));
let rt = std_runtime(RuntimeConfig::default());
let dummy = rt.new_inbox::<Pong>().unwrap();
let addr = rt.spawn(PanicAfterNActor { remaining_good: 2, counter: counter.clone() }).unwrap();
for _ in 0..5 {
rt.send_to(addr, Ping { reply_to: *dummy.addr() }).unwrap();
}
tick_n(&rt, 20);
assert_eq!(counter.load(Ordering::SeqCst), 2, "only messages before panic processed");
// --- Child spawned before parent panic survives ---
let rt = std_runtime(RuntimeConfig::default());
let inbox = rt.new_inbox::<Done>().unwrap();
let parent = rt.spawn(SpawnThenPanicActor).unwrap();
rt.send_to(parent, Forward { value: 5, reply_to: *inbox.addr() }).unwrap();
let reply = tick_until_recv(&rt, &inbox, 30);
assert_eq!(reply, Some(Done(10)), "child survives parent panic");
// --- Message sent before panic is delivered ---
let rt = std_runtime(RuntimeConfig::default());
let inbox = rt.new_inbox::<Pong>().unwrap();
let addr = rt.spawn(SendThenPanicActor).unwrap();
rt.send_to(addr, Ping { reply_to: *inbox.addr() }).unwrap();
let reply = tick_until_recv(&rt, &inbox, 20);
assert!(reply.is_some(), "message sent before panic still delivered");
// --- Bulk cleanup: 20 panicking actors all cleaned ---
let rt = std_runtime(RuntimeConfig::default());
let mut addrs = Vec::new();
for _ in 0..20 {
addrs.push(rt.spawn(PanicActor).unwrap());
}
for &addr in &addrs {
let _ = rt.send_to(addr, PanicMsg);
}
tick_n(&rt, 10);
let stats = rt.stats();
assert_eq!(stats.workers[0].num_actors, 0, "all poisoned actors cleaned up");
}
/// Watch API contract: watchers are notified on death, unwatch cancels,
/// double-watch is idempotent, multiple watchers all notified,
/// runtime-level watch works.
#[test]
fn watch_notification_contract() {
let rt = std_runtime(RuntimeConfig::default());
// Spawn target + 3 watchers + 1 that unwatches
let target = rt.spawn(PanicActor).unwrap();
let (w1, s1) = new_exit_watcher();
let (w2, s2) = new_exit_watcher();
let (w3, s3) = new_exit_watcher();
let (w4, s4) = new_exit_watcher(); // will unwatch
let w1_addr = rt.spawn(w1).unwrap();
let w2_addr = rt.spawn(w2).unwrap();
let w3_addr = rt.spawn(w3).unwrap();
let w4_addr = rt.spawn(w4).unwrap();
// All watch the target
rt.send_to(w1_addr, WatcherCmd::WatchThis(target)).unwrap();
rt.send_to(w2_addr, WatcherCmd::WatchThis(target)).unwrap();
rt.send_to(w3_addr, WatcherCmd::WatchThis(target)).unwrap();
rt.send_to(w4_addr, WatcherCmd::WatchThis(target)).unwrap();
tick_n(&rt, 3);
// w2 double-watches (idempotent test)
rt.send_to(w2_addr, WatcherCmd::WatchThis(target)).unwrap();
tick_n(&rt, 3);
// w4 unwatches
rt.send_to(w4_addr, WatcherCmd::UnwatchThis(target)).unwrap();
tick_n(&rt, 3);
// Kill target
rt.send_to(target, PanicMsg).unwrap();
tick_n(&rt, 5);
assert_eq!(s1.count(), 1, "watcher 1 notified");
assert_eq!(s2.count(), 1, "double-watch still only one notification");
assert_eq!(s3.count(), 1, "watcher 3 notified");
assert_eq!(s4.count(), 0, "unwatched watcher not notified");
assert_eq!(s1.last_reason(), Some(ExitReason::Panicked));
// --- Runtime-level watch ---
let rt = std_runtime(RuntimeConfig::default());
let target = rt.spawn(PanicActor).unwrap();
let (w, s) = new_exit_watcher();
let w_addr = rt.spawn(w).unwrap();
tick_n(&rt, 2);
rt.watch(w_addr, target);
rt.send_to(target, PanicMsg).unwrap();
tick_n(&rt, 5);
assert_eq!(s.count(), 1, "runtime-level watch delivers notification");
}
/// Watch edge cases: watcher dies before target (no crash), self-watch (no
/// crash), watcher reacts to death by spawning a replacement.
#[test]
fn watch_edge_cases() {
// Watcher dies before target — no crash
let rt = std_runtime(RuntimeConfig::default());
let target = rt.spawn(PanicActor).unwrap();
let target2 = rt.spawn(PanicActor).unwrap();
rt.watch(target2, target);
tick_n(&rt, 3);
rt.send_to(target2, PanicMsg).unwrap(); // kill watcher first
tick_n(&rt, 5);
rt.send_to(target, PanicMsg).unwrap(); // kill target — no crash
tick_n(&rt, 5);
// Self-watch — no crash
let rt = std_runtime(RuntimeConfig::default());
let (w, _s) = new_exit_watcher();
let addr = rt.spawn(w).unwrap();
rt.send_to(addr, WatcherCmd::WatchThis(addr)).unwrap();
tick_n(&rt, 5);
// Watcher reacts to death by spawning replacement
let rt = std_runtime(RuntimeConfig::default());
let spawned = Arc::new(AtomicUsize::new(0));
struct SupervisorWatcher {
spawned_count: Arc<AtomicUsize>,
}
#[derive(Clone)]
enum SupCmd {
WatchThis(ActorAddress),
}
impl ActorInterface for SupervisorWatcher {
type Incoming = SupCmd;
type Response = ();
fn handle(&mut self, ctx: &Ctx, msg: SupCmd) {
match msg {
SupCmd::WatchThis(target) => ctx.watch(target),
}
}
fn on_actor_exit(&mut self, ctx: &Ctx, _exited: ActorExited) {
let _ = ctx.spawn(Sleeper);
self.spawned_count.fetch_add(1, Ordering::SeqCst);
}
}
let target = rt.spawn(PanicActor).unwrap();
let sup = rt.spawn(SupervisorWatcher { spawned_count: spawned.clone() }).unwrap();
rt.send_to(sup, SupCmd::WatchThis(target)).unwrap();
tick_n(&rt, 3);
rt.send_to(target, PanicMsg).unwrap();
tick_n(&rt, 5);
assert_eq!(spawned.load(Ordering::SeqCst), 1, "watcher spawned replacement");
}
/// Monitor API contract: Down on stop (Normal) and panic (Panicked), multiple
/// monitors, demonitor cancels, dead watcher cleanup, stacked monitors,
/// external inbox, handle_down dispatch.
#[test]
fn monitor_death_notification_contract() {
let rt = std_runtime(RuntimeConfig::default());
// --- Stop → Down(Normal) ---
let inbox = rt.new_inbox::<Down>().unwrap();
let target = rt.spawn(PingPongActor).unwrap();
rt.spawn(MonitorWatcherActor {
watch_target: target,
reply_to: *inbox.addr(),
mref: None,
}).unwrap();
rt.tick();
rt.stop_actor(target).unwrap();
tick_n(&rt, 3);
let down = inbox.try_recv().expect("Down on graceful stop");
assert_eq!(down.addr, target);
assert_eq!(down.reason, StopReason::Normal);
// --- Panic → Down(Panicked) ---
let rt = std_runtime(RuntimeConfig::default());
let inbox = rt.new_inbox::<Down>().unwrap();
let target = rt.spawn(PanicActor).unwrap();
rt.spawn(MonitorWatcherActor {
watch_target: target,
reply_to: *inbox.addr(),
mref: None,
}).unwrap();
rt.tick();
rt.send_to(target, PanicMsg).unwrap();
tick_n(&rt, 3);
let down = inbox.try_recv().expect("Down on panic");
assert_eq!(down.reason, StopReason::Panicked);
// --- Multiple monitors ---
let rt = std_runtime(RuntimeConfig::default());
let inbox1 = rt.new_inbox::<Down>().unwrap();
let inbox2 = rt.new_inbox::<Down>().unwrap();
let target = rt.spawn(PingPongActor).unwrap();
rt.spawn(MonitorWatcherActor {
watch_target: target, reply_to: *inbox1.addr(), mref: None,
}).unwrap();
rt.spawn(MonitorWatcherActor {
watch_target: target, reply_to: *inbox2.addr(), mref: None,
}).unwrap();
rt.tick();
rt.stop_actor(target).unwrap();
tick_n(&rt, 3);
assert!(inbox1.try_recv().is_some(), "watcher 1 notified");
assert!(inbox2.try_recv().is_some(), "watcher 2 notified");
// --- Demonitor cancels ---
let rt = std_runtime(RuntimeConfig::default());
let down_inbox = rt.new_inbox::<Down>().unwrap();
let target = rt.spawn(PingPongActor).unwrap();
let watcher = rt.spawn(DemonitorActor { watch_target: target, mref: None }).unwrap();
rt.tick();
rt.send_to(watcher, Ping { reply_to: ActorAddress::default() }).unwrap();
rt.tick(); // demonitor
rt.stop_actor(target).unwrap();
tick_n(&rt, 3);
assert!(down_inbox.try_recv().is_none(), "demonitored: no Down delivered");
// --- Dead watcher cleaned up ---
let rt = std_runtime(RuntimeConfig::default());
let target = rt.spawn(PingPongActor).unwrap();
let watcher = rt.spawn(MonitorWatcherActor {
watch_target: target,
reply_to: ActorAddress::default(),
mref: None,
}).unwrap();
rt.tick();
rt.stop_actor(watcher).unwrap();
rt.tick(); // watcher dies
rt.stop_actor(target).unwrap();
tick_n(&rt, 3); // target dies — no crash trying to deliver to dead watcher
// --- Stacked monitors produce multiple notifications ---
struct DoubleMonitor {
target: ActorAddress,
reply_to: ActorAddress,
}
impl ActorInterface for DoubleMonitor {
type Incoming = Down;
type Response = ();
fn on_start(&mut self, ctx: &Ctx) {
ctx.monitor(self.target);
ctx.monitor(self.target);
}
fn handle(&mut self, ctx: &Ctx, msg: Down) {
ctx.send(self.reply_to, msg).unwrap();
}
}
let rt = std_runtime(RuntimeConfig::default());
let inbox = rt.new_inbox::<Down>().unwrap();
let target = rt.spawn(PingPongActor).unwrap();
rt.spawn(DoubleMonitor { target, reply_to: *inbox.addr() }).unwrap();
rt.tick();
rt.stop_actor(target).unwrap();
tick_n(&rt, 3);
assert!(inbox.try_recv().is_some(), "first Down from stacked monitor");
assert!(inbox.try_recv().is_some(), "second Down from stacked monitor");
assert!(inbox.try_recv().is_none(), "no more");
// --- handle_down dispatch ---
struct MonitoringTracker {
target: ActorAddress,
downs: Vec<Down>,
inbox: ActorAddress,
}
impl ActorInterface for MonitoringTracker {
type Incoming = Ping;
type Response = ();
fn on_start(&mut self, ctx: &Ctx) {
ctx.monitor(self.target);
}
fn handle(&mut self, ctx: &Ctx, _msg: Ping) {
let _ = ctx.send(self.inbox, Count(self.downs.len()));
}
fn handle_down(&mut self, _ctx: &Ctx, down: Down) {
self.downs.push(down);
}
}
let rt = std_runtime(RuntimeConfig::default());
let inbox = rt.new_inbox::<Count>().unwrap();
let target = rt.spawn(PanicActor).unwrap();
let tracker = rt.spawn(MonitoringTracker {
target,
downs: vec![],
inbox: *inbox.addr(),
}).unwrap();
rt.tick();
rt.send_to(target, PanicMsg).unwrap();
tick_n(&rt, 3);
rt.send_to(tracker, Ping { reply_to: ActorAddress::default() }).unwrap();
rt.tick();
assert_eq!(inbox.try_recv(), Some(Count(1)), "handle_down received exactly one Down");
// --- When Incoming=Down, handle_down is NOT called ---
struct DownAsIncoming {
target: ActorAddress,
inbox: ActorAddress,
}
impl ActorInterface for DownAsIncoming {
type Incoming = Down;
type Response = ();
fn on_start(&mut self, ctx: &Ctx) {
ctx.monitor(self.target);
}
fn handle(&mut self, ctx: &Ctx, msg: Down) {
let _ = ctx.send(self.inbox, msg);
}
fn handle_down(&mut self, _ctx: &Ctx, _down: Down) {
panic!("handle_down must not be called when Incoming=Down");
}
}
let rt = std_runtime(RuntimeConfig::default());
let inbox = rt.new_inbox::<Down>().unwrap();
let target = rt.spawn(PanicActor).unwrap();
rt.spawn(DownAsIncoming { target, inbox: *inbox.addr() }).unwrap();
rt.tick();
rt.send_to(target, PanicMsg).unwrap();
tick_n(&rt, 3);
let received = inbox.try_recv().expect("Down delivered via handle(), not handle_down");
assert_eq!(received.reason, StopReason::Panicked);
}

255
tests/common/mod.rs Normal file
View file

@ -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<AtomicUsize>,
}
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<M: swactor::actor::Message>(
rt: &Runtime,
inbox: &Inbox<M>,
max: usize,
) -> Option<M> {
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<M: swactor::actor::Message>(
rt: &Runtime,
inbox: &Inbox<M>,
ticks: usize,
) -> Vec<M> {
for _ in 0..ticks {
rt.tick();
}
std::iter::from_fn(|| inbox.try_recv()).collect()
}

489
tests/message_delivery.rs Normal file
View file

@ -0,0 +1,489 @@
//! Message Delivery Tests — how data flows through the system.
//!
//! Covers: FIFO ordering, routing correctness at scale, delivery from within
//! handlers, address error handling, fairness/budgets, timers, and mailbox
//! backpressure policies.
mod common;
use common::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
// ── Local actors ────────────────────────────────────────────────────────────
/// Sends a countdown message to itself, then replies Done(0).
struct SelfSendActor;
#[derive(Clone)]
struct Countdown {
remaining: usize,
reply_to: ActorAddress,
}
impl ActorInterface for SelfSendActor {
type Incoming = Countdown;
type Response = Done;
fn handle(&mut self, ctx: &Ctx, msg: Countdown) {
if msg.remaining == 0 {
let _ = ctx.send(msg.reply_to, Done(0));
} else {
let _ = ctx.send(
ctx.self_addr(),
Countdown { remaining: msg.remaining - 1, reply_to: msg.reply_to },
);
}
}
}
/// Schedules a one-shot timer in on_start.
struct TimerStartActor {
target: ActorAddress,
delay_ticks: u64,
}
impl ActorInterface for TimerStartActor {
type Incoming = Ping;
type Response = Pong;
fn on_start(&mut self, ctx: &Ctx) {
ctx.send_after_ticks(self.target, Ping { reply_to: ctx.self_addr() }, self.delay_ticks);
}
fn handle(&mut self, _ctx: &Ctx, _msg: Ping) {}
}
/// Schedules a one-shot timer from a handler.
struct DelayPingPongActor;
impl ActorInterface for DelayPingPongActor {
type Incoming = Forward;
type Response = Done;
fn handle(&mut self, ctx: &Ctx, msg: Forward) {
ctx.send_after_ticks(msg.reply_to, Done(msg.value), 3);
}
}
/// Schedules an interval timer on start.
struct HeartbeatActor {
target: ActorAddress,
period: u64,
}
impl ActorInterface for HeartbeatActor {
type Incoming = Ping;
type Response = Pong;
fn on_start(&mut self, ctx: &Ctx) {
ctx.send_interval_ticks(self.target, Ping { reply_to: ctx.self_addr() }, self.period);
}
fn handle(&mut self, _ctx: &Ctx, _msg: Ping) {}
}
/// NumberedMsg/Reply for routing correctness tests.
#[derive(Clone)]
struct NumberedMsg {
n: usize,
reply_to: ActorAddress,
}
#[derive(Clone, Debug, PartialEq)]
struct NumberedReply {
from: ActorAddress,
n: usize,
}
struct NumberedActor;
impl ActorInterface for NumberedActor {
type Incoming = NumberedMsg;
type Response = ();
fn handle(&mut self, ctx: &Ctx, msg: NumberedMsg) {
let _ = ctx.send(msg.reply_to, NumberedReply { from: ctx.self_addr(), n: msg.n });
}
}
/// Ring node for routing chain test.
#[derive(Clone)]
struct RingHop {
hops_remaining: usize,
final_dest: ActorAddress,
}
#[derive(Clone, Debug, PartialEq)]
struct RingDone(usize);
struct RingNode {
next: ActorAddress,
}
impl ActorInterface for RingNode {
type Incoming = RingHop;
type Response = ();
fn handle(&mut self, ctx: &Ctx, msg: RingHop) {
if msg.hops_remaining == 0 {
let _ = ctx.send(msg.final_dest, RingDone(100));
} else {
let _ = ctx.send(self.next, RingHop {
hops_remaining: msg.hops_remaining - 1,
final_dest: msg.final_dest,
});
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Tests
// ═══════════════════════════════════════════════════════════════════════════
/// Messages arrive in FIFO order even with small buffers, budget constraints,
/// and independent mailboxes isolate actors from each other.
#[test]
fn fifo_ordering_and_mailbox_isolation() {
// FIFO with small buffer and budget
let rt = std_runtime(RuntimeConfig {
channel_buffer_size: 1,
actor_message_budget: 8,
..Default::default()
});
let addr = rt.spawn(CounterActor { count: 0 }).unwrap();
let inbox = rt.new_inbox::<Count>().unwrap();
for _ in 0..100 {
rt.send_to(addr, Increment { reply_to: *inbox.addr() }).unwrap();
}
let replies: Vec<_> = tick_and_drain(&rt, &inbox, 50);
assert_eq!(replies.len(), 100, "all messages delivered");
for (i, reply) in replies.iter().enumerate() {
assert_eq!(*reply, Count(i + 1), "FIFO order preserved at position {i}");
}
// Mailbox isolation: 3 actors each get exactly their own messages
let rt = std_runtime(RuntimeConfig::default());
let mut inboxes = Vec::new();
for _ in 0..3 {
let addr = rt.spawn(PingPongActor).unwrap();
let inbox = rt.new_inbox::<Pong>().unwrap();
rt.send_to(addr, Ping { reply_to: *inbox.addr() }).unwrap();
inboxes.push(inbox);
}
tick_n(&rt, 10);
for (i, inbox) in inboxes.iter().enumerate() {
assert!(inbox.try_recv().is_some(), "actor {i} replied");
assert!(inbox.try_recv().is_none(), "actor {i} has exactly one reply");
}
}
/// 200 actors each get a unique numbered message and reply correctly.
/// A 100-hop ring traversal completes.
#[test]
fn message_routing_at_scale() {
// 200-actor numbered routing
let rt = std_runtime(RuntimeConfig {
max_actors: 300,
channel_buffer_size: 1024,
num_threads: 1,
..Default::default()
});
let inbox = rt.new_inbox::<NumberedReply>().unwrap();
let inbox_addr = *inbox.addr();
let mut addrs = Vec::new();
for _ in 0..200 {
addrs.push(rt.spawn(NumberedActor).unwrap());
}
rt.tick();
for (i, addr) in addrs.iter().enumerate() {
rt.send_to(*addr, NumberedMsg { n: i, reply_to: inbox_addr }).unwrap();
}
tick_n(&rt, 3);
let replies: Vec<NumberedReply> = std::iter::from_fn(|| inbox.try_recv()).collect();
assert_eq!(replies.len(), 200, "all 200 actors replied");
for (i, addr) in addrs.iter().enumerate() {
let reply = replies.iter().find(|r| r.n == i);
assert!(reply.is_some(), "missing reply for actor #{i}");
assert_eq!(reply.unwrap().from, *addr, "reply #{i} came from correct actor");
}
// 100-hop ring
let rt = std_runtime(RuntimeConfig {
max_actors: 200,
channel_buffer_size: 1024,
num_threads: 1,
..Default::default()
});
let inbox = rt.new_inbox::<RingDone>().unwrap();
let inbox_addr = *inbox.addr();
let mut ring_addrs = Vec::new();
let mut next = inbox_addr;
for _ in (0..100).rev() {
let addr = rt.spawn(RingNode { next }).unwrap();
ring_addrs.push(addr);
next = addr;
}
ring_addrs.reverse();
rt.tick();
rt.send_to(ring_addrs[0], RingHop { hops_remaining: 99, final_dest: inbox_addr }).unwrap();
let result = tick_until_recv(&rt, &inbox, 110);
assert_eq!(result, Some(RingDone(100)), "ring message traverses all 100 hops");
}
/// Messages sent in handlers are delivered: delegation, self-send chains,
/// rapid spawn+immediate-send, multiple inbox types coexist.
#[test]
fn delivery_from_within_handlers() {
let rt = std_runtime(RuntimeConfig::default());
// Delegation: spawn+send in handler
let delegator = rt.spawn(DelegatorActor).unwrap();
let inbox = rt.new_inbox::<Done>().unwrap();
rt.send_to(delegator, Forward { value: 5, reply_to: *inbox.addr() }).unwrap();
let reply = tick_until_recv(&rt, &inbox, 20);
assert_eq!(reply, Some(Done(10)), "child spawned during handler receives message");
// Self-send countdown of 20
let self_sender = rt.spawn(SelfSendActor).unwrap();
rt.send_to(self_sender, Countdown { remaining: 20, reply_to: *inbox.addr() }).unwrap();
let reply = tick_until_recv(&rt, &inbox, 50);
assert_eq!(reply, Some(Done(0)), "self-send chain completes");
// Multiple senders reach same actor
let counter = rt.spawn(CounterActor { count: 0 }).unwrap();
let inbox_a = rt.new_inbox::<Count>().unwrap();
let inbox_b = rt.new_inbox::<Count>().unwrap();
rt.send_to(counter, Increment { reply_to: *inbox_a.addr() }).unwrap();
rt.send_to(counter, Increment { reply_to: *inbox_b.addr() }).unwrap();
tick_n(&rt, 10);
assert!(inbox_a.try_recv().is_some());
assert_eq!(inbox_b.try_recv(), Some(Count(2)), "both senders reach same actor");
// 50 rapid spawn+immediate-send pairs
let rt = std_runtime(RuntimeConfig::default());
let pong_inbox = rt.new_inbox::<Pong>().unwrap();
for _ in 0..50 {
let addr = rt.spawn(PingPongActor).unwrap();
rt.send_to(addr, Ping { reply_to: *pong_inbox.addr() }).unwrap();
}
let replies = tick_and_drain(&rt, &pong_inbox, 50);
assert_eq!(replies.len(), 50, "all spawn+send pairs complete");
// Multiple inbox types coexist
let rt = std_runtime(RuntimeConfig::default());
let counter_addr = rt.spawn(CounterActor { count: 0 }).unwrap();
let pinger_addr = rt.spawn(PingPongActor).unwrap();
let count_inbox = rt.new_inbox::<Count>().unwrap();
let pong_inbox = rt.new_inbox::<Pong>().unwrap();
rt.send_to(counter_addr, Increment { reply_to: *count_inbox.addr() }).unwrap();
rt.send_to(pinger_addr, Ping { reply_to: *pong_inbox.addr() }).unwrap();
tick_n(&rt, 10);
assert_eq!(count_inbox.try_recv(), Some(Count(1)));
assert_eq!(pong_inbox.try_recv(), Some(Pong));
}
/// Sending to nonexistent address returns error, wrong type increments
/// type_mismatch counter.
#[test]
fn address_error_handling() {
let rt = std_runtime(RuntimeConfig::default());
// Nonexistent address
let bogus = ActorAddress::new_random();
assert!(rt.send_to(bogus, Pong).is_err(), "send to unknown address fails");
// Wrong type
let addr = rt.spawn(PingPongActor).unwrap();
rt.send_to(addr, Count(42)).unwrap(); // Count instead of Ping
rt.send_to(addr, Count(0)).unwrap();
rt.send_to(addr, Count(0)).unwrap();
tick_n(&rt, 10);
let stats = rt.stats();
let mismatches: u64 = stats.workers.iter().map(|w| w.type_mismatches).sum();
assert_eq!(mismatches, 3, "3 wrong-type messages counted as mismatches");
}
/// Budget fairness: hot actor doesn't starve cold actor, budget is respected
/// with self-sends, unlimited budget drains all.
#[test]
fn fairness_budget_prevents_starvation() {
// Hot (1000 msgs) vs cold (1 msg), budget=64
let rt = std_runtime(RuntimeConfig::default());
let hot_counter = Arc::new(AtomicUsize::new(0));
let cold_inbox = rt.new_inbox::<Pong>().unwrap();
let hot = rt.spawn(CountingPingActor { counter: hot_counter.clone() }).unwrap();
let cold = rt.spawn(PingPongActor).unwrap();
let dummy = rt.new_inbox::<Pong>().unwrap();
for _ in 0..1000 {
rt.send_to(hot, Ping { reply_to: *dummy.addr() }).unwrap();
}
rt.send_to(cold, Ping { reply_to: *cold_inbox.addr() }).unwrap();
rt.tick();
assert!(cold_inbox.try_recv().is_some(), "cold actor not starved by hot actor");
assert!(hot_counter.load(Ordering::SeqCst) <= 64, "hot capped at budget");
// Budget=4 with self-send chain of 20 → completes across multiple ticks
let rt = std_runtime(RuntimeConfig { actor_message_budget: 4, ..Default::default() });
let addr = rt.spawn(SelfSendActor).unwrap();
let inbox = rt.new_inbox::<Done>().unwrap();
rt.send_to(addr, Countdown { remaining: 20, reply_to: *inbox.addr() }).unwrap();
tick_n(&rt, 30);
assert_eq!(inbox.try_recv(), Some(Done(0)), "self-send chain completes despite budget");
// Unlimited budget (0) drains all
let rt = std_runtime(RuntimeConfig { actor_message_budget: 0, ..Default::default() });
let counter = Arc::new(AtomicUsize::new(0));
let dummy = rt.new_inbox::<Pong>().unwrap();
let addr = rt.spawn(CountingPingActor { counter: counter.clone() }).unwrap();
for _ in 0..500 {
rt.send_to(addr, Ping { reply_to: *dummy.addr() }).unwrap();
}
rt.tick();
rt.tick();
assert_eq!(counter.load(Ordering::SeqCst), 500, "unlimited budget drains all");
}
/// One-shot timers fire at the right tick and only once. Interval timers fire
/// repeatedly at the right period. Timers are cleaned up when actors die.
#[test]
fn timer_one_shot_and_interval() {
// One-shot: delay=3 from on_start
let rt = std_runtime(RuntimeConfig::default());
let inbox = rt.new_inbox::<Ping>().unwrap();
rt.spawn(TimerStartActor { target: *inbox.addr(), delay_ticks: 3 }).unwrap();
rt.tick(); // tick 1: on_start schedules
assert!(inbox.try_recv().is_none(), "no delivery tick 1");
rt.tick(); // tick 2
assert!(inbox.try_recv().is_none(), "no delivery tick 2");
rt.tick(); // tick 3
assert!(inbox.try_recv().is_none(), "no delivery tick 3");
rt.tick(); // tick 4: fires
assert!(inbox.try_recv().is_some(), "timer fires after 3-tick delay");
// One-shot from handler
let rt = std_runtime(RuntimeConfig::default());
let inbox = rt.new_inbox::<Done>().unwrap();
let addr = rt.spawn(DelayPingPongActor).unwrap();
rt.send_to(addr, Forward { value: 42, reply_to: *inbox.addr() }).unwrap();
rt.tick(); // process Forward, schedule timer
assert!(inbox.try_recv().is_none());
rt.tick(); // tick 2
rt.tick(); // tick 3
assert!(inbox.try_recv().is_none());
rt.tick(); // tick 4: fires
assert_eq!(inbox.try_recv(), Some(Done(42)), "delayed reply from handler timer");
// One-shot does NOT repeat
let rt = std_runtime(RuntimeConfig::default());
let inbox = rt.new_inbox::<Ping>().unwrap();
rt.spawn(TimerStartActor { target: *inbox.addr(), delay_ticks: 1 }).unwrap();
rt.tick(); // schedule
rt.tick(); // fires
assert!(inbox.try_recv().is_some(), "first fire");
tick_n(&rt, 5);
assert!(inbox.try_recv().is_none(), "one-shot doesn't repeat");
// Zero-delay fires next tick
let rt = std_runtime(RuntimeConfig::default());
let inbox = rt.new_inbox::<Ping>().unwrap();
rt.spawn(TimerStartActor { target: *inbox.addr(), delay_ticks: 0 }).unwrap();
rt.tick(); // schedule
assert!(inbox.try_recv().is_none(), "not immediate — fires next tick");
rt.tick(); // fires
assert!(inbox.try_recv().is_some(), "zero-delay fires next tick");
// Interval: period=2, fires on ticks 3, 5, 7
let rt = std_runtime(RuntimeConfig::default());
let inbox = rt.new_inbox::<Ping>().unwrap();
rt.spawn(HeartbeatActor { target: *inbox.addr(), period: 2 }).unwrap();
rt.tick(); // tick 1: schedule
assert!(inbox.try_recv().is_none());
rt.tick(); // tick 2
assert!(inbox.try_recv().is_none());
rt.tick(); // tick 3: first fire
assert!(inbox.try_recv().is_some(), "fire on tick 3");
rt.tick(); // tick 4
assert!(inbox.try_recv().is_none());
rt.tick(); // tick 5: second fire
assert!(inbox.try_recv().is_some(), "fire on tick 5");
rt.tick(); // tick 6
assert!(inbox.try_recv().is_none());
rt.tick(); // tick 7: third fire
assert!(inbox.try_recv().is_some(), "fire on tick 7");
// Timer cleanup when target actor dies
let rt = std_runtime(RuntimeConfig::default());
let counter_addr = rt.spawn(CounterActor { count: 0 }).unwrap();
rt.spawn(HeartbeatActor { target: counter_addr, period: 1 }).unwrap();
tick_n(&rt, 3);
rt.stop_actor(counter_addr).unwrap();
tick_n(&rt, 5);
let stats = rt.stats();
assert_eq!(stats.workers[0].num_actors, 1, "only heartbeat actor remains");
}
/// Bounded mailboxes: DropNewest caps at capacity, DropOldest keeps newest,
/// unbounded delivers all, mailbox refills after processing.
#[test]
fn mailbox_backpressure_policies() {
// DropNewest: capacity=10, send 50 → only 10 delivered
let rt = std_runtime(RuntimeConfig {
default_mailbox_capacity: 10,
mailbox_overflow: MailboxOverflow::DropNewest,
..Default::default()
});
let inbox = rt.new_inbox::<Count>().unwrap();
let addr = rt.spawn(CounterActor { count: 0 }).unwrap();
for _ in 0..50 {
let _ = rt.send_to(addr, Increment { reply_to: *inbox.addr() });
}
tick_n(&rt, 20);
let mut replies = 0;
while inbox.try_recv().is_some() { replies += 1; }
assert_eq!(replies, 10, "DropNewest caps at mailbox capacity");
let drops: u64 = rt.stats().workers.iter().map(|w| w.messages_dropped).sum();
assert_eq!(drops, 40, "40 messages dropped");
// DropOldest: capacity=5, send 10 → newest 5 kept
let rt = std_runtime(RuntimeConfig {
default_mailbox_capacity: 5,
mailbox_overflow: MailboxOverflow::DropOldest,
..Default::default()
});
let inbox = rt.new_inbox::<Done>().unwrap();
let addr = rt.spawn(DoubleActor).unwrap();
for i in 0..10 {
let _ = rt.send_to(addr, Forward { value: i, reply_to: *inbox.addr() });
}
tick_n(&rt, 10);
let mut replies = Vec::new();
while let Some(Done(v)) = inbox.try_recv() { replies.push(v); }
assert_eq!(replies.len(), 5, "only 5 kept");
assert_eq!(replies, vec![10, 12, 14, 16, 18], "newest values kept (5-9 doubled)");
// Unbounded: 200 messages all delivered
let rt = std_runtime(RuntimeConfig::default());
let inbox = rt.new_inbox::<Count>().unwrap();
let addr = rt.spawn(CounterActor { count: 0 }).unwrap();
for _ in 0..200 {
let _ = rt.send_to(addr, Increment { reply_to: *inbox.addr() });
}
tick_n(&rt, 50);
let mut count = 0;
while inbox.try_recv().is_some() { count += 1; }
assert_eq!(count, 200, "unbounded delivers all");
// Refill after processing
let rt = std_runtime(RuntimeConfig {
default_mailbox_capacity: 5,
actor_message_budget: 5,
mailbox_overflow: MailboxOverflow::DropNewest,
..Default::default()
});
let inbox = rt.new_inbox::<Count>().unwrap();
let addr = rt.spawn(CounterActor { count: 0 }).unwrap();
for _ in 0..5 {
let _ = rt.send_to(addr, Increment { reply_to: *inbox.addr() });
}
rt.tick(); // process batch 1
for _ in 0..5 {
let _ = rt.send_to(addr, Increment { reply_to: *inbox.addr() });
}
rt.tick(); // process batch 2
let mut count = 0;
while inbox.try_recv().is_some() { count += 1; }
assert_eq!(count, 10, "mailbox refills after draining");
}

View file

@ -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::<Ping>().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::<Ping>().unwrap();
struct IntervalActor { target: ActorAddress, period: u64 }

File diff suppressed because it is too large Load diff

364
tests/runtime_stress.rs Normal file
View file

@ -0,0 +1,364 @@
//! Runtime Stress Tests — multi-threaded execution, parking, placement, and scale.
//!
//! Covers: single vs multi-threaded processing, high-volume MT delivery,
//! panic isolation under load, worker parking/shutdown, sustained throughput,
//! and load-aware actor placement.
mod common;
use common::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
// ── Helpers ──────────────────────────────────────────────────────────────────
/// Poll `inbox` until a message arrives or `timeout` elapses.
fn poll_inbox<M: swactor::actor::Message>(
inbox: &Inbox<M>,
timeout: Duration,
) -> Option<M> {
let deadline = Instant::now() + timeout;
loop {
if let Some(msg) = inbox.try_recv() {
return Some(msg);
}
if Instant::now() > deadline {
return None;
}
std::thread::sleep(Duration::from_millis(1));
}
}
/// Wait until `counter` reaches `target` or `timeout` elapses.
fn wait_for_count(counter: &AtomicUsize, target: usize, timeout: Duration) -> usize {
let deadline = Instant::now() + timeout;
loop {
let n = counter.load(Ordering::SeqCst);
if n >= target {
return n;
}
if Instant::now() > deadline {
return n;
}
std::thread::sleep(Duration::from_millis(5));
}
}
// ── Tests ────────────────────────────────────────────────────────────────────
/// Single-threaded vs multi-threaded runtime basics.
///
/// Story: We start with a single-threaded runtime driven by tick(), confirm
/// nothing happens without ticking, then graduate to a multi-threaded runtime
/// with run() and verify background processing, cross-worker delegation,
/// custom thread counts, and clean shutdown.
#[test]
fn single_vs_multi_threaded_basics() {
// ── Part A: Single-threaded requires tick() ──
let rt = std_runtime(RuntimeConfig::default());
let addr = rt.spawn(PingPongActor).unwrap();
let inbox = rt.new_inbox::<Pong>().unwrap();
rt.send_to(addr, Ping { reply_to: *inbox.addr() }).unwrap();
assert!(inbox.try_recv().is_none(), "no processing before tick");
tick_n(&rt, 2);
assert!(inbox.try_recv().is_some(), "tick() drives single-threaded processing");
// ── Part B: Multi-threaded processes without ticking ──
let rt_mt = std_runtime(RuntimeConfig { num_threads: 4, ..Default::default() });
let addr = rt_mt.spawn(PingPongActor).unwrap();
let inbox = rt_mt.new_inbox::<Pong>().unwrap();
rt_mt.send_to(addr, Ping { reply_to: *inbox.addr() }).unwrap();
let handle = rt_mt.run().unwrap();
let reply = poll_inbox(&inbox, Duration::from_secs(5));
assert!(reply.is_some(), "background workers process without manual ticking");
// ── Part C: Cross-worker delegation (2 threads, spawn child from handler) ──
let addr2 = handle.runtime.spawn(DelegatorActor).unwrap();
let done_inbox = handle.runtime.new_inbox::<Done>().unwrap();
handle.runtime.send_to(addr2, Forward { value: 3, reply_to: *done_inbox.addr() }).unwrap();
let reply = poll_inbox(&done_inbox, Duration::from_secs(5));
assert_eq!(reply, Some(Done(6)), "cross-worker delegation delivers reply");
// ── Part D: Custom thread count reflected in stats ──
let stats = handle.runtime.stats();
assert_eq!(stats.num_workers, 4, "runtime respects requested thread count");
// ── Part E: Clean shutdown ──
handle.shutdown();
handle.join();
// Test passes by not hanging.
}
/// High-volume multi-threaded delivery.
///
/// Story: We throw large workloads at a 4-thread runtime — 50 senders
/// each firing 100 messages at one receiver, 200 concurrent spawn+send
/// pairs, and a 50-level chain that must hop across workers.
#[test]
fn mt_high_volume_delivery() {
let cfg = || RuntimeConfig {
num_threads: 4,
max_actors: 5_000,
channel_buffer_size: 10_000,
..Default::default()
};
// ── Part A: 50 senders × 100 messages → one receiver ──
{
let rt = std_runtime(cfg());
let counter = Arc::new(AtomicUsize::new(0));
let dummy = rt.new_inbox::<Pong>().unwrap();
let receiver = rt.spawn(CountingPingActor { counter: counter.clone() }).unwrap();
let total_expected = 50 * 100;
for _ in 0..50 {
for _ in 0..100 {
rt.send_to(receiver, Ping { reply_to: *dummy.addr() }).unwrap();
}
}
let handle = rt.run().unwrap();
let processed = wait_for_count(&counter, total_expected, Duration::from_secs(5));
handle.shutdown();
handle.join();
assert_eq!(processed, total_expected, "all 5000 messages delivered to single receiver");
}
// ── Part B: 200 concurrent spawn+send pairs ──
{
let rt = std_runtime(cfg());
let counter = Arc::new(AtomicUsize::new(0));
let dummy = rt.new_inbox::<Pong>().unwrap();
for _ in 0..200 {
let a = rt.spawn(CountingPingActor { counter: counter.clone() }).unwrap();
rt.send_to(a, Ping { reply_to: *dummy.addr() }).unwrap();
}
let handle = rt.run().unwrap();
let received = wait_for_count(&counter, 200, Duration::from_secs(5));
handle.shutdown();
handle.join();
assert_eq!(received, 200, "all 200 spawn+send pairs complete");
}
// ── Part C: 50-level chain across workers ──
{
let rt = std_runtime(RuntimeConfig { num_threads: 2, max_actors: 5_000, ..Default::default() });
let addr = rt.spawn(ChainActor).unwrap();
let inbox = rt.new_inbox::<Done>().unwrap();
rt.send_to(addr, ChainMsg { remaining: 50, depth: 0, reply_to: *inbox.addr() }).unwrap();
let handle = rt.run().unwrap();
let reply = poll_inbox(&inbox, Duration::from_secs(5));
handle.shutdown();
handle.join();
assert_eq!(reply, Some(Done(50)), "50-level chain completes across workers");
}
}
/// Panic isolation under multi-threaded load.
///
/// Story: 10 panicking actors and 10 healthy actors on 4 threads — every
/// panic is isolated and all 1000 healthy messages are still processed.
#[test]
fn mt_panic_isolation_under_load() {
let rt = std_runtime(RuntimeConfig {
num_threads: 4,
max_actors: 5_000,
channel_buffer_size: 10_000,
..Default::default()
});
let counter = Arc::new(AtomicUsize::new(0));
let dummy = rt.new_inbox::<Pong>().unwrap();
let mut panic_addrs = Vec::new();
let mut healthy_addrs = Vec::new();
for _ in 0..10 {
panic_addrs.push(rt.spawn(PanicActor).unwrap());
healthy_addrs.push(rt.spawn(CountingPingActor { counter: counter.clone() }).unwrap());
}
// Trigger panics and flood healthy actors.
for &addr in &panic_addrs {
rt.send_to(addr, PanicMsg).unwrap();
}
for &addr in &healthy_addrs {
for _ in 0..100 {
rt.send_to(addr, Ping { reply_to: *dummy.addr() }).unwrap();
}
}
let handle = rt.run().unwrap();
let expected = 10 * 100;
let processed = wait_for_count(&counter, expected, Duration::from_secs(5));
handle.shutdown();
handle.join();
assert_eq!(
processed, expected,
"all {expected} healthy messages processed despite panicking peers"
);
}
/// Worker parking and shutdown latency.
///
/// Story: Workers park after idle time. We verify they wake quickly on new
/// messages, that messages sent after run() are delivered, and that shutdown
/// wakes all parked workers promptly.
#[test]
fn worker_parking_and_shutdown() {
// ── Part A: Parked workers wake on send ──
let rt = std_runtime(RuntimeConfig { num_threads: 2, ..Default::default() });
let addr = rt.spawn(PingPongActor).unwrap();
let inbox = rt.new_inbox::<Pong>().unwrap();
let handle = rt.run().unwrap();
std::thread::sleep(Duration::from_millis(50)); // Let workers park.
let before = Instant::now();
handle.runtime.send_to(addr, Ping { reply_to: *inbox.addr() }).unwrap();
let reply = poll_inbox(&inbox, Duration::from_secs(1));
let latency = before.elapsed();
assert!(reply.is_some(), "parked worker should wake and process");
assert!(latency.as_millis() < 100, "wake latency should be <100ms, was {:?}", latency);
// ── Part B: Send after run() delivers ──
let addr2 = handle.runtime.spawn(PingPongActor).unwrap();
let inbox2 = handle.runtime.new_inbox::<Pong>().unwrap();
std::thread::sleep(Duration::from_millis(10));
handle.runtime.send_to(addr2, Ping { reply_to: *inbox2.addr() }).unwrap();
let reply2 = poll_inbox(&inbox2, Duration::from_secs(5));
assert!(reply2.is_some(), "message sent after run() must be delivered");
handle.shutdown();
handle.join();
// ── Part C: Shutdown wakes parked workers quickly ──
let rt2 = std_runtime(RuntimeConfig { num_threads: 4, ..Default::default() });
let h2 = rt2.run().unwrap();
std::thread::sleep(Duration::from_millis(50)); // Let workers park.
let before = Instant::now();
h2.shutdown();
h2.join();
let shutdown_time = before.elapsed();
assert!(
shutdown_time.as_millis() < 500,
"shutdown should complete quickly with parked workers, took {:?}",
shutdown_time
);
}
/// Sustained throughput with no message loss.
///
/// Story: We send 10 batches of 100 messages, ticking between batches on a
/// single-threaded runtime. Each batch must make forward progress, and after
/// draining, all 1000 messages are accounted for.
#[test]
fn sustained_throughput_no_message_loss() {
let rt = std_runtime(RuntimeConfig::default());
let counter = Arc::new(AtomicUsize::new(0));
let dummy = rt.new_inbox::<Pong>().unwrap();
let addr = rt.spawn(CountingPingActor { counter: counter.clone() }).unwrap();
for batch in 0..10 {
for _ in 0..100 {
rt.send_to(addr, Ping { reply_to: *dummy.addr() }).unwrap();
}
tick_n(&rt, 5);
let processed = counter.load(Ordering::SeqCst);
assert!(
processed > batch * 50,
"batch {batch}: expected progress, only {processed} processed"
);
}
// Drain remaining.
tick_n(&rt, 100);
let total = counter.load(Ordering::SeqCst);
assert_eq!(total, 1000, "sustained load should not drop any messages");
}
/// Load-aware actor placement.
///
/// Story: A fresh runtime falls back to round-robin (even distribution).
/// Under imbalanced load, new actors bias toward the lighter worker.
/// A single-worker runtime degrades gracefully.
#[test]
fn load_aware_actor_placement() {
// ── Part A: Round-robin fallback on fresh runtime (4 workers, 100 actors) ──
let rt = std_runtime(RuntimeConfig { num_threads: 4, ..Default::default() });
for _ in 0..100 {
rt.spawn(CounterActor { count: 0 }).unwrap();
}
let handle = rt.run().unwrap();
std::thread::sleep(Duration::from_millis(20));
let stats = handle.runtime.stats();
handle.shutdown();
handle.join();
for w in &stats.workers {
assert!(
w.num_actors >= 20 && w.num_actors <= 30,
"worker {} has {} actors, expected ~25 (round-robin)",
w.id, w.num_actors
);
}
// ── Part B: Imbalanced load biases toward lighter worker ──
let rt2 = std_runtime(RuntimeConfig { num_threads: 2, ..Default::default() });
let mut addrs = Vec::new();
for _ in 0..20 {
addrs.push(rt2.spawn(CounterActor { count: 0 }).unwrap());
}
let h2 = rt2.run().unwrap();
std::thread::sleep(Duration::from_millis(10));
// Bombard the first 10 actors (likely worker 0) with messages.
for addr in &addrs[..10] {
for _ in 0..50 {
let _ = h2.runtime.send_to(*addr, Increment { reply_to: *addr });
}
}
std::thread::sleep(Duration::from_millis(20));
// Spawn 10 more — should bias toward lighter worker.
for _ in 0..10 {
h2.runtime.spawn(CounterActor { count: 0 }).unwrap();
}
std::thread::sleep(Duration::from_millis(20));
let stats2 = h2.runtime.stats();
h2.shutdown();
h2.join();
let total_actors: usize = stats2.workers.iter().map(|w| w.num_actors).sum();
assert!(total_actors >= 20, "expected at least 20 actors, got {total_actors}");
assert!(
stats2.workers.iter().all(|w| w.num_actors > 0),
"both workers should have actors: {:?}",
stats2.workers.iter().map(|w| w.num_actors).collect::<Vec<_>>()
);
// ── Part C: Single-worker degrades gracefully ──
let rt3 = std_runtime(RuntimeConfig::default());
for _ in 0..50 {
rt3.spawn(CounterActor { count: 0 }).unwrap();
}
tick_n(&rt3, 10);
let stats3 = rt3.stats();
assert_eq!(stats3.workers.len(), 1);
assert_eq!(stats3.workers[0].num_actors, 50);
}

742
tests/std_extension.rs Normal file
View file

@ -0,0 +1,742 @@
//! StdExtension Tests — higher-level patterns from swactor-std.
//!
//! Covers: naming registry, groups/pub-sub, ask pattern, supervision
//! strategies and restart policies, and router work distribution.
mod common;
use common::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
// ── Local actors ────────────────────────────────────────────────────────────
/// Looks up a peer by name using ctx.where_is().
struct NameLookupActor {
target_name: &'static str,
reply_to: ActorAddress,
}
impl ActorInterface for NameLookupActor {
type Incoming = Ping;
type Response = ();
fn handle(&mut self, ctx: &Ctx, _msg: Ping) {
if let Some(peer) = ctx.where_is(self.target_name) {
ctx.send(self.reply_to, MyAddr(peer)).unwrap();
}
}
}
/// Spawns a named child from a handler.
struct NamedSpawnerActor {
reply_to: ActorAddress,
}
impl ActorInterface for NamedSpawnerActor {
type Incoming = Ping;
type Response = ();
fn handle(&mut self, ctx: &Ctx, _msg: Ping) {
if let Ok(addr) = ctx.spawn_named("child", PingPongActor) {
ctx.send(self.reply_to, MyAddr(addr)).unwrap();
}
}
}
/// Panics after `trigger` messages.
struct PanicAfterN {
trigger: usize,
count: usize,
counter: Arc<AtomicUsize>,
}
impl ActorInterface for PanicAfterN {
type Incoming = Ping;
type Response = ();
fn handle(&mut self, ctx: &Ctx, msg: Ping) {
self.count += 1;
self.counter.fetch_add(1, Ordering::SeqCst);
let _ = ctx.send(msg.reply_to, Pong);
if self.count >= self.trigger {
panic!("intentional panic at message {}", self.count);
}
}
}
/// Stops itself on first message.
struct StopsAfterFirst;
impl ActorInterface for StopsAfterFirst {
type Incoming = Ping;
type Response = ();
fn handle(&mut self, ctx: &Ctx, _msg: Ping) {
ctx.stop_self();
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Naming Registry
// ═══════════════════════════════════════════════════════════════════════════
/// Full naming lifecycle: register, lookup, send, duplicate fails, auto-unregister
/// on stop and panic, name reuse, registered_names list, manual unregister.
#[test]
fn naming_registry_lifecycle() {
let rt = std_runtime(RuntimeConfig::default());
let inbox = rt.new_inbox::<Pong>().unwrap();
// Register "alice", lookup, send Ping → Pong
let alice = rt.spawn_named("alice", PingPongActor).unwrap();
assert_eq!(rt.where_is("alice"), Some(alice));
rt.send_to(alice, Ping { reply_to: *inbox.addr() }).unwrap();
rt.tick();
assert!(inbox.try_recv().is_some(), "named actor processes messages");
// Duplicate fails, original binding preserved
assert!(rt.spawn_named("alice", PingPongActor).is_err());
assert_eq!(rt.where_is("alice"), Some(alice));
// Unknown name → None
assert_eq!(rt.where_is("ghost"), None);
// Stop "alice" → name freed
rt.stop_actor(alice).unwrap();
rt.tick();
assert_eq!(rt.where_is("alice"), None, "name freed after stop");
// Reuse the name
let alice2 = rt.spawn_named("alice", PingPongActor).unwrap();
assert_ne!(alice, alice2);
assert_eq!(rt.where_is("alice"), Some(alice2));
// Panic also frees the name
let bob = rt.spawn_named("bob", PanicActor).unwrap();
rt.tick();
rt.send_to(bob, PanicMsg).unwrap();
rt.tick();
assert_eq!(rt.where_is("bob"), None, "name freed after panic");
let _bob2 = rt.spawn_named("bob", PingPongActor).unwrap();
assert!(rt.where_is("bob").is_some());
// registered_names enumerates all
rt.spawn_named("gamma", PingPongActor).unwrap();
let mut names = rt.registered_names();
names.sort();
assert!(names.contains(&"alice".to_string()));
assert!(names.contains(&"bob".to_string()));
assert!(names.contains(&"gamma".to_string()));
// Manual unregister: name freed but actor lives
let charlie_inbox = rt.new_inbox::<Pong>().unwrap();
let charlie = rt.spawn_named("charlie", PingPongActor).unwrap();
rt.tick();
let removed = rt.unregister("charlie");
assert_eq!(removed, Some(charlie));
assert_eq!(rt.where_is("charlie"), None, "name freed by unregister");
rt.send_to(charlie, Ping { reply_to: *charlie_inbox.addr() }).unwrap();
rt.tick();
assert!(charlie_inbox.try_recv().is_some(), "actor still alive after name unregistered");
}
/// Actors resolve and register names from handlers using ctx.
#[test]
fn naming_from_actor_handlers() {
let rt = std_runtime(RuntimeConfig::default());
let inbox = rt.new_inbox::<MyAddr>().unwrap();
// ctx.where_is from handler
let target = rt.spawn_named("target", PingPongActor).unwrap();
let looker = rt.spawn(NameLookupActor {
target_name: "target",
reply_to: *inbox.addr(),
}).unwrap();
rt.tick();
rt.send_to(looker, Ping { reply_to: ActorAddress::default() }).unwrap();
tick_n(&rt, 3);
assert_eq!(inbox.try_recv(), Some(MyAddr(target)), "ctx.where_is resolves");
// ctx.spawn_named from handler
let spawner = rt.spawn(NamedSpawnerActor { reply_to: *inbox.addr() }).unwrap();
rt.tick();
rt.send_to(spawner, Ping { reply_to: ActorAddress::default() }).unwrap();
tick_n(&rt, 3);
let child_addr = inbox.try_recv().expect("child address returned");
assert_eq!(rt.where_is("child"), Some(child_addr.0), "name registered from handler");
}
// ═══════════════════════════════════════════════════════════════════════════
// Groups / Pub-Sub
// ═══════════════════════════════════════════════════════════════════════════
/// Full groups lifecycle: join, publish broadcasts, leave stops delivery,
/// dead actor auto-removed, multi-group cleanup, empty group deleted,
/// join and publish from handlers.
#[test]
fn groups_pub_sub_lifecycle() {
let rt = std_runtime(RuntimeConfig::default());
let inbox = rt.new_inbox::<Pong>().unwrap();
// Join 3 actors, publish → all 3 get it
let a = rt.spawn(PingPongActor).unwrap();
let b = rt.spawn(PingPongActor).unwrap();
let c = rt.spawn(PingPongActor).unwrap();
rt.join_group(a, "workers");
rt.join_group(b, "workers");
rt.join_group(c, "workers");
rt.tick();
let count = rt.publish_to("workers", Ping { reply_to: *inbox.addr() });
assert_eq!(count, 3, "3 members, 3 messages sent");
rt.tick();
let mut pongs = 0;
while inbox.try_recv().is_some() { pongs += 1; }
assert_eq!(pongs, 3, "all 3 received");
// Leave stops delivery
rt.leave_group(c, "workers");
let count = rt.publish_to("workers", Ping { reply_to: *inbox.addr() });
assert_eq!(count, 2, "2 after leave");
rt.tick();
let mut pongs = 0;
while inbox.try_recv().is_some() { pongs += 1; }
assert_eq!(pongs, 2);
// Dead actor auto-removed
rt.stop_actor(b).unwrap();
rt.tick();
let count = rt.publish_to("workers", Ping { reply_to: *inbox.addr() });
assert_eq!(count, 1, "dead actor removed");
// Multi-group cleanup: actor in alpha/beta/gamma dies → all cleaned
let rt = std_runtime(RuntimeConfig::default());
let actor = rt.spawn(PingPongActor).unwrap();
rt.join_group(actor, "alpha");
rt.join_group(actor, "beta");
rt.join_group(actor, "gamma");
rt.tick();
rt.stop_actor(actor).unwrap();
rt.tick();
assert!(rt.group_members("alpha").is_empty());
assert!(rt.group_members("beta").is_empty());
assert!(rt.group_members("gamma").is_empty());
// Empty group auto-deleted
let rt = std_runtime(RuntimeConfig::default());
let actor = rt.spawn(PingPongActor).unwrap();
rt.join_group(actor, "temp");
assert!(rt.groups().contains(&"temp".to_string()));
rt.leave_group(actor, "temp");
assert!(!rt.groups().contains(&"temp".to_string()), "empty group removed");
// Empty group query
let rt = std_runtime(RuntimeConfig::default());
assert!(rt.group_members("nonexistent").is_empty());
// ctx.join_group from on_start
struct GroupJoiner;
impl ActorInterface for GroupJoiner {
type Incoming = Ping;
type Response = ();
fn on_start(&mut self, ctx: &Ctx) {
ctx.join_group("auto-joined");
}
fn handle(&mut self, _ctx: &Ctx, _msg: Ping) {}
}
let rt = std_runtime(RuntimeConfig::default());
let x = rt.spawn(GroupJoiner).unwrap();
let y = rt.spawn(GroupJoiner).unwrap();
rt.tick();
let members = rt.group_members("auto-joined");
assert_eq!(members.len(), 2);
assert!(members.contains(&x));
assert!(members.contains(&y));
// ctx.publish from handler
#[derive(Clone)]
struct BroadcastCmd { reply_to: ActorAddress }
struct Broadcaster;
impl ActorInterface for Broadcaster {
type Incoming = BroadcastCmd;
type Response = ();
fn on_start(&mut self, ctx: &Ctx) {
ctx.join_group("bcast");
}
fn handle(&mut self, ctx: &Ctx, msg: BroadcastCmd) {
ctx.publish("bcast", Ping { reply_to: msg.reply_to });
}
}
let rt = std_runtime(RuntimeConfig::default());
let inbox = rt.new_inbox::<Pong>().unwrap();
let p1 = rt.spawn(PingPongActor).unwrap();
let p2 = rt.spawn(PingPongActor).unwrap();
rt.join_group(p1, "bcast");
rt.join_group(p2, "bcast");
let broadcaster = rt.spawn(Broadcaster).unwrap();
rt.tick();
rt.send_to(broadcaster, BroadcastCmd { reply_to: *inbox.addr() }).unwrap();
tick_n(&rt, 3);
let mut pongs = 0;
while inbox.try_recv().is_some() { pongs += 1; }
assert!(pongs >= 2, "at least 2 PingPong members replied, got {pongs}");
}
// ═══════════════════════════════════════════════════════════════════════════
// Ask Pattern
// ═══════════════════════════════════════════════════════════════════════════
/// Ask pattern: basic ask, repeated asks track state, try_recv before/after
/// tick, dead actor times out.
#[test]
fn ask_pattern() {
let rt = std_runtime(RuntimeConfig::default());
// Basic ask
let actor = rt.spawn(PingPongActor).unwrap();
rt.tick();
let pong: Pong = rt.ask(actor, |reply_to| Ping { reply_to })
.unwrap().recv_ticking(&rt, 10).unwrap();
assert_eq!(pong, Pong);
// Repeated asks track state
let counter = rt.spawn(CounterActor { count: 0 }).unwrap();
rt.tick();
let c1: Count = rt.ask(counter, |reply_to| Increment { reply_to })
.unwrap().recv_ticking(&rt, 10).unwrap();
let c2: Count = rt.ask(counter, |reply_to| Increment { reply_to })
.unwrap().recv_ticking(&rt, 10).unwrap();
let c3: Count = rt.ask(counter, |reply_to| Increment { reply_to })
.unwrap().recv_ticking(&rt, 10).unwrap();
assert_eq!((c1, c2, c3), (Count(1), Count(2), Count(3)));
// try_recv: None before tick, Some after
let rt = std_runtime(RuntimeConfig::default());
let actor = rt.spawn(PingPongActor).unwrap();
rt.tick();
let ask = rt.ask::<Ping, Pong>(actor, |reply_to| Ping { reply_to }).unwrap();
assert!(ask.try_recv().is_none(), "no response before tick");
rt.tick();
assert_eq!(ask.try_recv(), Some(Pong));
// Dead actor → timeout
let rt = std_runtime(RuntimeConfig::default());
let actor = rt.spawn(PingPongActor).unwrap();
rt.tick();
rt.stop_actor(actor).unwrap();
rt.tick();
if let Ok(ask) = rt.ask::<Ping, Pong>(actor, |reply_to| Ping { reply_to }) {
assert!(ask.recv_ticking(&rt, 5).is_err(), "timeout with dead actor");
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Supervision
// ═══════════════════════════════════════════════════════════════════════════
/// Restart policies: permanent always restarts, transient only on panic,
/// temporary never restarts, meltdown after max_restarts.
#[test]
fn supervision_restart_policies() {
// Permanent child panics → restarted
let rt = std_runtime(RuntimeConfig::default());
let counter = Arc::new(AtomicUsize::new(0));
let counter_c = counter.clone();
let inbox = rt.new_inbox::<Pong>().unwrap();
let sup = Supervisor::new(
SupervisorStrategy::OneForOne, 5,
vec![ChildSpec::new("worker", RestartPolicy::Permanent, move |ctx| {
ctx.spawn(PanicAfterN { trigger: 2, count: 0, counter: counter_c.clone() })
})],
);
let sup_addr = rt.spawn(sup).unwrap();
tick_n(&rt, 2);
let child = rt.stats().actors.iter()
.find(|(a, _)| *a != sup_addr).map(|(a, _)| *a).unwrap();
rt.send_to(child, Ping { reply_to: *inbox.addr() }).unwrap();
rt.tick();
assert_eq!(counter.load(Ordering::SeqCst), 1);
rt.send_to(child, Ping { reply_to: *inbox.addr() }).unwrap();
tick_n(&rt, 5); // panics, supervisor restarts
assert_eq!(rt.stats().workers[0].num_actors, 2, "supervisor + restarted child");
// Transient stops normally → NOT restarted
let rt = std_runtime(RuntimeConfig::default());
let sup = Supervisor::new(
SupervisorStrategy::OneForOne, 5,
vec![ChildSpec::new("worker", RestartPolicy::Transient, |ctx| {
ctx.spawn(StopsAfterFirst)
})],
);
let sup_addr = rt.spawn(sup).unwrap();
tick_n(&rt, 2);
let child = rt.stats().actors.iter()
.find(|(a, _)| *a != sup_addr).map(|(a, _)| *a).unwrap();
rt.send_to(child, Ping { reply_to: ActorAddress::default() }).unwrap();
tick_n(&rt, 4);
assert_eq!(rt.stats().workers[0].num_actors, 1, "transient+normal → no restart");
// Transient panics → restarted
let rt = std_runtime(RuntimeConfig::default());
let counter = Arc::new(AtomicUsize::new(0));
let counter_c = counter.clone();
let sup = Supervisor::new(
SupervisorStrategy::OneForOne, 5,
vec![ChildSpec::new("worker", RestartPolicy::Transient, move |ctx| {
ctx.spawn(PanicAfterN { trigger: 1, count: 0, counter: counter_c.clone() })
})],
);
let sup_addr = rt.spawn(sup).unwrap();
tick_n(&rt, 2);
let child = rt.stats().actors.iter()
.find(|(a, _)| *a != sup_addr).map(|(a, _)| *a).unwrap();
let inbox = rt.new_inbox::<Pong>().unwrap();
rt.send_to(child, Ping { reply_to: *inbox.addr() }).unwrap();
tick_n(&rt, 5);
assert_eq!(rt.stats().workers[0].num_actors, 2, "transient+panic → restarted");
// Temporary never restarts
let rt = std_runtime(RuntimeConfig::default());
let sup = Supervisor::new(
SupervisorStrategy::OneForOne, 5,
vec![ChildSpec::new("worker", RestartPolicy::Temporary, |ctx| ctx.spawn(PanicActor))],
);
let sup_addr = rt.spawn(sup).unwrap();
tick_n(&rt, 2);
let child = rt.stats().actors.iter()
.find(|(a, _)| *a != sup_addr).map(|(a, _)| *a).unwrap();
rt.send_to(child, PanicMsg).unwrap();
tick_n(&rt, 4);
assert_eq!(rt.stats().workers[0].num_actors, 1, "temporary → no restart");
// Meltdown: max_restarts=2, crash 3 times → supervisor stops
let rt = std_runtime(RuntimeConfig::default());
let counter = Arc::new(AtomicUsize::new(0));
let sup = Supervisor::new(
SupervisorStrategy::OneForOne, 2,
vec![ChildSpec::new("crasher", RestartPolicy::Permanent, {
let c = counter.clone();
move |ctx| ctx.spawn(PanicAfterN { trigger: 1, count: 0, counter: c.clone() })
})],
);
let sup_addr = rt.spawn(sup).unwrap();
tick_n(&rt, 2);
for _ in 0..3 {
if let Some((child, _)) = rt.stats().actors.iter()
.find(|(a, _)| *a != sup_addr)
{
let inbox = rt.new_inbox::<Pong>().unwrap();
let _ = rt.send_to(*child, Ping { reply_to: *inbox.addr() });
tick_n(&rt, 5);
}
}
let sup_alive = rt.stats().actors.iter().any(|(a, _)| *a == sup_addr);
assert!(!sup_alive, "supervisor stopped after exceeding max_restarts");
}
/// Strategies: OneForOne, OneForAll, RestForOne. Stopping supervisor kills children.
#[test]
fn supervision_strategies() {
// OneForOne: only failed child restarted
let rt = std_runtime(RuntimeConfig::default());
let counter_a = Arc::new(AtomicUsize::new(0));
let counter_b = Arc::new(AtomicUsize::new(0));
let sup = Supervisor::new(
SupervisorStrategy::OneForOne, 5,
vec![
ChildSpec::new("crasher", RestartPolicy::Permanent, {
let c = counter_a.clone();
move |ctx| ctx.spawn_named("ofo_a", PanicAfterN {
trigger: 1, count: 0, counter: c.clone(),
})
}),
ChildSpec::new("stable", RestartPolicy::Permanent, {
let c = counter_b.clone();
move |ctx| ctx.spawn_named("ofo_b", CountingPingActor { counter: c.clone() })
}),
],
);
rt.spawn(sup).unwrap();
tick_n(&rt, 2);
let child_a = rt.where_is("ofo_a").unwrap();
let child_b = rt.where_is("ofo_b").unwrap();
let inbox = rt.new_inbox::<Pong>().unwrap();
rt.send_to(child_a, Ping { reply_to: *inbox.addr() }).unwrap();
tick_n(&rt, 5);
let child_b_after = rt.where_is("ofo_b").unwrap();
assert_eq!(child_b, child_b_after, "child_b unchanged in OneForOne");
rt.send_to(child_b, Ping { reply_to: *inbox.addr() }).unwrap();
rt.tick();
assert!(counter_b.load(Ordering::SeqCst) >= 1, "child_b still processing");
// OneForAll: all children restarted
let rt = std_runtime(RuntimeConfig::default());
let sup = Supervisor::new(
SupervisorStrategy::OneForAll, 5,
vec![
ChildSpec::new("a", RestartPolicy::Permanent, {
let c = Arc::new(AtomicUsize::new(0));
move |ctx| ctx.spawn_named("ofa_a", PanicAfterN {
trigger: 1, count: 0, counter: c.clone(),
})
}),
ChildSpec::new("b", RestartPolicy::Permanent, {
let c = Arc::new(AtomicUsize::new(0));
move |ctx| ctx.spawn_named("ofa_b", CountingPingActor { counter: c.clone() })
}),
],
);
rt.spawn(sup).unwrap();
tick_n(&rt, 2);
let old_b = rt.where_is("ofa_b").unwrap();
let child_a = rt.where_is("ofa_a").unwrap();
let inbox = rt.new_inbox::<Pong>().unwrap();
rt.send_to(child_a, Ping { reply_to: *inbox.addr() }).unwrap();
tick_n(&rt, 8);
let new_b = rt.where_is("ofa_b").expect("ofa_b re-registered");
assert_ne!(old_b, new_b, "child_b restarted in OneForAll");
// RestForOne: failed child + later children restarted, earlier unaffected
let rt = std_runtime(RuntimeConfig::default());
let sup = Supervisor::new(
SupervisorStrategy::RestForOne, 5,
vec![
ChildSpec::new("a", RestartPolicy::Permanent, {
let c = Arc::new(AtomicUsize::new(0));
move |ctx| ctx.spawn_named("rfo_a", CountingPingActor { counter: c.clone() })
}),
ChildSpec::new("b", RestartPolicy::Permanent, {
let c = Arc::new(AtomicUsize::new(0));
move |ctx| ctx.spawn_named("rfo_b", PanicAfterN {
trigger: 1, count: 0, counter: c.clone(),
})
}),
ChildSpec::new("c", RestartPolicy::Permanent, {
let c = Arc::new(AtomicUsize::new(0));
move |ctx| ctx.spawn_named("rfo_c", CountingPingActor { counter: c.clone() })
}),
],
);
rt.spawn(sup).unwrap();
tick_n(&rt, 2);
let old_a = rt.where_is("rfo_a").unwrap();
let old_c = rt.where_is("rfo_c").unwrap();
let child_b = rt.where_is("rfo_b").unwrap();
let inbox = rt.new_inbox::<Pong>().unwrap();
rt.send_to(child_b, Ping { reply_to: *inbox.addr() }).unwrap();
tick_n(&rt, 8);
let new_a = rt.where_is("rfo_a").unwrap();
let new_c = rt.where_is("rfo_c").expect("rfo_c re-registered");
assert_eq!(old_a, new_a, "child_a unchanged in RestForOne");
assert_ne!(old_c, new_c, "child_c restarted in RestForOne");
// Stopping supervisor kills children
let rt = std_runtime(RuntimeConfig::default());
let sup = Supervisor::new(
SupervisorStrategy::OneForOne, 5,
vec![
ChildSpec::new("a", RestartPolicy::Permanent, |ctx| ctx.spawn(PingPongActor)),
ChildSpec::new("b", RestartPolicy::Permanent, |ctx| ctx.spawn(PingPongActor)),
],
);
let sup_addr = rt.spawn(sup).unwrap();
tick_n(&rt, 2);
assert_eq!(rt.stats().workers[0].num_actors, 3);
rt.stop_actor(sup_addr).unwrap();
tick_n(&rt, 5);
assert_eq!(rt.stats().workers[0].num_actors, 0, "stopping supervisor kills children");
}
/// handle_down dispatch and ctx.stop_actor from handler.
#[test]
fn handle_down_dispatch() {
// ctx.stop_actor from handler stops target
#[derive(Clone)]
struct StopCmd { target: ActorAddress }
struct Stopper;
impl ActorInterface for Stopper {
type Incoming = StopCmd;
type Response = ();
fn handle(&mut self, ctx: &Ctx, msg: StopCmd) {
let _ = ctx.stop_actor(msg.target);
}
}
let rt = std_runtime(RuntimeConfig::default());
let target = rt.spawn(PingPongActor).unwrap();
let stopper = rt.spawn(Stopper).unwrap();
rt.tick();
rt.send_to(stopper, StopCmd { target }).unwrap();
tick_n(&rt, 4);
assert!(rt.send_to(target, Ping { reply_to: ActorAddress::default() }).is_err(),
"target stopped by ctx.stop_actor");
assert!(rt.send_to(stopper, StopCmd { target }).is_ok(), "stopper still alive");
}
// ═══════════════════════════════════════════════════════════════════════════
// Router
// ═══════════════════════════════════════════════════════════════════════════
/// Router distributes work: round-robin is even, broadcast hits all, random
/// uses multiple workers. Dead workers replaced. Stop router kills workers.
/// Meltdown after max restarts.
#[test]
fn router_work_distribution() {
// Round-robin: 3 workers, 6 msgs → 2 each
let rt = std_runtime(RuntimeConfig::default());
let collected = Arc::new(std::sync::Mutex::new(Vec::new()));
struct Collector(Arc<std::sync::Mutex<Vec<(ActorAddress, usize)>>>);
#[derive(Clone)]
struct Work(usize);
impl ActorInterface for Collector {
type Incoming = Work;
type Response = ();
fn handle(&mut self, ctx: &Ctx, msg: Work) {
self.0.lock().unwrap().push((ctx.self_addr(), msg.0));
}
}
let c = collected.clone();
let router = Router::<Work>::new(
RoutingStrategy::RoundRobin, 3,
move |ctx| ctx.spawn(Collector(c.clone())), 10,
);
let router_addr = rt.spawn(router).unwrap();
rt.tick();
for i in 0..6 {
rt.send_to(router_addr, Work(i)).unwrap();
}
tick_n(&rt, 3);
let data = collected.lock().unwrap();
assert_eq!(data.len(), 6);
let mut per_worker = std::collections::HashMap::new();
for (addr, _) in data.iter() {
*per_worker.entry(*addr).or_insert(0usize) += 1;
}
assert_eq!(per_worker.len(), 3, "3 distinct workers");
for count in per_worker.values() {
assert_eq!(*count, 2, "each worker gets exactly 2");
}
// Broadcast: 5 msgs to 3 workers → 15 total
let rt = std_runtime(RuntimeConfig::default());
let total = Arc::new(AtomicUsize::new(0));
struct BCounter(Arc<AtomicUsize>);
#[derive(Clone)]
struct BPing;
impl ActorInterface for BCounter {
type Incoming = BPing;
type Response = ();
fn handle(&mut self, _ctx: &Ctx, _msg: BPing) {
self.0.fetch_add(1, Ordering::Relaxed);
}
}
let t = total.clone();
let router = Router::<BPing>::new(
RoutingStrategy::Broadcast, 3,
move |ctx| ctx.spawn(BCounter(t.clone())), 10,
);
let router_addr = rt.spawn(router).unwrap();
rt.tick();
for _ in 0..5 {
rt.send_to(router_addr, BPing).unwrap();
}
tick_n(&rt, 3);
assert_eq!(total.load(Ordering::Relaxed), 15, "5 broadcasts × 3 workers = 15");
// Random: 30 msgs → at least 2 workers used
let rt = std_runtime(RuntimeConfig::default());
let rcollected = Arc::new(std::sync::Mutex::new(Vec::new()));
struct RCollector(Arc<std::sync::Mutex<Vec<ActorAddress>>>);
#[derive(Clone)]
struct RWork;
impl ActorInterface for RCollector {
type Incoming = RWork;
type Response = ();
fn handle(&mut self, ctx: &Ctx, _msg: RWork) {
self.0.lock().unwrap().push(ctx.self_addr());
}
}
let c = rcollected.clone();
let router = Router::<RWork>::new(
RoutingStrategy::Random, 3,
move |ctx| ctx.spawn(RCollector(c.clone())), 10,
);
let router_addr = rt.spawn(router).unwrap();
rt.tick();
for _ in 0..30 {
rt.send_to(router_addr, RWork).unwrap();
}
tick_n(&rt, 3);
let data = rcollected.lock().unwrap();
let unique: std::collections::HashSet<_> = data.iter().collect();
assert!(unique.len() >= 2, "random uses at least 2 workers");
// Dead worker replaced
let rt = std_runtime(RuntimeConfig::default());
let spawn_count = Arc::new(AtomicUsize::new(0));
struct PanicOnFirst { first: bool }
#[derive(Clone)]
struct DWork;
impl ActorInterface for PanicOnFirst {
type Incoming = DWork;
type Response = ();
fn handle(&mut self, _ctx: &Ctx, _msg: DWork) {
if self.first { self.first = false; panic!("first message panic"); }
}
}
let sc = spawn_count.clone();
let router = Router::<DWork>::new(
RoutingStrategy::RoundRobin, 3,
move |ctx| { sc.fetch_add(1, Ordering::Relaxed); ctx.spawn(PanicOnFirst { first: sc.load(Ordering::Relaxed) == 1 }) },
10,
);
let router_addr = rt.spawn(router).unwrap();
rt.tick();
rt.send_to(router_addr, DWork).unwrap();
tick_n(&rt, 5);
assert!(spawn_count.load(Ordering::Relaxed) >= 4, "replacement spawned");
// Meltdown: max_restarts=2
let rt = std_runtime(RuntimeConfig::default());
struct AlwaysPanics;
#[derive(Clone)]
struct MWork;
impl ActorInterface for AlwaysPanics {
type Incoming = MWork;
type Response = ();
fn handle(&mut self, _ctx: &Ctx, _msg: MWork) { panic!("always"); }
}
let router = Router::<MWork>::new(
RoutingStrategy::RoundRobin, 1,
|ctx| ctx.spawn(AlwaysPanics), 2,
);
let router_addr = rt.spawn(router).unwrap();
rt.tick();
for _ in 0..3 {
rt.send_to(router_addr, MWork).unwrap();
tick_n(&rt, 5);
}
tick_n(&rt, 5);
assert_eq!(rt.stats().workers[0].num_actors, 0, "router melted down");
// Stop router kills workers
let rt = std_runtime(RuntimeConfig::default());
struct Dummy;
#[derive(Clone)]
struct SWork;
impl ActorInterface for Dummy {
type Incoming = SWork;
type Response = ();
fn handle(&mut self, _ctx: &Ctx, _msg: SWork) {}
}
let router = Router::<SWork>::new(
RoutingStrategy::RoundRobin, 3,
|ctx| ctx.spawn(Dummy), 10,
);
let router_addr = rt.spawn(router).unwrap();
rt.tick();
assert_eq!(rt.stats().workers[0].num_actors, 4);
rt.stop_actor(router_addr).unwrap();
tick_n(&rt, 5);
assert_eq!(rt.stats().workers[0].num_actors, 0, "stop router kills workers");
}

View file

@ -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<AtomicUsize>,
last_reason: Arc<std::sync::Mutex<Option<ExitReason>>>,
last_addr: Arc<std::sync::Mutex<Option<ActorAddress>>>,
}
#[derive(Clone)]
enum WatcherCmd {
WatchThis(ActorAddress),
UnwatchThis(ActorAddress),
}
impl ActorInterface for ExitWatcher {
type Incoming = WatcherCmd;
type Response = ();
fn handle(&mut self, ctx: &Ctx, msg: WatcherCmd) {
match msg {
WatcherCmd::WatchThis(target) => {
ctx.watch(target);
}
WatcherCmd::UnwatchThis(target) => {
ctx.unwatch(target);
}
}
}
fn on_actor_exit(&mut self, _ctx: &Ctx, exited: ActorExited) {
self.exit_count.fetch_add(1, Ordering::SeqCst);
*self.last_reason.lock().unwrap() = Some(exited.reason);
*self.last_addr.lock().unwrap() = Some(exited.addr);
}
}
impl ExitWatcher {
fn new() -> (Self, WatcherState) {
let exit_count = Arc::new(AtomicUsize::new(0));
let last_reason = Arc::new(std::sync::Mutex::new(None));
let last_addr = Arc::new(std::sync::Mutex::new(None));
let state = WatcherState {
exit_count: exit_count.clone(),
last_reason: last_reason.clone(),
last_addr: last_addr.clone(),
};
(
ExitWatcher {
exit_count,
last_reason,
last_addr,
},
state,
)
}
}
/// Shared state for inspecting what ExitWatcher observed.
struct WatcherState {
exit_count: Arc<AtomicUsize>,
last_reason: Arc<std::sync::Mutex<Option<ExitReason>>>,
last_addr: Arc<std::sync::Mutex<Option<ActorAddress>>>,
}
impl WatcherState {
fn count(&self) -> usize {
self.exit_count.load(Ordering::SeqCst)
}
fn last_reason(&self) -> Option<ExitReason> {
self.last_reason.lock().unwrap().clone()
}
fn last_addr(&self) -> Option<ActorAddress> {
*self.last_addr.lock().unwrap()
}
}
/// A silent actor that does nothing (for targets that shouldn't panic).
struct Sleeper;
#[derive(Clone)]
struct Noop;
impl ActorInterface for Sleeper {
type Incoming = Noop;
type Response = ();
fn handle(&mut self, _ctx: &Ctx, _msg: Noop) {}
}
// ── Helper ──────────────────────────────────────────────────────────────────
fn tick_n(rt: &Runtime, n: usize) {
for _ in 0..n {
rt.tick();
}
}
fn 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<AtomicUsize>,
}
#[derive(Clone)]
enum SupervisorMsg {
WatchThis(ActorAddress),
}
impl ActorInterface for Supervisor {
type Incoming = SupervisorMsg;
type Response = ();
fn handle(&mut self, ctx: &Ctx, msg: SupervisorMsg) {
match msg {
SupervisorMsg::WatchThis(target) => ctx.watch(target),
}
}
fn on_actor_exit(&mut self, ctx: &Ctx, _exited: ActorExited) {
// React: spawn a replacement
let replacement = ctx.spawn(Sleeper).unwrap();
let _ = replacement;
self.spawned_count.fetch_add(1, Ordering::SeqCst);
}
}
let target = rt.spawn(PanicOnCommand).unwrap();
let sup = rt.spawn(Supervisor { spawned_count: spawned.clone() }).unwrap();
rt.send_to(sup, SupervisorMsg::WatchThis(target)).unwrap();
tick_n(&rt, 3);
rt.send_to(target, PanicMsg).unwrap();
tick_n(&rt, 5);
assert_eq!(spawned.load(Ordering::SeqCst), 1, "supervisor should have spawned a replacement");
}

181
tools/fn_complexity.py Executable file
View file

@ -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()

164
tools/loc_analysis.py Executable file
View file

@ -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()