use crate::config::{backoff_delay, SupervisedOutcome, SupervisorConfig};
use crate::health::HealthFlag;
use crate::time::now_unix_ms;
use std::future::Future;
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::sync::{Arc, Mutex};
use tokio::task::{AbortHandle, JoinHandle};
#[derive(Default)]
struct ChildControl {
shutdown: bool,
active: Option<AbortHandle>,
}
type ActiveChild = Arc<Mutex<ChildControl>>;
pub struct SupervisedTask {
handle: JoinHandle<()>,
health: HealthFlag,
active_child: ActiveChild,
}
impl SupervisedTask {
pub fn spawn<F, Fut>(config: SupervisorConfig, iteration: F) -> Self
where
F: FnMut() -> Fut + Send + 'static,
Fut: Future<Output = SupervisedOutcome> + Send + 'static,
{
let health = HealthFlag::new(config.tcb_critical);
let active_child: ActiveChild = Arc::new(Mutex::new(ChildControl::default()));
let handle = supervise_task_tracking_child(
config,
health.clone(),
Arc::clone(&active_child),
iteration,
);
Self {
handle,
health,
active_child,
}
}
#[must_use]
pub fn health(&self) -> HealthFlag {
self.health.clone()
}
#[must_use]
pub fn is_finished(&self) -> bool {
self.handle.is_finished()
}
pub fn abort(&self) {
self.handle.abort();
let mut control = self
.active_child
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
control.shutdown = true;
if let Some(child) = control.active.take() {
child.abort();
}
}
}
impl Drop for SupervisedTask {
fn drop(&mut self) {
self.abort();
}
}
pub fn supervise_task<F, Fut>(
config: SupervisorConfig,
health: HealthFlag,
iteration: F,
) -> JoinHandle<()>
where
F: FnMut() -> Fut + Send + 'static,
Fut: Future<Output = SupervisedOutcome> + Send + 'static,
{
supervise_task_tracking_child(
config,
health,
Arc::new(Mutex::new(ChildControl::default())),
iteration,
)
}
fn supervise_task_tracking_child<F, Fut>(
config: SupervisorConfig,
health: HealthFlag,
active_child: ActiveChild,
mut iteration: F,
) -> JoinHandle<()>
where
F: FnMut() -> Fut + Send + 'static,
Fut: Future<Output = SupervisedOutcome> + Send + 'static,
{
tokio::spawn(async move {
loop {
let spawned = catch_unwind(AssertUnwindSafe(&mut iteration)).map(tokio::spawn);
let outcome = match spawned {
Ok(handle) => {
let child_abort = handle.abort_handle();
let shutting_down = {
let mut control = active_child
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if control.shutdown {
true
} else {
control.active = Some(child_abort);
false
}
};
if shutting_down {
handle.abort();
return;
}
match handle.await {
Ok(outcome) => outcome,
Err(join_error) if join_error.is_panic() => SupervisedOutcome::Restart,
Err(_cancelled) => return,
}
}
Err(_panic) => SupervisedOutcome::Restart,
};
match outcome {
SupervisedOutcome::Shutdown => return,
SupervisedOutcome::Continue => {
health.record_ok(now_unix_ms());
continue;
}
SupervisedOutcome::Restart => {
let now = now_unix_ms();
let count = health.record_failure(
format!("{} iteration panicked or failed", config.name),
now,
config.trip_after,
);
if count >= config.max_restarts {
health.escalate_failed(now);
return;
}
tokio::time::sleep(backoff_delay(&config, count)).await;
}
}
}
})
}
#[cfg(all(test, not(loom)))]
mod tests {
use super::*;
use crate::HealthLevel;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::Arc;
use std::time::Duration;
fn fast_config(name: &'static str, tcb: bool, max_restarts: u32) -> SupervisorConfig {
SupervisorConfig {
name,
tcb_critical: tcb,
trip_after: 2,
max_restarts,
base_backoff: Duration::from_millis(1),
max_backoff: Duration::from_millis(2),
}
}
#[tokio::test]
async fn continue_iterations_keep_the_flag_healthy() {
let ticks = Arc::new(AtomicU32::new(0));
let worker_ticks = Arc::clone(&ticks);
let task = SupervisedTask::spawn(fast_config("healthy", false, 5), move || {
let worker_ticks = Arc::clone(&worker_ticks);
async move {
let seen = worker_ticks.fetch_add(1, Ordering::SeqCst);
if seen >= 3 {
SupervisedOutcome::Shutdown
} else {
SupervisedOutcome::Continue
}
}
});
let health = task.health();
tokio::time::timeout(Duration::from_secs(2), async {
while !task.is_finished() {
tokio::time::sleep(Duration::from_millis(1)).await;
}
})
.await
.unwrap_or(());
assert_eq!(health.level(), HealthLevel::Healthy);
assert!(ticks.load(Ordering::SeqCst) >= 3);
}
#[tokio::test]
async fn panicking_iteration_is_recorded_and_escalates() {
let task = SupervisedTask::spawn(fast_config("panic", true, 3), || async {
panic!("iteration blew up");
});
let health = task.health();
tokio::time::timeout(Duration::from_secs(3), async {
while health.level() != HealthLevel::Failed {
tokio::time::sleep(Duration::from_millis(2)).await;
}
})
.await
.unwrap_or(());
assert_eq!(health.level(), HealthLevel::Failed);
assert!(health.is_serving_closed());
assert!(task.is_finished());
}
#[tokio::test]
async fn panic_building_the_iteration_is_recorded_and_escalates() {
let task = SupervisedTask::spawn(
fast_config("build-panic", true, 3),
|| -> std::future::Ready<SupervisedOutcome> {
panic!("closure blew up before building the future");
},
);
let health = task.health();
tokio::time::timeout(Duration::from_secs(3), async {
while health.level() != HealthLevel::Failed {
tokio::time::sleep(Duration::from_millis(2)).await;
}
})
.await
.unwrap_or(());
assert_eq!(health.level(), HealthLevel::Failed);
assert!(health.is_serving_closed());
assert!(task.is_finished());
}
#[tokio::test]
async fn failing_iteration_trips_to_degraded() {
let task = SupervisedTask::spawn(fast_config("fail", false, 100), || async {
SupervisedOutcome::Restart
});
let health = task.health();
tokio::time::timeout(Duration::from_secs(3), async {
while health.level() == HealthLevel::Healthy {
tokio::time::sleep(Duration::from_millis(2)).await;
}
})
.await
.unwrap_or(());
assert_eq!(health.level(), HealthLevel::Degraded);
task.abort();
}
#[tokio::test]
async fn interleaved_restart_and_continue_never_trips() {
let phase = Arc::new(AtomicU32::new(0));
let worker_phase = Arc::clone(&phase);
let task = SupervisedTask::spawn(fast_config("interleave", false, 1000), move || {
let worker_phase = Arc::clone(&worker_phase);
async move {
match worker_phase.fetch_add(1, Ordering::SeqCst) {
0 | 2 | 4 => SupervisedOutcome::Restart,
1 | 3 => SupervisedOutcome::Continue,
_ => {
tokio::time::sleep(Duration::from_millis(1)).await;
SupervisedOutcome::Continue
}
}
}
});
let health = task.health();
tokio::time::timeout(Duration::from_secs(5), async {
while phase.load(Ordering::SeqCst) < 6 {
tokio::time::sleep(Duration::from_millis(1)).await;
}
})
.await
.unwrap_or(());
assert_eq!(health.level(), HealthLevel::Healthy);
task.abort();
}
#[tokio::test]
async fn abort_cancels_the_in_flight_child_iteration() {
let started = Arc::new(AtomicBool::new(false));
let completed = Arc::new(AtomicBool::new(false));
let worker_started = Arc::clone(&started);
let worker_completed = Arc::clone(&completed);
let task = SupervisedTask::spawn(fast_config("abort-child", false, 5), move || {
let worker_started = Arc::clone(&worker_started);
let worker_completed = Arc::clone(&worker_completed);
async move {
worker_started.store(true, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(500)).await;
worker_completed.store(true, Ordering::SeqCst);
SupervisedOutcome::Continue
}
});
tokio::time::timeout(Duration::from_secs(2), async {
while !started.load(Ordering::SeqCst) {
tokio::time::sleep(Duration::from_millis(1)).await;
}
})
.await
.unwrap_or(());
task.abort();
tokio::time::sleep(Duration::from_millis(1200)).await;
assert!(
started.load(Ordering::SeqCst),
"the iteration should have started"
);
assert!(
!completed.load(Ordering::SeqCst),
"abort must cancel the in-flight child iteration, not detach it to run to completion"
);
}
#[tokio::test]
async fn dropping_the_task_cancels_the_in_flight_child() {
let started = Arc::new(AtomicBool::new(false));
let completed = Arc::new(AtomicBool::new(false));
let worker_started = Arc::clone(&started);
let worker_completed = Arc::clone(&completed);
let task = SupervisedTask::spawn(fast_config("drop-child", false, 5), move || {
let worker_started = Arc::clone(&worker_started);
let worker_completed = Arc::clone(&worker_completed);
async move {
worker_started.store(true, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(500)).await;
worker_completed.store(true, Ordering::SeqCst);
SupervisedOutcome::Continue
}
});
tokio::time::timeout(Duration::from_secs(2), async {
while !started.load(Ordering::SeqCst) {
tokio::time::sleep(Duration::from_millis(1)).await;
}
})
.await
.unwrap_or(());
drop(task);
tokio::time::sleep(Duration::from_millis(1200)).await;
assert!(
started.load(Ordering::SeqCst),
"the iteration should have started"
);
assert!(
!completed.load(Ordering::SeqCst),
"dropping the task must cancel the in-flight child, not detach it to run to completion"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn abort_racing_the_publish_window_still_cancels_the_child() {
let (gate_tx, gate_rx) = std::sync::mpsc::channel::<()>();
let entered = Arc::new(AtomicBool::new(false));
let completed = Arc::new(AtomicBool::new(false));
let worker_entered = Arc::clone(&entered);
let worker_completed = Arc::clone(&completed);
let task = SupervisedTask::spawn(fast_config("publish-race", false, 5), move || {
worker_entered.store(true, Ordering::SeqCst);
let _ = gate_rx.recv();
let worker_completed = Arc::clone(&worker_completed);
async move {
tokio::time::sleep(Duration::from_millis(300)).await;
worker_completed.store(true, Ordering::SeqCst);
SupervisedOutcome::Continue
}
});
tokio::time::timeout(Duration::from_secs(2), async {
while !entered.load(Ordering::SeqCst) {
tokio::time::sleep(Duration::from_millis(1)).await;
}
})
.await
.unwrap_or(());
task.abort();
let _ = gate_tx.send(());
tokio::time::sleep(Duration::from_millis(800)).await;
assert!(
entered.load(Ordering::SeqCst),
"the iteration closure should have started building its future"
);
assert!(
!completed.load(Ordering::SeqCst),
"abort racing the publish window must cancel the spawned child, not detach it to run to completion"
);
}
}