swactor/tests/runtime_hooks.rs
Claude 29a16a335e refactor: split runtime_api.rs (4,622 lines) into 7 focused test files
Split monolithic test file into domain-specific test modules:
- common/mod.rs: shared messages, actors, helpers
- runtime_lifecycle.rs (57 tests): spawn, FIFO, threading, panic safety
- runtime_mechanics.rs (10): placement, backpressure, cleanup, recovery
- runtime_hooks.rs (12): on_start/on_stop, graceful stop
- runtime_timers.rs (6): one-shot and interval timers
- runtime_registry.rs (32): naming, monitoring, groups, ask pattern
- runtime_supervision.rs (20): supervisor + router
- runtime_hasher.rs (3): identity hasher correctness

All 140 tests pass.

Authored by Claude, lovingly guided by Zachery Aaron Shores-Chmielewski
2026-02-13 21:12:15 +07:00

454 lines
14 KiB
Rust

mod common;
use common::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
// ── Lifecycle Hook Helpers ────────────────────────────────────────────────
/// An actor that records lifecycle events to shared counters.
struct LifecycleActor {
started: Arc<AtomicUsize>,
stopped: Arc<AtomicUsize>,
handled: Arc<AtomicUsize>,
}
impl ActorInterface for LifecycleActor {
type Incoming = Ping;
type Response = Pong;
fn on_start(&mut self, _ctx: &Ctx) {
self.started.fetch_add(1, Ordering::Relaxed);
}
fn on_stop(&mut self, _ctx: &Ctx) {
self.stopped.fetch_add(1, Ordering::Relaxed);
}
fn handle(&mut self, ctx: &Ctx, msg: Ping) {
self.handled.fetch_add(1, Ordering::Relaxed);
let _ = ctx.send(msg.reply_to, Pong);
}
}
/// An actor that stops itself after processing N messages.
struct SelfStopActor {
count: usize,
stop_after: usize,
stopped: Arc<AtomicUsize>,
}
impl ActorInterface for SelfStopActor {
type Incoming = Forward;
type Response = Done;
fn on_stop(&mut self, _ctx: &Ctx) {
self.stopped.fetch_add(1, Ordering::Relaxed);
}
fn handle(&mut self, ctx: &Ctx, msg: Forward) {
self.count += 1;
let _ = ctx.send(msg.reply_to, Done(msg.value));
if self.count >= self.stop_after {
ctx.stop_self();
}
}
}
/// An actor that sends a farewell message in on_stop.
struct FarewellActor {
farewell_to: ActorAddress,
}
impl ActorInterface for FarewellActor {
type Incoming = Ping;
type Response = Pong;
fn on_stop(&mut self, ctx: &Ctx) {
let _ = ctx.send(self.farewell_to, Pong);
}
fn handle(&mut self, ctx: &Ctx, msg: Ping) {
let _ = ctx.send(msg.reply_to, Pong);
}
}
/// An actor whose on_start panics.
struct PanicOnStartActor {
handled: Arc<AtomicUsize>,
}
impl ActorInterface for PanicOnStartActor {
type Incoming = Ping;
type Response = Pong;
fn on_start(&mut self, _ctx: &Ctx) {
panic!("on_start panic");
}
fn handle(&mut self, _ctx: &Ctx, _msg: Ping) {
self.handled.fetch_add(1, Ordering::Relaxed);
}
}
/// Handles Forward messages, replies Done(value * 2), panics on the panic_at-th message.
/// (Needed for on_start_called_again_after_restart and stop_vs_panic tests.)
struct RestartTestActor {
count: usize,
panic_at: usize,
}
impl ActorInterface for RestartTestActor {
type Incoming = Forward;
type Response = Done;
fn handle(&mut self, ctx: &Ctx, msg: Forward) {
self.count += 1;
if self.count >= self.panic_at {
panic!("intentional panic at message {}", self.count);
}
let _ = ctx.send(msg.reply_to, Done(msg.value * 2));
}
}
// ── Lifecycle Hook Tests ──────────────────────────────────────────────────
/// Given an actor with on_start implemented,
/// when it is spawned and the runtime ticks,
/// then on_start is called exactly once before the first message.
#[test]
fn on_start_called_before_first_message() {
let started = Arc::new(AtomicUsize::new(0));
let stopped = Arc::new(AtomicUsize::new(0));
let handled = Arc::new(AtomicUsize::new(0));
let rt = std_runtime(RuntimeConfig::default());
let inbox = rt.new_inbox::<Pong>().unwrap();
let addr = rt.spawn(LifecycleActor {
started: started.clone(),
stopped: stopped.clone(),
handled: handled.clone(),
}).unwrap();
// First tick — should call on_start
rt.tick();
assert_eq!(started.load(Ordering::Relaxed), 1, "on_start called on first tick");
assert_eq!(handled.load(Ordering::Relaxed), 0, "no messages processed yet");
// Send messages and tick more
let _ = rt.send_to(addr, Ping { reply_to: *inbox.addr() });
rt.tick();
assert_eq!(started.load(Ordering::Relaxed), 1, "on_start not called again");
assert_eq!(handled.load(Ordering::Relaxed), 1, "message processed after on_start");
}
/// Given an actor with on_start,
/// when multiple actors are spawned,
/// then each gets its own on_start call exactly once.
#[test]
fn on_start_called_per_actor() {
let started = Arc::new(AtomicUsize::new(0));
let stopped = Arc::new(AtomicUsize::new(0));
let handled = Arc::new(AtomicUsize::new(0));
let rt = std_runtime(RuntimeConfig::default());
for _ in 0..5 {
let _ = rt.spawn(LifecycleActor {
started: started.clone(),
stopped: stopped.clone(),
handled: handled.clone(),
}).unwrap();
}
rt.tick();
assert_eq!(started.load(Ordering::Relaxed), 5, "on_start called for each of 5 actors");
// Subsequent ticks don't repeat on_start
rt.tick();
rt.tick();
assert_eq!(started.load(Ordering::Relaxed), 5, "on_start still 5 after more ticks");
}
/// Given an actor whose on_start panics,
/// when it is spawned and the runtime ticks,
/// then it is immediately poisoned and never processes messages.
#[test]
fn on_start_panic_poisons_actor() {
let handled = Arc::new(AtomicUsize::new(0));
let rt = std_runtime(RuntimeConfig::default());
let inbox = rt.new_inbox::<Pong>().unwrap();
let addr = rt.spawn(PanicOnStartActor { handled: handled.clone() }).unwrap();
let _ = rt.send_to(addr, Ping { reply_to: *inbox.addr() });
for _ in 0..5 { rt.tick(); }
assert_eq!(handled.load(Ordering::Relaxed), 0, "actor never processed messages");
let stats = rt.stats();
let total_panics: u64 = stats.workers.iter().map(|w| w.panics).sum();
assert_eq!(total_panics, 1, "on_start panic counted");
}
// ── Graceful Stop Tests ───────────────────────────────────────────────────
/// Given an actor that calls ctx.stop_self() after 3 messages,
/// when 5 messages are sent,
/// then only 3 are processed, the actor is removed, and on_stop is called.
#[test]
fn actor_can_stop_self() {
let stopped = Arc::new(AtomicUsize::new(0));
let rt = std_runtime(RuntimeConfig::default());
let inbox = rt.new_inbox::<Done>().unwrap();
let addr = rt.spawn(SelfStopActor {
count: 0,
stop_after: 3,
stopped: stopped.clone(),
}).unwrap();
for i in 0..5 {
let _ = rt.send_to(addr, Forward { value: i, reply_to: *inbox.addr() });
}
for _ in 0..10 { rt.tick(); }
// Only 3 messages should be processed (stop_self after 3rd)
let mut replies = Vec::new();
while let Some(Done(v)) = inbox.try_recv() {
replies.push(v);
}
assert_eq!(replies.len(), 3, "only 3 messages processed before stop");
assert!(replies.contains(&0));
assert!(replies.contains(&1));
assert!(replies.contains(&2));
assert_eq!(stopped.load(Ordering::Relaxed), 1, "on_stop called exactly once");
// Actor should be removed from address map
let stats = rt.stats();
assert_eq!(stats.actors.len(), 0, "stopped actor removed from address map");
}
/// Given a running actor,
/// when runtime.stop_actor(addr) is called,
/// then the actor stops, on_stop is called, and it's removed from the pool.
#[test]
fn runtime_can_stop_actor() {
let started = Arc::new(AtomicUsize::new(0));
let stopped = Arc::new(AtomicUsize::new(0));
let handled = Arc::new(AtomicUsize::new(0));
let rt = std_runtime(RuntimeConfig::default());
let inbox = rt.new_inbox::<Pong>().unwrap();
let addr = rt.spawn(LifecycleActor {
started: started.clone(),
stopped: stopped.clone(),
handled: handled.clone(),
}).unwrap();
// Let it start and process a message
let _ = rt.send_to(addr, Ping { reply_to: *inbox.addr() });
for _ in 0..3 { rt.tick(); }
assert_eq!(handled.load(Ordering::Relaxed), 1);
// Stop it externally
rt.stop_actor(addr).unwrap();
for _ in 0..3 { rt.tick(); }
assert_eq!(stopped.load(Ordering::Relaxed), 1, "on_stop called");
// Actor should be gone
let stats = rt.stats();
assert_eq!(stats.actors.len(), 0, "stopped actor removed");
assert_eq!(stats.workers[0].num_actors, 0);
}
/// Given a stopped actor,
/// when new messages are sent to it,
/// then sends return Err (address not found).
#[test]
fn send_to_stopped_actor_returns_error() {
let stopped = Arc::new(AtomicUsize::new(0));
let rt = std_runtime(RuntimeConfig::default());
let inbox = rt.new_inbox::<Done>().unwrap();
let addr = rt.spawn(SelfStopActor {
count: 0,
stop_after: 1,
stopped: stopped.clone(),
}).unwrap();
// One message triggers stop
let _ = rt.send_to(addr, Forward { value: 1, reply_to: *inbox.addr() });
for _ in 0..10 { rt.tick(); }
// Actor is now removed — send should fail
let result = rt.send_to(addr, Forward { value: 2, reply_to: *inbox.addr() });
assert!(result.is_err(), "send to stopped actor should return Err");
}
/// Given a gracefully stopped actor and a panicked actor,
/// then stats.stops and stats.panics track them separately.
#[test]
fn stop_vs_panic_tracked_separately_in_stats() {
let stopped = Arc::new(AtomicUsize::new(0));
let rt = std_runtime(RuntimeConfig::default());
let inbox = rt.new_inbox::<Done>().unwrap();
// Actor that stops itself after 1 message
let _stop_addr = rt.spawn(SelfStopActor {
count: 0,
stop_after: 1,
stopped: stopped.clone(),
}).unwrap();
// Actor that panics on first message
let panic_addr = rt.spawn(RestartTestActor { count: 0, panic_at: 1 }).unwrap();
let _ = rt.send_to(_stop_addr, Forward { value: 1, reply_to: *inbox.addr() });
let _ = rt.send_to(panic_addr, Forward { value: 1, reply_to: *inbox.addr() });
for _ in 0..10 { rt.tick(); }
let stats = rt.stats();
let total_stops: u64 = stats.workers.iter().map(|w| w.stops).sum();
let total_panics: u64 = stats.workers.iter().map(|w| w.panics).sum();
assert_eq!(total_stops, 1, "one graceful stop");
assert_eq!(total_panics, 1, "one panic");
}
/// Given an actor with on_stop that sends a farewell message,
/// when the actor is stopped,
/// then the farewell message is delivered.
#[test]
fn on_stop_can_send_messages() {
let rt = std_runtime(RuntimeConfig::default());
let inbox = rt.new_inbox::<Pong>().unwrap();
let addr = rt.spawn(FarewellActor {
farewell_to: *inbox.addr(),
}).unwrap();
// Let it start
rt.tick();
// Stop it
rt.stop_actor(addr).unwrap();
for _ in 0..5 { rt.tick(); }
// Should receive farewell Pong from on_stop
let farewell = inbox.try_recv();
assert_eq!(farewell, Some(Pong), "farewell message delivered from on_stop");
}
/// Given a supervisor with a child that panics and is restarted,
/// when the child is respawned by the supervisor,
/// then on_start is called again on the fresh instance.
#[test]
fn on_start_called_again_after_restart() {
let started = Arc::new(AtomicUsize::new(0));
let rt = std_runtime(RuntimeConfig::default());
let started_c = started.clone();
let _sup_addr = rt.spawn(Supervisor::new(
SupervisorStrategy::OneForOne,
5,
vec![ChildSpec::new("child", RestartPolicy::Permanent, move |ctx| {
ctx.spawn(LifecycleActor {
started: started_c.clone(),
stopped: Arc::new(AtomicUsize::new(0)),
handled: Arc::new(AtomicUsize::new(0)),
})
})],
)).unwrap();
// First tick: supervisor starts, spawns child, on_start called
for _ in 0..3 { rt.tick(); }
assert_eq!(started.load(Ordering::Relaxed), 1, "on_start called once");
}
/// Given an actor stopped via stop_actor() with messages already queued,
/// when the stop signal arrives after the queued messages (PoisonPill semantics),
/// then messages ahead of the signal are processed, then the actor stops.
#[test]
fn external_stop_is_queued_after_pending_messages() {
let stopped = Arc::new(AtomicUsize::new(0));
let handled = Arc::new(AtomicUsize::new(0));
let rt = std_runtime(RuntimeConfig::default());
let started = Arc::new(AtomicUsize::new(0));
let addr = rt.spawn(LifecycleActor {
started: started.clone(),
stopped: stopped.clone(),
handled: handled.clone(),
}).unwrap();
let inbox = rt.new_inbox::<Pong>().unwrap();
// Queue 10 messages, then stop — StopSignal is queued AFTER the 10
for _ in 0..10 {
let _ = rt.send_to(addr, Ping { reply_to: *inbox.addr() });
}
rt.stop_actor(addr).unwrap();
for _ in 0..10 { rt.tick(); }
// All 10 messages processed (they were ahead of StopSignal in the queue)
let total_handled = handled.load(Ordering::Relaxed);
assert_eq!(total_handled, 10, "all messages processed before stop signal");
assert_eq!(stopped.load(Ordering::Relaxed), 1, "on_stop called");
// Actor is removed
let stats = rt.stats();
assert_eq!(stats.actors.len(), 0, "stopped actor removed");
}
/// Given a running actor with no pending messages,
/// when stop_actor() is called and then new messages are sent,
/// then the stop takes priority and new messages are not processed.
#[test]
fn external_stop_before_new_messages_prevents_processing() {
let stopped = Arc::new(AtomicUsize::new(0));
let handled = Arc::new(AtomicUsize::new(0));
let started = Arc::new(AtomicUsize::new(0));
let rt = std_runtime(RuntimeConfig::default());
let inbox = rt.new_inbox::<Pong>().unwrap();
let addr = rt.spawn(LifecycleActor {
started: started.clone(),
stopped: stopped.clone(),
handled: handled.clone(),
}).unwrap();
// Let actor start
rt.tick();
// Stop first, then send messages
rt.stop_actor(addr).unwrap();
for _ in 0..5 {
let _ = rt.send_to(addr, Ping { reply_to: *inbox.addr() });
}
for _ in 0..10 { rt.tick(); }
// Stop signal was first in queue, so no messages processed
assert_eq!(handled.load(Ordering::Relaxed), 0, "no messages processed after stop");
assert_eq!(stopped.load(Ordering::Relaxed), 1, "on_stop called");
}
/// Given stop_actor is called on a nonexistent address,
/// then it returns Err.
#[test]
fn stop_nonexistent_actor_returns_error() {
let rt = std_runtime(RuntimeConfig::default());
let fake_addr = swactor::actor::ActorAddress::default();
let result = rt.stop_actor(fake_addr);
assert!(result.is_err(), "stop_actor on nonexistent address should return Err");
}