use rsactor::{
dead_letter_count, message_handlers, spawn, spawn_with_options, Actor, ActorRef, Error,
SpawnOptions,
};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use tokio::sync::{Mutex, Notify};
fn dead_letter_serial_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(2);
#[derive(Debug)]
struct CounterActor {
normal_count: u32,
priority_count: u32,
}
#[derive(Debug)]
struct NormalMsg;
#[derive(Debug)]
struct PriorityMsg;
#[derive(Debug)]
struct GetCounts;
#[derive(Debug)]
struct PriorityPing(u32);
impl Actor for CounterActor {
type Args = ();
type Error = anyhow::Error;
type IdleEvent = ();
async fn on_start(_: (), _: &ActorRef<Self>) -> std::result::Result<Self, Self::Error> {
Ok(CounterActor {
normal_count: 0,
priority_count: 0,
})
}
}
#[message_handlers]
impl CounterActor {
#[handler]
async fn handle_normal(&mut self, _: NormalMsg, _: &ActorRef<Self>) {
self.normal_count += 1;
}
#[handler]
async fn handle_priority(&mut self, _: PriorityMsg, _: &ActorRef<Self>) {
self.priority_count += 1;
}
#[handler]
async fn handle_get_counts(&mut self, _: GetCounts, _: &ActorRef<Self>) -> (u32, u32) {
(self.normal_count, self.priority_count)
}
#[handler]
async fn handle_priority_ping(&mut self, msg: PriorityPing, _: &ActorRef<Self>) -> u32 {
self.priority_count += 1;
msg.0 + 1
}
}
#[derive(Debug)]
struct BlockingActor {
started: Arc<AtomicU32>,
release: Arc<Notify>,
priority_count: u32,
}
#[derive(Debug)]
struct BlockMe;
#[derive(Debug)]
struct PingPriority;
#[derive(Debug)]
struct GetPriorityCount;
impl Actor for BlockingActor {
type Args = (Arc<AtomicU32>, Arc<Notify>);
type Error = anyhow::Error;
type IdleEvent = ();
async fn on_start(
(started, release): Self::Args,
_: &ActorRef<Self>,
) -> std::result::Result<Self, Self::Error> {
Ok(BlockingActor {
started,
release,
priority_count: 0,
})
}
}
#[message_handlers]
impl BlockingActor {
#[handler]
async fn handle_block(&mut self, _: BlockMe, _: &ActorRef<Self>) {
self.started.fetch_add(1, Ordering::SeqCst);
self.release.notified().await;
}
#[handler]
async fn handle_ping(&mut self, _: PingPriority, _: &ActorRef<Self>) -> u32 {
self.priority_count += 1;
self.priority_count
}
#[handler]
async fn handle_get_priority_count(&mut self, _: GetPriorityCount, _: &ActorRef<Self>) -> u32 {
self.priority_count
}
}
#[tokio::test]
async fn default_spawn_has_no_priority_channel() {
let (actor_ref, _handle) = spawn::<CounterActor>(());
assert!(
!actor_ref.has_priority_channel(),
"spawn() must not enable the priority channel"
);
actor_ref.stop().await;
}
#[tokio::test]
async fn tell_priority_on_disabled_actor_returns_not_enabled_error() {
let _serial = dead_letter_serial_lock().lock().await;
let (actor_ref, _handle) = spawn::<CounterActor>(());
let before = dead_letter_count();
let err = actor_ref
.tell_priority(PriorityMsg, DEFAULT_TIMEOUT)
.await
.unwrap_err();
match err {
Error::PriorityChannelNotEnabled { .. } => {}
other => panic!("expected PriorityChannelNotEnabled, got {other:?}"),
}
assert_eq!(
dead_letter_count(),
before,
"PriorityChannelNotEnabled must not increment the dead letter counter"
);
actor_ref.stop().await;
}
#[tokio::test]
async fn ask_priority_on_disabled_actor_returns_not_enabled_error() {
let _serial = dead_letter_serial_lock().lock().await;
let (actor_ref, _handle) = spawn::<CounterActor>(());
let before = dead_letter_count();
let err = actor_ref
.ask_priority(PriorityPing(1), DEFAULT_TIMEOUT)
.await
.unwrap_err();
assert!(matches!(err, Error::PriorityChannelNotEnabled { .. }));
assert_eq!(dead_letter_count(), before);
actor_ref.stop().await;
}
#[tokio::test]
async fn priority_bypasses_saturated_regular_mailbox() {
let _serial = dead_letter_serial_lock().lock().await;
let started = Arc::new(AtomicU32::new(0));
let release = Arc::new(Notify::new());
let opts = SpawnOptions::new().mailbox_capacity(1).with_priority();
let (actor_ref, handle) =
spawn_with_options::<BlockingActor>((started.clone(), release.clone()), opts);
actor_ref.tell(BlockMe).await.unwrap();
while started.load(Ordering::SeqCst) == 0 {
tokio::time::sleep(Duration::from_millis(5)).await;
}
actor_ref.tell(BlockMe).await.unwrap();
let saturated = tokio::time::timeout(
Duration::from_millis(100),
actor_ref.tell_with_timeout(BlockMe, Duration::from_millis(50)),
)
.await
.expect("tell_with_timeout should return")
.unwrap_err();
assert!(
matches!(saturated, Error::Timeout { .. }),
"regular mailbox should be saturated, got {saturated:?}"
);
release.notify_one();
let n = actor_ref
.ask_priority(PingPriority, DEFAULT_TIMEOUT)
.await
.unwrap();
assert_eq!(n, 1, "priority handler should have run exactly once");
release.notify_one();
release.notify_one();
actor_ref.stop().await;
handle.await.unwrap(); }
#[tokio::test]
async fn ask_priority_returns_typed_reply() {
let opts = SpawnOptions::new().with_priority();
let (actor_ref, _handle) = spawn_with_options::<CounterActor>((), opts);
let reply: u32 = actor_ref
.ask_priority(PriorityPing(41), DEFAULT_TIMEOUT)
.await
.unwrap();
assert_eq!(reply, 42);
actor_ref.stop().await;
}
#[tokio::test]
async fn kill_overtakes_pending_priority_messages() {
let _serial = dead_letter_serial_lock().lock().await;
let started = Arc::new(AtomicU32::new(0));
let release = Arc::new(Notify::new());
let opts = SpawnOptions::new().mailbox_capacity(1).with_priority();
let (actor_ref, handle) =
spawn_with_options::<BlockingActor>((started.clone(), release.clone()), opts);
actor_ref.tell(BlockMe).await.unwrap();
while started.load(Ordering::SeqCst) == 0 {
tokio::time::sleep(Duration::from_millis(5)).await;
}
actor_ref
.tell_priority(PingPriority, DEFAULT_TIMEOUT)
.await
.unwrap();
actor_ref.kill();
release.notify_one();
let result = handle.await.unwrap();
let actor = result.actor().expect("actor should be returned on kill");
assert_eq!(
actor.priority_count, 0,
"kill must not drain pending priority messages"
);
}
#[tokio::test]
async fn priority_message_pending_at_stop_is_processed_before_on_stop() {
let started = Arc::new(AtomicU32::new(0));
let release = Arc::new(Notify::new());
let opts = SpawnOptions::new().mailbox_capacity(1).with_priority();
let (actor_ref, handle) =
spawn_with_options::<BlockingActor>((started.clone(), release.clone()), opts);
actor_ref.tell(BlockMe).await.unwrap();
while started.load(Ordering::SeqCst) == 0 {
tokio::time::sleep(Duration::from_millis(5)).await;
}
actor_ref
.tell_priority(PingPriority, DEFAULT_TIMEOUT)
.await
.unwrap();
actor_ref.stop().await;
release.notify_one();
let result = handle.await.unwrap();
let actor = result.actor().expect("actor returned on graceful stop");
assert_eq!(
actor.priority_count, 1,
"priority message pending at stop must be processed before on_stop"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn stop_drain_loop_processes_racing_priority_send() {
let _serial = dead_letter_serial_lock().lock().await;
const ITERS: usize = 200;
let mut saw_post_close_send = false;
for _ in 0..ITERS {
let started = Arc::new(AtomicU32::new(0));
let release = Arc::new(Notify::new());
let opts = SpawnOptions::new().mailbox_capacity(1).with_priority();
let (actor_ref, handle) =
spawn_with_options::<BlockingActor>((started.clone(), release.clone()), opts);
actor_ref.tell(BlockMe).await.unwrap();
while started.load(Ordering::SeqCst) == 0 {
tokio::time::sleep(Duration::from_millis(1)).await;
}
actor_ref.stop().await;
let sender = actor_ref.clone();
let race = tokio::spawn(async move {
let mut send_errors = 0u32;
let mut accepted = 0u32;
for _ in 0..8 {
match sender
.tell_priority(PingPriority, Duration::from_millis(50))
.await
{
Ok(()) => accepted += 1,
Err(Error::Send { .. }) => send_errors += 1,
Err(Error::Timeout { .. }) => {
}
Err(other) => panic!("unexpected error from racing send: {other:?}"),
}
}
(accepted, send_errors)
});
release.notify_one();
let (_accepted, send_errors) = race.await.unwrap();
if send_errors > 0 {
saw_post_close_send = true;
}
let result = handle.await.unwrap();
result.actor().expect("clean termination");
}
assert!(
saw_post_close_send,
"across {ITERS} iterations of racing tell_priority vs stop(), at least one \
send should have observed the priority channel closed by the drain branch — \
this is what proves the drain code is reachable in production"
);
}
#[tokio::test]
async fn priority_send_after_stop_drain_records_dead_letter() {
let _serial = dead_letter_serial_lock().lock().await;
let opts = SpawnOptions::new().with_priority();
let (actor_ref, handle) = spawn_with_options::<CounterActor>((), opts);
actor_ref.stop().await;
handle.await.unwrap();
let before = dead_letter_count();
let err = actor_ref
.tell_priority(PriorityMsg, DEFAULT_TIMEOUT)
.await
.unwrap_err();
assert!(matches!(err, Error::Send { .. }));
assert!(
dead_letter_count() > before,
"Send failure on closed priority channel must be recorded as a dead letter"
);
}
#[tokio::test]
async fn tell_priority_timeout_on_wedged_actor_records_dead_letter() {
let _serial = dead_letter_serial_lock().lock().await;
let started = Arc::new(AtomicU32::new(0));
let release = Arc::new(Notify::new());
let opts = SpawnOptions::new().mailbox_capacity(1).with_priority();
let (actor_ref, handle) =
spawn_with_options::<BlockingActor>((started.clone(), release.clone()), opts);
actor_ref.tell(BlockMe).await.unwrap();
while started.load(Ordering::SeqCst) == 0 {
tokio::time::sleep(Duration::from_millis(5)).await;
}
actor_ref
.tell_priority(PingPriority, Duration::from_millis(500))
.await
.unwrap();
let before = dead_letter_count();
let err = actor_ref
.tell_priority(PingPriority, Duration::from_millis(100))
.await
.unwrap_err();
match err {
Error::Timeout { operation, .. } => assert_eq!(operation, "tell_priority"),
other => panic!("expected Timeout, got {other:?}"),
}
assert!(
dead_letter_count() > before,
"Timeout on tell_priority must be recorded as a dead letter"
);
release.notify_one();
actor_ref.kill();
let _ = handle.await;
}
async fn wait_for_lifecycle_settled<A>(actor_ref: &ActorRef<A>)
where
A: Actor + rsactor::Message<GetCounts, Reply = (u32, u32)> + 'static,
{
let _ = actor_ref.ask(GetCounts).await.unwrap();
}
#[tokio::test]
async fn actor_weak_upgrade_succeeds_when_only_priority_strong_dropped() {
let opts = SpawnOptions::new().with_priority();
let (mut actor_ref, _handle) = spawn_with_options::<CounterActor>((), opts);
wait_for_lifecycle_settled(&actor_ref).await;
let weak = ActorRef::downgrade(&actor_ref);
assert!(
actor_ref.has_priority_channel(),
"spawned with priority enabled"
);
actor_ref.drop_priority_sender_for_test();
assert!(
!actor_ref.has_priority_channel(),
"priority sender removed from this ActorRef"
);
let upgraded = weak.upgrade().expect("actor should still be alive");
assert!(
!upgraded.has_priority_channel(),
"upgrade() returns priority-disabled ActorRef when every strong priority sender is gone"
);
let err = upgraded
.tell_priority(PriorityMsg, DEFAULT_TIMEOUT)
.await
.unwrap_err();
assert!(matches!(err, Error::PriorityChannelNotEnabled { .. }));
upgraded.stop().await;
}
#[tokio::test]
async fn actor_weak_upgrade_preserves_priority_when_other_strong_alive() {
let opts = SpawnOptions::new().with_priority();
let (mut actor_ref, _handle) = spawn_with_options::<CounterActor>((), opts);
wait_for_lifecycle_settled(&actor_ref).await;
let weak = ActorRef::downgrade(&actor_ref);
let other_strong = actor_ref.clone();
actor_ref.drop_priority_sender_for_test();
let upgraded = weak.upgrade().expect("alive");
assert!(
upgraded.has_priority_channel(),
"priority sender survives in upgrade because another strong clone holds it"
);
drop(other_strong);
upgraded.stop().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn blocking_tell_priority_succeeds_when_enabled() {
let opts = SpawnOptions::new().with_priority();
let (actor_ref, _handle) = spawn_with_options::<CounterActor>((), opts);
let actor_ref_clone = actor_ref.clone();
let join = tokio::task::spawn_blocking(move || {
actor_ref_clone.blocking_tell_priority(PriorityMsg, DEFAULT_TIMEOUT)
});
join.await.unwrap().unwrap();
let (_normal, priority) = actor_ref.ask(GetCounts).await.unwrap();
assert_eq!(priority, 1);
actor_ref.stop().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn blocking_ask_priority_returns_reply() {
let opts = SpawnOptions::new().with_priority();
let (actor_ref, _handle) = spawn_with_options::<CounterActor>((), opts);
let actor_ref_clone = actor_ref.clone();
let reply: u32 = tokio::task::spawn_blocking(move || {
actor_ref_clone.blocking_ask_priority(PriorityPing(99), DEFAULT_TIMEOUT)
})
.await
.unwrap()
.unwrap();
assert_eq!(reply, 100);
actor_ref.stop().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn blocking_tell_priority_from_async_multi_thread_context() {
let opts = SpawnOptions::new().with_priority();
let (actor_ref, _handle) = spawn_with_options::<CounterActor>((), opts);
actor_ref
.blocking_tell_priority(PriorityMsg, DEFAULT_TIMEOUT)
.expect("blocking_tell_priority from async ctx should succeed");
let (_normal, priority) = actor_ref.ask(GetCounts).await.unwrap();
assert_eq!(priority, 1);
actor_ref.stop().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn blocking_ask_priority_from_async_multi_thread_context() {
let opts = SpawnOptions::new().with_priority();
let (actor_ref, _handle) = spawn_with_options::<CounterActor>((), opts);
let reply: u32 = actor_ref
.blocking_ask_priority(PriorityPing(7), DEFAULT_TIMEOUT)
.expect("blocking_ask_priority from async ctx should succeed");
assert_eq!(reply, 8);
actor_ref.stop().await;
}
#[tokio::test(flavor = "current_thread")]
async fn blocking_tell_priority_on_current_thread_runtime() {
let opts = SpawnOptions::new().with_priority();
let (actor_ref, _handle) = spawn_with_options::<CounterActor>((), opts);
let actor_ref_clone = actor_ref.clone();
tokio::task::spawn_blocking(move || {
actor_ref_clone.blocking_tell_priority(PriorityMsg, DEFAULT_TIMEOUT)
})
.await
.expect("spawn_blocking task should not panic")
.expect("blocking_tell_priority on current_thread runtime should succeed");
let (_normal, priority) = actor_ref.ask(GetCounts).await.unwrap();
assert_eq!(priority, 1);
actor_ref.stop().await;
}
#[tokio::test(flavor = "current_thread")]
async fn blocking_ask_priority_on_current_thread_runtime() {
let opts = SpawnOptions::new().with_priority();
let (actor_ref, _handle) = spawn_with_options::<CounterActor>((), opts);
let actor_ref_clone = actor_ref.clone();
let reply: u32 = tokio::task::spawn_blocking(move || {
actor_ref_clone.blocking_ask_priority(PriorityPing(20), DEFAULT_TIMEOUT)
})
.await
.expect("spawn_blocking task should not panic")
.expect("blocking_ask_priority on current_thread runtime should succeed");
assert_eq!(reply, 21);
actor_ref.stop().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn blocking_priority_on_disabled_actor_returns_not_enabled() {
let (actor_ref, _handle) = spawn::<CounterActor>(());
let actor_ref_clone = actor_ref.clone();
let err = tokio::task::spawn_blocking(move || {
actor_ref_clone.blocking_tell_priority(PriorityMsg, DEFAULT_TIMEOUT)
})
.await
.unwrap()
.unwrap_err();
assert!(matches!(err, Error::PriorityChannelNotEnabled { .. }));
actor_ref.stop().await;
}
#[tokio::test]
async fn capacity_one_serializes_concurrent_priority_admission() {
let _serial = dead_letter_serial_lock().lock().await;
let started = Arc::new(AtomicU32::new(0));
let release = Arc::new(Notify::new());
let opts = SpawnOptions::new().mailbox_capacity(1).with_priority();
let (actor_ref, handle) =
spawn_with_options::<BlockingActor>((started.clone(), release.clone()), opts);
actor_ref.tell(BlockMe).await.unwrap();
while started.load(Ordering::SeqCst) == 0 {
tokio::time::sleep(Duration::from_millis(5)).await;
}
let r1 = actor_ref.clone();
let r2 = actor_ref.clone();
let task1 = tokio::spawn(async move {
r1.tell_priority(PingPriority, Duration::from_millis(200))
.await
});
let task2 = tokio::spawn(async move {
r2.tell_priority(PingPriority, Duration::from_millis(200))
.await
});
let res1 = task1.await.unwrap();
let res2 = task2.await.unwrap();
let oks = [&res1, &res2].iter().filter(|r| r.is_ok()).count();
let timeouts = [&res1, &res2]
.iter()
.filter(|r| matches!(r, Err(Error::Timeout { .. })))
.count();
assert_eq!(
oks, 1,
"exactly one priority send should succeed, results: {res1:?} {res2:?}"
);
assert_eq!(
timeouts, 1,
"exactly one priority send should time out, results: {res1:?} {res2:?}"
);
release.notify_one();
actor_ref.kill();
let _ = handle.await; }
#[derive(Debug)]
struct OrderActor {
log: Arc<std::sync::Mutex<Vec<&'static str>>>,
r1_started: Arc<Notify>,
r1_release: Arc<Notify>,
}
#[derive(Debug)]
struct SlowRegular(&'static str);
#[derive(Debug)]
struct PriorityTag(&'static str);
impl Actor for OrderActor {
type Args = (
Arc<std::sync::Mutex<Vec<&'static str>>>,
Arc<Notify>,
Arc<Notify>,
);
type Error = anyhow::Error;
type IdleEvent = ();
async fn on_start(
(log, r1_started, r1_release): Self::Args,
_: &ActorRef<Self>,
) -> std::result::Result<Self, Self::Error> {
Ok(OrderActor {
log,
r1_started,
r1_release,
})
}
}
#[message_handlers]
impl OrderActor {
#[handler]
async fn handle_slow(&mut self, msg: SlowRegular, _: &ActorRef<Self>) {
if msg.0 == "R1" {
self.r1_started.notify_one();
self.r1_release.notified().await;
}
self.log.lock().unwrap().push(msg.0);
}
#[handler]
async fn handle_priority_tag(&mut self, msg: PriorityTag, _: &ActorRef<Self>) {
self.log.lock().unwrap().push(msg.0);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn priority_handler_runs_before_queued_regular_messages() {
let log = Arc::new(std::sync::Mutex::new(Vec::<&'static str>::new()));
let r1_started = Arc::new(Notify::new());
let r1_release = Arc::new(Notify::new());
let opts = SpawnOptions::new().mailbox_capacity(8).with_priority();
let (actor_ref, handle) = spawn_with_options::<OrderActor>(
(log.clone(), r1_started.clone(), r1_release.clone()),
opts,
);
actor_ref.tell(SlowRegular("R1")).await.unwrap();
actor_ref.tell(SlowRegular("R2")).await.unwrap();
actor_ref.tell(SlowRegular("R3")).await.unwrap();
r1_started.notified().await;
actor_ref
.tell_priority(PriorityTag("P"), DEFAULT_TIMEOUT)
.await
.unwrap();
r1_release.notify_one();
actor_ref.stop().await;
handle.await.unwrap();
let order = log.lock().unwrap().clone();
assert_eq!(
order,
vec!["R1", "P", "R2", "R3"],
"priority handler must run between R1 (in-flight) and R2/R3 (queued)"
);
}
#[cfg(feature = "metrics")]
#[tokio::test]
async fn priority_metrics_counters_are_distinct_from_regular() {
let opts = SpawnOptions::new().with_priority();
let (actor_ref, _handle) = spawn_with_options::<CounterActor>((), opts);
for _ in 0..5 {
actor_ref.tell(NormalMsg).await.unwrap();
}
for n in 0..3u32 {
let _: u32 = actor_ref
.ask_priority(PriorityPing(n), DEFAULT_TIMEOUT)
.await
.unwrap();
}
let _ = actor_ref.ask(GetCounts).await.unwrap();
let snap = actor_ref.metrics();
assert_eq!(
snap.message_count, 6,
"regular counter must include only mailbox-channel messages, got snapshot {snap:?}"
);
assert_eq!(
snap.priority_message_count, 3,
"priority counter must include only priority-channel messages, got snapshot {snap:?}"
);
assert_eq!(actor_ref.priority_message_count(), 3);
assert_eq!(actor_ref.message_count(), 6);
actor_ref.stop().await;
}