swactor/tests/runtime_mechanics.rs
Claude 25e791ae9b chore: clean up unused imports in test common module
Remove redundant AtomicUsize/Ordering/Arc re-exports from common/mod.rs
and runtime_mechanics.rs. Zero warnings now.

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

362 lines
12 KiB
Rust

mod common;
use common::*;
// ── Load-Aware Placement Tests ─────────────────────────────────────────────
/// Given a multi-threaded runtime where one worker has many more actors,
/// when new actors are spawned after a few ticks (so stats propagate),
/// then they should be placed on the lighter worker.
#[test]
fn load_aware_placement_prefers_lighter_worker() {
// 2 threads: intentionally imbalance by spawning many actors first
let rt = std_runtime(RuntimeConfig {
num_threads: 2,
..Default::default()
});
// Phase 1: Spawn 20 actors. With round-robin, they split ~10/10.
let mut addrs = Vec::new();
for _ in 0..20 {
addrs.push(rt.spawn(CounterActor { count: 0 }).unwrap());
}
// Run so stats propagate, then bombard worker 0's actors with messages
// to create mailbox depth imbalance.
let handle = rt.run().unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
// Send 500 messages to the first 10 actors (likely on worker 0).
for addr in &addrs[..10] {
for _ in 0..50 {
let _ = handle.runtime.send_to(*addr, Increment {
reply_to: *addr, // self-reply to keep mailbox depth up
});
}
}
std::thread::sleep(std::time::Duration::from_millis(20));
// Phase 2: Spawn 10 more actors. With load-aware placement,
// they should bias toward the lighter worker.
let mut late_addrs = Vec::new();
for _ in 0..10 {
late_addrs.push(handle.runtime.spawn(CounterActor { count: 0 }).unwrap());
}
std::thread::sleep(std::time::Duration::from_millis(20));
let stats = handle.runtime.stats();
handle.shutdown();
handle.join();
// Verify the system is operational — both workers should have actors
let total_actors: usize = stats.workers.iter().map(|w| w.num_actors).sum();
assert!(total_actors >= 20, "expected at least 20 actors, got {}", total_actors);
// The lighter worker should have gotten more of the late actors.
assert!(
stats.workers.iter().all(|w| w.num_actors > 0),
"both workers should have actors, got {:?}",
stats.workers.iter().map(|w| w.num_actors).collect::<Vec<_>>()
);
}
/// Given a single-threaded runtime (1 worker),
/// when many actors are spawned,
/// then all go to worker 0 regardless of load (no panic, no error).
#[test]
fn load_aware_placement_single_worker_degrades_gracefully() {
let rt = std_runtime(RuntimeConfig::default());
for _ in 0..50 {
rt.spawn(CounterActor { count: 0 }).unwrap();
}
// Tick several times to let stats update
for _ in 0..10 {
rt.tick();
}
let stats = rt.stats();
assert_eq!(stats.workers.len(), 1);
assert_eq!(stats.workers[0].num_actors, 50);
}
/// Given a fresh runtime with no prior ticks,
/// when actors are spawned in a burst,
/// then they distribute evenly (round-robin fallback when stats are all zero).
#[test]
fn load_aware_placement_falls_back_to_round_robin_on_fresh_runtime() {
let rt = std_runtime(RuntimeConfig {
num_threads: 4,
..Default::default()
});
// Spawn 100 actors before any ticks (all stats are zero)
for _ in 0..100 {
rt.spawn(CounterActor { count: 0 }).unwrap();
}
let handle = rt.run().unwrap();
std::thread::sleep(std::time::Duration::from_millis(20));
let stats = handle.runtime.stats();
handle.shutdown();
handle.join();
// With 4 workers and 100 actors, each should have ~25 (+-5).
for w in &stats.workers {
assert!(
w.num_actors >= 20 && w.num_actors <= 30,
"worker {} has {} actors, expected ~25 (round-robin)",
w.id, w.num_actors
);
}
}
// ── Mailbox Backpressure Tests ─────────────────────────────────────────────
/// Given a runtime with bounded mailboxes (capacity=10, DropNewest),
/// when 50 messages are sent to an actor before any ticks,
/// then only the first 10 are delivered and the rest are dropped.
#[test]
fn bounded_mailbox_drop_newest_caps_at_capacity() {
let rt = std_runtime(RuntimeConfig {
default_mailbox_capacity: 10,
mailbox_overflow: MailboxOverflow::DropNewest,
..Default::default()
});
let inbox = rt.new_inbox::<Count>().unwrap();
let addr = rt.spawn(CounterActor { count: 0 }).unwrap();
// Send 50 messages — only first 10 should be queued
for _ in 0..50 {
let _ = rt.send_to(addr, Increment { reply_to: *inbox.addr() });
}
// Tick enough times to process all queued messages
for _ in 0..20 {
rt.tick();
}
// Count replies — should be exactly 10 (the mailbox capacity)
let mut replies = 0;
while inbox.try_recv().is_some() {
replies += 1;
}
assert_eq!(replies, 10, "should deliver exactly mailbox_capacity messages");
// Stats should show drops
let stats = rt.stats();
let total_drops: u64 = stats.workers.iter().map(|w| w.messages_dropped).sum();
assert_eq!(total_drops, 40, "40 messages should have been dropped");
}
/// Given a runtime with bounded mailboxes (capacity=5, DropOldest),
/// when 10 messages are sent before any tick,
/// then only the 5 most recent messages are delivered.
#[test]
fn bounded_mailbox_drop_oldest_keeps_newest() {
let rt = std_runtime(RuntimeConfig {
default_mailbox_capacity: 5,
mailbox_overflow: MailboxOverflow::DropOldest,
..Default::default()
});
let inbox = rt.new_inbox::<Done>().unwrap();
let addr = rt.spawn(DoubleActor).unwrap();
// Send messages with values 0..10. DoubleActor replies Done(value * 2).
for i in 0..10 {
let _ = rt.send_to(addr, Forward {
value: i,
reply_to: *inbox.addr(),
});
}
for _ in 0..10 {
rt.tick();
}
// Collect all replies
let mut replies = Vec::new();
while let Some(Done(v)) = inbox.try_recv() {
replies.push(v);
}
assert_eq!(replies.len(), 5, "should deliver exactly 5 messages");
// The 5 most recent: values 5,6,7,8,9 -> doubled: 10,12,14,16,18
assert_eq!(replies, vec![10, 12, 14, 16, 18], "should keep the newest messages");
}
/// Given a runtime with unbounded mailboxes (capacity=0, the default),
/// when many messages are sent,
/// then all are delivered (backward compatibility).
#[test]
fn unbounded_mailbox_delivers_all_messages() {
let rt = std_runtime(RuntimeConfig::default());
let inbox = rt.new_inbox::<Count>().unwrap();
let addr = rt.spawn(CounterActor { count: 0 }).unwrap();
for _ in 0..200 {
let _ = rt.send_to(addr, Increment { reply_to: *inbox.addr() });
}
for _ in 0..50 {
rt.tick();
}
let mut replies = 0;
while inbox.try_recv().is_some() {
replies += 1;
}
assert_eq!(replies, 200, "all 200 messages should be delivered with unbounded mailbox");
let stats = rt.stats();
let total_drops: u64 = stats.workers.iter().map(|w| w.messages_dropped).sum();
assert_eq!(total_drops, 0, "no drops with unbounded mailbox");
}
/// Given bounded mailboxes with budget, when an actor processes messages
/// and frees mailbox space, then new messages should be accepted on subsequent ticks.
#[test]
fn bounded_mailbox_refills_after_processing() {
let rt = std_runtime(RuntimeConfig {
default_mailbox_capacity: 5,
actor_message_budget: 5,
mailbox_overflow: MailboxOverflow::DropNewest,
..Default::default()
});
let inbox = rt.new_inbox::<Count>().unwrap();
let addr = rt.spawn(CounterActor { count: 0 }).unwrap();
// Send first batch of 5 — fills mailbox exactly
for _ in 0..5 {
let _ = rt.send_to(addr, Increment { reply_to: *inbox.addr() });
}
// Tick to process all 5 (budget=5, capacity=5)
rt.tick();
// Send second batch of 5 — mailbox is empty, so all 5 should be accepted
for _ in 0..5 {
let _ = rt.send_to(addr, Increment { reply_to: *inbox.addr() });
}
rt.tick();
let mut replies = 0;
while inbox.try_recv().is_some() {
replies += 1;
}
assert_eq!(replies, 10, "all 10 messages across 2 batches should be processed");
let stats = rt.stats();
let total_drops: u64 = stats.workers.iter().map(|w| w.messages_dropped).sum();
assert_eq!(total_drops, 0, "no drops when mailbox drains between batches");
}
// ── Dead Actor Cleanup Tests ───────────────────────────────────────────────
/// Given an actor that panics and is poisoned,
/// when ticks continue,
/// then the actor is removed from stats and sends to its address fail.
#[test]
fn dead_actor_cleaned_up_from_stats_and_address_map() {
let rt = std_runtime(RuntimeConfig::default());
let good = rt.spawn(PingPongActor).unwrap();
let bad = rt.spawn(PanicActor).unwrap();
// Trigger panic
let _ = rt.send_to(bad, PanicMsg);
for _ in 0..5 { rt.tick(); }
let stats = rt.stats();
// Good actor still present, bad actor cleaned up
assert_eq!(stats.workers[0].num_actors, 1, "only the healthy actor should remain");
assert!(
stats.actors.iter().any(|(a, _)| *a == good),
"good actor should be in address map"
);
assert!(
!stats.actors.iter().any(|(a, _)| *a == bad),
"poisoned actor should be removed from address map"
);
// Sends to cleaned-up actor fail
let result = rt.send_to(bad, PanicMsg);
assert!(result.is_err(), "send to cleaned-up actor should fail");
}
/// Given many actors that all panic,
/// when ticks proceed,
/// then all are cleaned up and stats reflect zero actors.
#[test]
fn bulk_dead_actor_cleanup() {
let rt = std_runtime(RuntimeConfig::default());
let mut addrs = Vec::new();
for _ in 0..20 {
addrs.push(rt.spawn(PanicActor).unwrap());
}
// Trigger all panics
for &addr in &addrs {
let _ = rt.send_to(addr, PanicMsg);
}
for _ in 0..10 { rt.tick(); }
let stats = rt.stats();
assert_eq!(stats.workers[0].num_actors, 0, "all poisoned actors should be cleaned up");
assert_eq!(
stats.actors.len(), 0,
"address map should be empty after all actors poisoned"
);
}
// ── Actor Recovery Helpers ──────────────────────────────────────────────────
/// Handles Forward messages, replies Done(value * 2), panics on the panic_at-th message.
struct RestartTestActor {
count: usize,
panic_at: usize,
}
impl ActorInterface for RestartTestActor {
type Incoming = Forward;
type Response = Done;
fn handle(&mut self, ctx: &Ctx, msg: Forward) {
self.count += 1;
if self.count >= self.panic_at {
panic!("intentional panic at message {}", self.count);
}
let _ = ctx.send(msg.reply_to, Done(msg.value * 2));
}
}
// ── Actor Recovery Tests ───────────────────────────────────────────────────
/// Given an actor that panics,
/// when it panics,
/// then it is poisoned and future messages are discarded.
#[test]
fn non_restartable_actor_still_poisons_on_panic() {
let rt = std_runtime(RuntimeConfig::default());
let inbox = rt.new_inbox::<Done>().unwrap();
// Normal spawn — not restartable
let addr = rt.spawn(RestartTestActor { count: 0, panic_at: 1 }).unwrap();
let _ = rt.send_to(addr, Forward { value: 42, reply_to: *inbox.addr() });
for _ in 0..5 { rt.tick(); }
let stats = rt.stats();
let total_panics: u64 = stats.workers.iter().map(|w| w.panics).sum();
let total_restarts: u64 = stats.workers.iter().map(|w| w.restarts).sum();
assert_eq!(total_panics, 1, "should panic");
assert_eq!(total_restarts, 0, "should not restart (not restartable)");
}