#![cfg(not(feature = "catch-handler-panics"))]
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use acton_reactive::prelude::*;
use tokio::time::Duration;
async fn await_notification(
captured: &Arc<Mutex<Option<ChildTerminated>>>,
) -> ChildTerminated {
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
loop {
let current = captured
.lock()
.expect("notification mutex poisoned")
.clone();
if let Some(notification) = current {
return notification;
}
assert!(
tokio::time::Instant::now() < deadline,
"Timed out waiting for the ChildTerminated notification"
);
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
#[acton_message]
struct Boom;
#[acton_message]
struct BoomWithMessage {
message: String,
}
#[derive(Default, Debug)]
struct PanickingChild;
#[derive(Default, Debug)]
struct SupervisorActor {
last_notification: Arc<Mutex<Option<ChildTerminated>>>,
}
async fn start_supervisor(
app: &mut ActorRuntime,
name: &str,
captured: Arc<Mutex<Option<ChildTerminated>>>,
) -> anyhow::Result<ActorHandle> {
let parent_config = ActorConfig::new(Ern::with_root(name)?, None, None)?;
let mut parent = app.new_actor_with_config::<SupervisorActor>(parent_config);
parent.model.last_notification = captured;
parent.mutate_on::<ChildTerminated>(|actor, ctx| {
let captured = actor.model.last_notification.clone();
let notification = ctx.message().clone();
Box::pin(async move {
*captured.lock().expect("notification mutex poisoned") = Some(notification);
})
});
Ok(parent.start().await)
}
#[tokio::test]
async fn test_parent_notified_of_panic_termination() -> anyhow::Result<()> {
let mut app = ActonApp::launch_async().await;
let captured = Arc::new(Mutex::new(None));
let parent_handle =
start_supervisor(&mut app, "panic-supervisor", captured.clone()).await?;
let child_config = ActorConfig::new(
Ern::with_root("panicking-child")?,
Some(parent_handle.clone()),
None,
)?;
let mut child = app.new_actor_with_config::<PanickingChild>(child_config);
child.mutate_on::<Boom>(|_actor, _ctx| {
panic!("intentional test panic");
});
let child_handle = child.start().await;
child_handle.send(Boom).await;
let notification = await_notification(&captured).await;
assert_eq!(
notification.reason,
TerminationReason::Panic("intentional test panic".to_string()),
"Termination reason should be Panic carrying the panic message"
);
assert_eq!(
notification.child_id,
child_handle.id(),
"Notification should identify the panicked child"
);
parent_handle.stop().await?;
app.shutdown_all().await?;
Ok(())
}
#[tokio::test]
async fn test_panic_reason_preserves_string_payload_and_policy() -> anyhow::Result<()> {
let mut app = ActonApp::launch_async().await;
let captured = Arc::new(Mutex::new(None));
let parent_handle =
start_supervisor(&mut app, "transient-panic-supervisor", captured.clone()).await?;
let child_config = ActorConfig::new(
Ern::with_root("transient-panicking-child")?,
Some(parent_handle.clone()),
None,
)?
.with_restart_policy(RestartPolicy::Transient);
let mut child = app.new_actor_with_config::<PanickingChild>(child_config);
child.mutate_on::<BoomWithMessage>(|_actor, ctx| {
let msg = ctx.message().message.clone();
panic!("worker failed: {msg}");
});
let child_handle = child.start().await;
child_handle
.send(BoomWithMessage {
message: "disk full".to_string(),
})
.await;
let notification = await_notification(&captured).await;
assert_eq!(
notification.reason,
TerminationReason::Panic("worker failed: disk full".to_string()),
"Formatted panic message should be preserved in the termination reason"
);
assert_eq!(
notification.restart_policy,
RestartPolicy::Transient,
"Notification should carry the child's configured restart policy"
);
assert!(
notification
.restart_policy
.should_restart(¬ification.reason),
"Transient policy should restart on panic termination"
);
parent_handle.stop().await?;
app.shutdown_all().await?;
Ok(())
}
#[tokio::test]
async fn test_notification_survives_after_stop_panic() -> anyhow::Result<()> {
let mut app = ActonApp::launch_async().await;
let captured = Arc::new(Mutex::new(None));
let parent_handle =
start_supervisor(&mut app, "cleanup-panic-supervisor", captured.clone()).await?;
let child_config = ActorConfig::new(
Ern::with_root("double-panicking-child")?,
Some(parent_handle.clone()),
None,
)?;
let mut child = app.new_actor_with_config::<PanickingChild>(child_config);
child
.mutate_on::<Boom>(|_actor, _ctx| {
panic!("original handler panic");
})
.after_stop(|_actor| {
Reply::pending(async move {
panic!("panic during shutdown cleanup");
})
});
let child_handle = child.start().await;
child_handle.send(Boom).await;
let notification = await_notification(&captured).await;
assert_eq!(
notification.reason,
TerminationReason::Panic("original handler panic".to_string()),
"The first panic's message must be preserved even when after_stop panics too"
);
parent_handle.stop().await?;
app.shutdown_all().await?;
Ok(())
}
#[tokio::test]
async fn test_after_stop_panic_reported_on_clean_termination() -> anyhow::Result<()> {
let mut app = ActonApp::launch_async().await;
let captured = Arc::new(Mutex::new(None));
let parent_handle =
start_supervisor(&mut app, "cleanup-only-panic-supervisor", captured.clone()).await?;
let child_config = ActorConfig::new(
Ern::with_root("cleanup-panicking-child")?,
Some(parent_handle.clone()),
None,
)?;
let mut child = app.new_actor_with_config::<PanickingChild>(child_config);
child.after_stop(|_actor| {
Reply::pending(async move {
panic!("cleanup-only panic");
})
});
let child_handle = child.start().await;
child_handle.stop().await?;
let notification = await_notification(&captured).await;
assert_eq!(
notification.reason,
TerminationReason::Panic("cleanup-only panic".to_string()),
"A cleanup panic during clean termination should be reported as Panic"
);
parent_handle.stop().await?;
app.shutdown_all().await?;
Ok(())
}
#[derive(Default, Debug)]
struct Recorder {
pongs: Arc<AtomicUsize>,
}
#[acton_message]
struct Pong;
#[tokio::test]
async fn test_panicked_actor_is_unsubscribed_from_broker() -> anyhow::Result<()> {
let mut app = ActonApp::launch_async().await;
let broker_handle = app.broker();
let recorder_id = Ern::with_root("panic-recorder")?;
let first_config =
ActorConfig::new(recorder_id.clone(), None, Some(broker_handle.clone()))?;
let mut first = app.new_actor_with_config::<Recorder>(first_config);
let first_pongs = first.model.pongs.clone();
first
.mutate_on::<Pong>(|actor, _ctx| {
actor.model.pongs.fetch_add(1, Ordering::SeqCst);
Reply::ready()
})
.mutate_on::<Boom>(|_actor, _ctx| {
panic!("subscriber panic");
});
first.handle().subscribe::<Pong>().await;
let first_id = first.handle().id();
let first_handle = first.start().await;
first_handle.send(Boom).await;
first_handle.stop().await?;
let second_config = ActorConfig::new(recorder_id, None, Some(broker_handle.clone()))?;
let mut second = app.new_actor_with_config::<Recorder>(second_config);
let second_pongs = second.model.pongs.clone();
second.mutate_on::<Pong>(|actor, _ctx| {
actor.model.pongs.fetch_add(1, Ordering::SeqCst);
Reply::ready()
});
assert_eq!(
second.handle().id(),
first_id,
"test precondition: both incarnations must share the same Ern so their \
broker subscription entries genuinely collide"
);
second.handle().subscribe::<Pong>().await;
let _second_handle = second.start().await;
broker_handle.broadcast(Pong).await;
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
while second_pongs.load(Ordering::SeqCst) == 0 {
assert!(
tokio::time::Instant::now() < deadline,
"the replacement actor should receive broadcasts, proving the panicked \
actor's subscription entry was removed"
);
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert_eq!(
first_pongs.load(Ordering::SeqCst),
0,
"the panicked actor should not have received any broadcasts"
);
app.shutdown_all().await?;
Ok(())
}