use std::collections::HashMap;
use std::future::Future;
use std::panic::AssertUnwindSafe;
use std::sync::{Mutex, OnceLock};
use futures_util::FutureExt;
use tracing::error;
const MAX_BACKOFF_MS: u64 = 300_000;
static RESTARTS: OnceLock<Mutex<HashMap<&'static str, u64>>> = OnceLock::new();
fn registry() -> &'static Mutex<HashMap<&'static str, u64>> {
RESTARTS.get_or_init(|| Mutex::new(HashMap::new()))
}
fn record_restart(task_name: &'static str) -> u64 {
let mut counts = registry().lock().expect("restart registry poisoned");
let count = counts.entry(task_name).or_insert(0);
*count += 1;
*count
}
pub fn restart_counts() -> Vec<(&'static str, u64)> {
let counts = registry().lock().expect("restart registry poisoned");
let mut out: Vec<_> = counts.iter().map(|(k, v)| (*k, *v)).collect();
out.sort_by_key(|(name, _)| *name);
out
}
pub fn spawn_supervised<F, Fut>(task_name: &'static str, make: F)
where
F: Fn() -> Fut + Send + 'static,
Fut: Future<Output = ()> + Send + 'static,
{
spawn_supervised_with_backoff(task_name, 1_000, make);
}
fn spawn_supervised_with_backoff<F, Fut>(task_name: &'static str, initial_backoff_ms: u64, make: F)
where
F: Fn() -> Fut + Send + 'static,
Fut: Future<Output = ()> + Send + 'static,
{
tokio::spawn(async move {
let mut backoff_ms = initial_backoff_ms.max(1);
loop {
match AssertUnwindSafe(make()).catch_unwind().await {
Ok(()) => break,
Err(payload) => {
let restarts = record_restart(task_name);
error!(
"Background task '{task_name}' panicked (restart #{restarts}); \
restarting in {backoff_ms}ms. reason={}",
panic_message(&*payload)
);
tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)).await;
backoff_ms = (backoff_ms.saturating_mul(2)).min(MAX_BACKOFF_MS);
}
}
}
});
}
fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
if let Some(s) = payload.downcast_ref::<&str>() {
(*s).to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
"non-string panic payload".to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
#[tokio::test]
async fn panicking_task_restarts_and_is_counted() {
let attempts = Arc::new(AtomicU64::new(0));
let attempts_in = attempts.clone();
spawn_supervised_with_backoff("test-crashy-loop", 5, move || {
let attempts = attempts_in.clone();
async move {
let n = attempts.fetch_add(1, Ordering::SeqCst);
if n < 2 {
panic!("boom {n}");
}
}
});
tokio::time::timeout(std::time::Duration::from_secs(5), async {
while attempts.load(Ordering::SeqCst) < 3 {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
})
.await
.expect("supervised task did not restart in time");
let counts = restart_counts();
let entry = counts.iter().find(|(name, _)| *name == "test-crashy-loop");
assert_eq!(entry.map(|(_, c)| *c), Some(2));
}
#[tokio::test]
async fn completing_task_is_not_counted() {
spawn_supervised_with_backoff("test-clean-loop", 5, || async {});
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert!(restart_counts()
.iter()
.all(|(name, _)| *name != "test-clean-loop"));
}
}