#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
#[cfg(feature = "unsafe-pool")]
use actor_kit::memory_pool::{BumpAllocator, MemoryPoolConfig};
use actor_kit::{
create_local_queue, ActorId, ActorScheduler, ChildSpec, ExitReason, Mailbox, MailboxConfig,
Message, MessagePayload, Priority, RestartPolicy, SchedulerConfig, SupervisionStrategy,
SupervisorTree, WorkStealer,
};
use actor_kit::{ActorExecutor, ExecutionResult};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
struct OrderRecorder {
order: Mutex<Vec<u8>>,
}
impl OrderRecorder {
fn new() -> Self {
Self {
order: Mutex::new(Vec::new()),
}
}
fn order(&self) -> Vec<u8> {
self.order.lock().unwrap().clone()
}
}
impl ActorExecutor for OrderRecorder {
fn execute(&self, _actor_id: &ActorId, message: &Message) -> ExecutionResult {
if let MessagePayload::Custom(v) = &message.payload {
self.order.lock().unwrap().extend(v.iter().copied());
}
ExecutionResult::Success {
fuel_consumed: 1,
response: None,
}
}
fn is_ready(&self, _actor_id: &ActorId) -> bool {
true
}
fn get_fuel(&self, _actor_id: &ActorId) -> Option<u64> {
None
}
fn reset(&self, _actor_id: &ActorId) -> actor_kit::Result<()> {
Ok(())
}
}
fn custom(priority: Priority, byte: u8) -> Message {
Message {
sender: None,
payload: MessagePayload::Custom(vec![byte]),
priority,
}
}
fn wait_processed(scheduler: &ActorScheduler, id: &ActorId, want: u64) {
let deadline = Instant::now() + Duration::from_secs(10);
while scheduler.registry().get_processed_count(id) < want {
assert!(
Instant::now() < deadline,
"timed out waiting for {want} processed messages"
);
std::thread::sleep(Duration::from_millis(5));
}
}
#[test]
fn knob_workers_changes_thread_count() {
for (workers, want) in [(1usize, 1usize), (2, 2)] {
let scheduler = ActorScheduler::new(SchedulerConfig::new().workers(workers));
scheduler.start().unwrap();
assert_eq!(
scheduler.stats().worker_count,
want,
"workers({workers}) must spawn {want} worker threads"
);
scheduler.stop();
}
}
#[test]
fn knob_mailbox_config_is_plumbed_into_registry() {
let tiny = ActorScheduler::new(SchedulerConfig {
mailbox_config: MailboxConfig::new(2),
..SchedulerConfig::default()
});
let id = tiny.spawn().unwrap();
tiny.set_actor_running(&id).unwrap();
tiny.try_send(id, custom(Priority::Normal, 1)).unwrap();
tiny.try_send(id, custom(Priority::Normal, 2)).unwrap();
assert!(
tiny.try_send(id, custom(Priority::Normal, 3)).is_err(),
"mailbox_config capacity must be honored by spawned actors"
);
let big = ActorScheduler::new(SchedulerConfig::default());
let id = big.spawn().unwrap();
big.set_actor_running(&id).unwrap();
for b in 0..3 {
big.try_send(id, custom(Priority::Normal, b)).unwrap();
}
}
#[test]
fn knob_priority_scheduling_changes_delivery_order() {
for (enabled, want) in [(true, vec![2u8, 1u8]), (false, vec![1u8, 2u8])] {
let recorder = Arc::new(OrderRecorder::new());
let mut config = SchedulerConfig::new().workers(1);
config.priority_scheduling = enabled;
let scheduler = ActorScheduler::with_executor(config, recorder.clone());
let id = scheduler.spawn().unwrap();
scheduler.set_actor_running(&id).unwrap();
scheduler.try_send(id, custom(Priority::Normal, 1)).unwrap();
scheduler
.try_send(id, custom(Priority::Critical, 2))
.unwrap();
scheduler.start().unwrap();
wait_processed(&scheduler, &id, 2);
let deadline = Instant::now() + Duration::from_secs(5);
while recorder.order().len() < 2 && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(5));
}
assert_eq!(
recorder.order(),
want,
"priority_scheduling={enabled} must deliver in order {want:?}"
);
scheduler.stop();
}
}
#[test]
fn knob_max_steal_batch_caps_batch_steals() {
use actor_kit::queue::Task;
fn task(byte: u8) -> Task {
Task {
actor_id: ActorId::new(),
message: custom(Priority::Normal, byte),
priority: Priority::Normal,
additional_messages: Vec::new(),
}
}
let (w1, s1) = create_local_queue();
for b in 0..4 {
w1.push(task(b));
}
let (w2, _s2) = create_local_queue();
let thief = WorkStealer::new(vec![s1]);
assert_eq!(thief.steal_batch(&w2, 0), 0);
assert!(w2.pop().is_none(), "max=0 must steal nothing");
let (w3, s3) = create_local_queue();
for b in 0..4 {
w3.push(task(b));
}
let (w4, _s4) = create_local_queue();
let thief = WorkStealer::new(vec![s3]);
let stolen = thief.steal_batch(&w4, 64);
assert!(stolen > 0, "max=64 must steal available tasks");
assert!(w3.pop().is_none(), "max=64 must drain the source worker");
}
#[test]
fn knob_max_steal_batch_extremes_still_make_progress() {
for max in [1usize, 64] {
let mut config = SchedulerConfig::new().workers(2);
config.max_steal_batch = max;
let scheduler = ActorScheduler::new(config);
let id = scheduler.spawn().unwrap();
scheduler.set_actor_running(&id).unwrap();
scheduler.start().unwrap();
for b in 0..4u8 {
scheduler.try_send(id, custom(Priority::Normal, b)).unwrap();
}
wait_processed(&scheduler, &id, 4);
scheduler.stop();
}
}
#[test]
fn knob_idle_sleep_us_extremes_stay_functional() {
for idle in [0u64, 1_000_000] {
let mut config = SchedulerConfig::new().workers(1);
config.idle_sleep_us = idle;
let scheduler = ActorScheduler::new(config);
let id = scheduler.spawn().unwrap();
scheduler.set_actor_running(&id).unwrap();
scheduler.start().unwrap();
scheduler.try_send(id, custom(Priority::Normal, 7)).unwrap();
wait_processed(&scheduler, &id, 1);
scheduler.stop();
assert!(!scheduler.stats().running);
}
}
#[test]
fn knob_stealer_refresh_interval_zero_does_not_kill_workers() {
let mut config = SchedulerConfig::new().workers(1);
config.stealer_refresh_interval = 0;
let scheduler = ActorScheduler::new(config);
let id = scheduler.spawn().unwrap();
scheduler.set_actor_running(&id).unwrap();
scheduler.start().unwrap();
scheduler.try_send(id, custom(Priority::Normal, 9)).unwrap();
wait_processed(&scheduler, &id, 1);
scheduler.stop();
}
#[test]
fn knob_stealer_refresh_interval_extremes_make_progress() {
for interval in [1u32, u32::MAX] {
let mut config = SchedulerConfig::new().workers(2);
config.stealer_refresh_interval = interval;
let scheduler = ActorScheduler::new(config);
let id = scheduler.spawn().unwrap();
scheduler.set_actor_running(&id).unwrap();
scheduler.start().unwrap();
scheduler.try_send(id, custom(Priority::Normal, 3)).unwrap();
wait_processed(&scheduler, &id, 1);
scheduler.stop();
}
}
#[test]
fn knob_mailbox_capacity_bounds_try_send() {
let small = Mailbox::new(ActorId::new(), MailboxConfig::new(2));
small.try_send(custom(Priority::Normal, 1)).unwrap();
small.try_send(custom(Priority::Normal, 2)).unwrap();
assert!(
small.try_send(custom(Priority::Normal, 3)).is_err(),
"capacity=2 must reject the 3rd message"
);
let big = Mailbox::new(ActorId::new(), MailboxConfig::new(4));
for b in 0..3 {
big.try_send(custom(Priority::Normal, b)).unwrap();
}
}
#[test]
fn knob_mailbox_priority_queue_changes_recv_order() {
let prio = Mailbox::new(
ActorId::new(),
MailboxConfig {
priority_queue: true,
..MailboxConfig::default()
},
);
prio.try_send(custom(Priority::Normal, 1)).unwrap();
prio.try_send(custom(Priority::Critical, 2)).unwrap();
let first = prio.try_recv().unwrap();
assert_eq!(
first.priority,
Priority::Critical,
"priority_queue=true must deliver Critical first"
);
let fifo = Mailbox::new(
ActorId::new(),
MailboxConfig {
priority_queue: false,
..MailboxConfig::default()
},
);
fifo.try_send(custom(Priority::Normal, 1)).unwrap();
fifo.try_send(custom(Priority::Critical, 2)).unwrap();
let first = fifo.try_recv().unwrap();
assert_eq!(
first.priority,
Priority::Normal,
"priority_queue=false must keep FIFO order"
);
}
#[test]
fn knob_backpressure_threshold_moves_the_trip_point() {
fn pressured(threshold: f32, sends: usize) -> bool {
let mb = Mailbox::new(
ActorId::new(),
MailboxConfig {
capacity: 10,
backpressure_threshold: threshold,
..MailboxConfig::default()
},
);
for b in 0..sends {
mb.try_send(custom(Priority::Normal, b as u8)).unwrap();
}
mb.is_backpressured()
}
assert!(pressured(0.5, 5));
assert!(!pressured(0.9, 5));
assert!(pressured(0.9, 9));
}
#[cfg(feature = "unsafe-pool")]
#[test]
fn knob_arena_size_bytes_changes_capacity() {
let big = BumpAllocator::new(MemoryPoolConfig {
arena_size_bytes: 8192,
..MemoryPoolConfig::default()
});
let small = BumpAllocator::new(MemoryPoolConfig {
arena_size_bytes: 64,
..MemoryPoolConfig::default()
});
assert_eq!(big.capacity(), 8192);
assert_eq!(small.capacity(), 64);
assert!(big.borrow(4096).is_some());
assert!(
small.borrow(4096).is_none(),
"64-byte arena must reject a 4 KiB borrow the 8 KiB arena accepts"
);
}
#[cfg(feature = "unsafe-pool")]
#[test]
fn knob_preallocate_changes_initial_availability() {
let eager = BumpAllocator::new(MemoryPoolConfig {
preallocate: true,
..MemoryPoolConfig::default()
});
let lazy = BumpAllocator::new(MemoryPoolConfig {
preallocate: false,
..MemoryPoolConfig::default()
});
assert!(eager.capacity() > 0);
assert_eq!(lazy.capacity(), 0, "preallocate=false must defer the arena");
assert!(eager.borrow(64).is_some());
assert!(
lazy.borrow(64).is_none(),
"unallocated pool must refuse borrows the preallocated pool serves"
);
}
#[cfg(feature = "unsafe-pool")]
#[test]
fn dead_knob_page_size_only_clamps_never_read() {
let clamped = MemoryPoolConfig::default().page_size(1);
assert_eq!(
clamped.page_size, 16,
"builder clamps below MIN_BLOCK_SIZE (only observable effect)"
);
let a = BumpAllocator::new(MemoryPoolConfig::default().page_size(16));
let b = BumpAllocator::new(MemoryPoolConfig::default().page_size(4096));
assert_eq!(a.capacity(), b.capacity());
assert_eq!(a.available(), b.available());
assert_eq!(a.borrow(32).is_some(), b.borrow(32).is_some());
}
#[cfg(feature = "unsafe-pool")]
#[test]
fn dead_knob_max_pools_is_stored_never_read() {
let a = BumpAllocator::new(MemoryPoolConfig::default().max_pools(1));
let b = BumpAllocator::new(MemoryPoolConfig::default().max_pools(1_000_000));
assert_eq!(a.capacity(), b.capacity());
assert!(a.borrow(64).is_some());
assert!(b.borrow(64).is_some());
}
fn tree_with_policy(policy: RestartPolicy) -> (SupervisorTree, ActorId) {
let mut tree = SupervisorTree::new(SupervisionStrategy::one_for_one(
10,
Duration::from_secs(60),
));
let root = tree.root();
tree.start_child_under(root, ChildSpec::new("child").restart_policy(policy))
.unwrap();
(tree, root)
}
fn child_state(tree: &SupervisorTree, root: &ActorId) -> String {
format!(
"{:?}",
tree.get_supervisor(root)
.unwrap()
.get_child("child")
.unwrap()
.state
)
}
#[tokio::test]
async fn knob_restart_policy_changes_restart_decision() {
let (mut tree, root) = tree_with_policy(RestartPolicy::Permanent);
tree.handle_child_exit(root, "child", ExitReason::Normal)
.await
.unwrap();
assert!(
child_state(&tree, &root).contains("Restarting"),
"Permanent must restart on Normal exit"
);
let (mut tree, root) = tree_with_policy(RestartPolicy::Temporary);
tree.handle_child_exit(root, "child", ExitReason::Error("boom".into()))
.await
.unwrap();
assert!(
child_state(&tree, &root).contains("Stopped"),
"Temporary must not restart on Error exit"
);
let (mut tree, root) = tree_with_policy(RestartPolicy::Transient);
tree.handle_child_exit(root, "child", ExitReason::Error("boom".into()))
.await
.unwrap();
assert!(child_state(&tree, &root).contains("Restarting"));
let (mut tree, root) = tree_with_policy(RestartPolicy::Transient);
tree.handle_child_exit(root, "child", ExitReason::Normal)
.await
.unwrap();
assert!(
child_state(&tree, &root).contains("Stopped"),
"Transient must not restart on Normal exit"
);
}
#[tokio::test]
async fn dead_knob_shutdown_timeout_is_documented_inert() {
let mut states = Vec::new();
for timeout in [Duration::from_millis(1), Duration::from_secs(3600)] {
let mut tree = SupervisorTree::new(SupervisionStrategy::one_for_one(
10,
Duration::from_secs(60),
));
let root = tree.root();
tree.start_child_under(root, ChildSpec::new("child").shutdown_timeout(timeout))
.unwrap();
tree.handle_child_exit(root, "child", ExitReason::Error("x".into()))
.await
.unwrap();
states.push(child_state(&tree, &root));
}
assert_eq!(states[0], states[1], "shutdown_timeout changes no outcome");
}
#[tokio::test]
async fn dead_knob_significant_is_documented_inert() {
let mut states = Vec::new();
for significant in [false, true] {
let mut tree = SupervisorTree::new(SupervisionStrategy::one_for_one(
10,
Duration::from_secs(60),
));
let root = tree.root();
tree.start_child_under(root, ChildSpec::new("child").significant(significant))
.unwrap();
tree.handle_child_exit(root, "child", ExitReason::Error("x".into()))
.await
.unwrap();
states.push(child_state(&tree, &root));
}
assert_eq!(
states[0], states[1],
"significant changes no child state (only a log line)"
);
}