swactor/tests/proptest_runtime.rs

526 lines
18 KiB
Rust
Raw Normal View History

//! Property-based tests for the swactor runtime.
//!
//! Uses proptest for randomized testing and proptest-state-machine for
//! stateful property testing with automatic shrinking of failing sequences.
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::RuntimeConfig;
use swactor::runtime::{Ctx, Inbox, Runtime};
use swactor::std::{CtxTimers, StdExtension};
// ─── Shared Actor Types ────────────────────────────────────────────────────
#[derive(Clone, Debug)]
struct Ping(u64);
/// Echo: receives Ping, sends Ping back to reply_to address.
struct EchoActor {
reply_to: ActorAddress,
}
impl ActorInterface for EchoActor {
type Incoming = Ping;
type Response = ();
fn handle(&mut self, ctx: &Ctx, msg: Ping) {
let _ = ctx.send(self.reply_to, msg);
}
}
/// Counter: tracks message count, replies with count.
struct CounterActor {
count: u64,
reply_to: ActorAddress,
}
impl ActorInterface for CounterActor {
type Incoming = Ping;
type Response = ();
fn handle(&mut self, ctx: &Ctx, _msg: Ping) {
self.count += 1;
let _ = ctx.send(self.reply_to, Ping(self.count));
}
}
/// PanicActor: panics on first message.
struct PanicActor;
impl ActorInterface for PanicActor {
type Incoming = Ping;
type Response = ();
fn handle(&mut self, _ctx: &Ctx, _msg: Ping) {
panic!("intentional panic");
}
}
/// Noop: discards all messages silently.
struct NoopActor;
impl ActorInterface for NoopActor {
type Incoming = Ping;
type Response = ();
fn handle(&mut self, _ctx: &Ctx, _msg: Ping) {}
}
// ─── Simple Property Tests ─────────────────────────────────────────────────
proptest! {
/// Budget fairness: no actor processes more than budget messages per tick
/// when multiple actors have pending messages.
#[test]
fn budget_limits_per_actor_processing(
n_actors in 2usize..10,
msgs_per in 10usize..100,
budget in 1usize..32,
) {
let config = RuntimeConfig {
actor_message_budget: budget,
..Default::default()
};
let rt = Runtime::new(config);
let inbox = rt.new_inbox::<Ping>().unwrap();
let mut addrs = Vec::new();
for _ in 0..n_actors {
addrs.push(rt.spawn(CounterActor { count: 0, reply_to: *inbox.addr() }).unwrap());
}
rt.tick(); // on_start
// Send msgs_per messages to each actor
for addr in &addrs {
for v in 0..msgs_per as u64 {
rt.send_to(*addr, Ping(v)).unwrap();
}
}
// Single tick — each actor should process at most `budget` messages
rt.tick();
// Drain inbox to count replies per actor
// CounterActor replies with incrementing count, so max reply value = messages processed
let mut replies = Vec::new();
while let Some(msg) = inbox.try_recv() {
replies.push(msg.0);
}
// Total replies should be at most n_actors * budget
prop_assert!(
replies.len() <= n_actors * budget,
"Too many messages processed: {} > {} (n_actors={}, budget={})",
replies.len(), n_actors * budget, n_actors, budget,
);
}
/// 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())
.with_extension(Arc::new(StdExtension::new()));
let inbox = rt.new_inbox::<Ping>().unwrap();
struct TimerActor { target: ActorAddress, delay: u64 }
impl ActorInterface for TimerActor {
type Incoming = Ping;
type Response = ();
fn handle(&mut self, _ctx: &Ctx, _msg: Ping) {}
fn on_start(&mut self, ctx: &Ctx) {
ctx.send_after_ticks(self.target, Ping(42), self.delay);
}
}
let _addr = rt.spawn(TimerActor { target: *inbox.addr(), delay }).unwrap();
// Tick up to the expected fire tick
for tick in 1..=(delay + 1) {
rt.tick();
let msg = inbox.try_recv();
if tick <= delay {
prop_assert!(msg.is_none(), "Timer fired too early at tick {}", tick);
} else {
prop_assert!(msg.is_some(), "Timer should have fired at tick {}", tick);
}
}
// No second fire (one-shot)
rt.tick();
prop_assert!(inbox.try_recv().is_none(), "One-shot timer fired twice");
}
/// 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())
.with_extension(Arc::new(StdExtension::new()));
let inbox = rt.new_inbox::<Ping>().unwrap();
struct IntervalActor { target: ActorAddress, period: u64 }
impl ActorInterface for IntervalActor {
type Incoming = Ping;
type Response = ();
fn handle(&mut self, _ctx: &Ctx, _msg: Ping) {}
fn on_start(&mut self, ctx: &Ctx) {
ctx.send_interval_ticks(self.target, Ping(1), self.period);
}
}
let _addr = rt.spawn(IntervalActor { target: *inbox.addr(), period }).unwrap();
// Verify 3 consecutive fires
let mut fire_count = 0;
// Timer scheduled on tick 1 (on_start). First fire at tick 1+period.
for tick in 1..=(period * 3 + 2) {
rt.tick();
if let Some(_) = inbox.try_recv() {
fire_count += 1;
// First fire should be at tick (period + 1)
// Subsequent fires every `period` ticks after that
let expected_tick = period + 1 + (fire_count - 1) * period;
prop_assert_eq!(tick, expected_tick,
"Fire #{} at wrong tick (period={})", fire_count, period);
}
}
prop_assert!(fire_count >= 3, "Expected 3+ fires, got {} (period={})", fire_count, period);
}
/// Spawn N actors and verify all get unique addresses and appear in stats.
#[test]
fn spawn_n_actors_all_tracked(n in 1usize..50) {
let rt = Runtime::new(RuntimeConfig::default());
let mut addrs = Vec::new();
for _ in 0..n {
addrs.push(rt.spawn(NoopActor).unwrap());
}
rt.tick(); // process spawns
let stats = rt.stats();
prop_assert_eq!(stats.actors.len(), n, "Expected {} actors in stats", n);
// All addresses should be unique
let unique: std::collections::HashSet<_> = addrs.iter().collect();
prop_assert_eq!(unique.len(), n, "Duplicate addresses detected");
}
}
// ─── State Machine Test ────────────────────────────────────────────────────
//
// Reference model tracks expected runtime state. Transitions are random
// operations (spawn, send, tick, stop). After each transition, invariants
// are checked against the actual runtime.
#[derive(Clone, Debug)]
struct RefState {
/// actor_id -> is_alive (not stopped/poisoned)
actors: HashMap<usize, bool>,
/// actor_id -> messages sent (values, in order)
sent_messages: HashMap<usize, Vec<u64>>,
/// Number of ticks executed
tick_count: u64,
/// Next actor ID to assign
next_id: usize,
/// IDs of actors that will panic on first message
panic_actors: Vec<usize>,
}
#[derive(Clone, Debug)]
enum Transition {
/// Spawn a new echo actor
SpawnEcho,
/// Spawn a panic-on-first-message actor
SpawnPanic,
/// Send a numbered message to actor at index
Send { actor_idx: usize, value: u64 },
/// Run one tick
Tick,
/// Run N ticks
TickN(u8),
/// Stop actor at index gracefully
StopActor(usize),
/// Check runtime stats match reference
CheckStats,
}
struct SwactorModel;
impl ReferenceStateMachine for SwactorModel {
type State = RefState;
type Transition = Transition;
fn init_state() -> BoxedStrategy<Self::State> {
Just(RefState {
actors: HashMap::new(),
sent_messages: HashMap::new(),
tick_count: 0,
next_id: 0,
panic_actors: Vec::new(),
})
.boxed()
}
fn transitions(state: &Self::State) -> BoxedStrategy<Self::Transition> {
let has_actors = !state.actors.is_empty();
let has_alive = state.actors.values().any(|&alive| alive);
if !has_actors {
// Must spawn first
prop_oneof![
3 => Just(Transition::SpawnEcho),
1 => Just(Transition::SpawnPanic),
]
.boxed()
} else if !has_alive {
// All actors dead, spawn new ones or tick to clean up
prop_oneof![
3 => Just(Transition::SpawnEcho),
1 => Just(Transition::SpawnPanic),
1 => Just(Transition::Tick),
]
.boxed()
} else {
let n = state.actors.len();
prop_oneof![
3 => Just(Transition::SpawnEcho),
1 => Just(Transition::SpawnPanic),
10 => (0..n, 0u64..1000).prop_map(|(idx, val)| Transition::Send {
actor_idx: idx,
value: val,
}),
5 => Just(Transition::Tick),
2 => (1u8..5).prop_map(Transition::TickN),
2 => (0..n).prop_map(Transition::StopActor),
1 => Just(Transition::CheckStats),
]
.boxed()
}
}
fn apply(mut state: Self::State, transition: &Self::Transition) -> Self::State {
match transition {
Transition::SpawnEcho => {
let id = state.next_id;
state.next_id += 1;
state.actors.insert(id, true);
state.sent_messages.insert(id, Vec::new());
}
Transition::SpawnPanic => {
let id = state.next_id;
state.next_id += 1;
state.actors.insert(id, true);
state.sent_messages.insert(id, Vec::new());
state.panic_actors.push(id);
}
Transition::Send { actor_idx, value } => {
let alive_ids: Vec<usize> = state
.actors
.iter()
.filter(|(_, alive)| **alive)
.map(|(&id, _)| id)
.collect();
if !alive_ids.is_empty() {
let id = alive_ids[*actor_idx % alive_ids.len()];
state
.sent_messages
.entry(id)
.or_default()
.push(*value);
}
}
Transition::Tick => {
state.tick_count += 1;
}
Transition::TickN(n) => {
state.tick_count += *n as u64;
}
Transition::StopActor(idx) => {
let alive_ids: Vec<usize> = state
.actors
.iter()
.filter(|(_, alive)| **alive)
.map(|(&id, _)| id)
.collect();
if !alive_ids.is_empty() {
let id = alive_ids[*idx % alive_ids.len()];
state.actors.insert(id, false);
}
}
Transition::CheckStats => {}
}
state
}
fn preconditions(state: &Self::State, transition: &Self::Transition) -> bool {
match transition {
Transition::Send { .. } | Transition::StopActor(_) => {
state.actors.values().any(|&alive| alive)
}
_ => true,
}
}
}
// ─── Concrete System Under Test ────────────────────────────────────────────
struct SutState {
runtime: Runtime,
inbox: Inbox<Ping>,
/// Maps reference actor_id to actual ActorAddress
actor_map: HashMap<usize, ActorAddress>,
/// Reference IDs that are panic actors
panic_ids: Vec<usize>,
/// Tracks which actor IDs are alive (mirrors ref model)
alive: HashMap<usize, bool>,
/// Next ID for spawn
next_id: usize,
}
struct SwactorTest;
impl StateMachineTest for SwactorTest {
type SystemUnderTest = SutState;
type Reference = SwactorModel;
fn init_test(_ref_state: &RefState) -> Self::SystemUnderTest {
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<Ping>().unwrap();
SutState {
runtime: rt,
inbox,
actor_map: HashMap::new(),
panic_ids: Vec::new(),
alive: HashMap::new(),
next_id: 0,
}
}
fn apply(
mut sut: Self::SystemUnderTest,
_ref_state: &RefState,
transition: Transition,
) -> Self::SystemUnderTest {
match transition {
Transition::SpawnEcho => {
let id = sut.next_id;
sut.next_id += 1;
let addr = sut
.runtime
.spawn(EchoActor {
reply_to: *sut.inbox.addr(),
})
.unwrap();
sut.actor_map.insert(id, addr);
sut.alive.insert(id, true);
}
Transition::SpawnPanic => {
let id = sut.next_id;
sut.next_id += 1;
let addr = sut.runtime.spawn(PanicActor).unwrap();
sut.actor_map.insert(id, addr);
sut.alive.insert(id, true);
sut.panic_ids.push(id);
}
Transition::Send { actor_idx, value } => {
let alive_ids: Vec<usize> = sut
.alive
.iter()
.filter(|(_, alive)| **alive)
.map(|(&id, _)| id)
.collect();
if !alive_ids.is_empty() {
let id = alive_ids[actor_idx % alive_ids.len()];
if let Some(&addr) = sut.actor_map.get(&id) {
let _ = sut.runtime.send_to(addr, Ping(value));
}
}
}
Transition::Tick => {
sut.runtime.tick();
}
Transition::TickN(n) => {
for _ in 0..n {
sut.runtime.tick();
}
}
Transition::StopActor(idx) => {
let alive_ids: Vec<usize> = sut
.alive
.iter()
.filter(|(_, alive)| **alive)
.map(|(&id, _)| id)
.collect();
if !alive_ids.is_empty() {
let id = alive_ids[idx % alive_ids.len()];
sut.alive.insert(id, false);
if let Some(&addr) = sut.actor_map.get(&id) {
let _ = sut.runtime.stop_actor(addr);
}
}
}
Transition::CheckStats => {
let stats = sut.runtime.stats();
assert!(stats.num_workers >= 1);
for info in &stats.workers {
assert!(info.id < stats.num_workers);
}
}
}
sut
}
fn check_invariants(sut: &Self::SystemUnderTest, _ref_state: &RefState) {
let stats = sut.runtime.stats();
// Invariant 1: worker count is consistent
assert_eq!(stats.workers.len(), stats.num_workers);
// Invariant 2: all actors in stats are on valid workers
for (_, wid) in &stats.actors {
assert!(
*wid < stats.num_workers,
"Actor on worker {} but only {} workers",
wid,
stats.num_workers
);
}
// Invariant 3: per-worker actor count <= address map count
// (workers lag behind address map because they drain spawn queue on tick)
let worker_actor_count: usize = stats.workers.iter().map(|w| w.num_actors).sum();
assert!(
worker_actor_count <= stats.actors.len(),
"Worker actor count {} > address map count {}",
worker_actor_count,
stats.actors.len(),
);
// Invariant 4: inbox can be drained without panic
// (type-safety of inbox messages)
while let Some(_msg) = sut.inbox.try_recv() {
// Just verify no panic on try_recv
}
// Invariant 5: stats queries don't panic
let _total_processed: u64 = stats.workers.iter().map(|w| w.messages_processed).sum();
let _total_depth: usize = stats.workers.iter().map(|w| w.mailbox_depth).sum();
}
fn teardown(_sut: Self::SystemUnderTest) {
// Runtime drops normally
}
}
prop_state_machine! {
#![proptest_config(proptest::test_runner::Config {
cases: 128,
max_shrink_iters: 10_000,
.. proptest::test_runner::Config::default()
})]
/// Given a random sequence of spawn/send/tick/stop operations,
/// when applied to a swactor runtime,
/// then all invariants (worker consistency, mailbox safety, stats accuracy) hold.
#[test]
fn swactor_state_machine(sequential 1..40 => SwactorTest);
}