use std::future::Future;
use std::sync::atomic::{AtomicI64, AtomicU32, AtomicU8, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tokio::sync::watch;
use tokio::task::{JoinError, JoinHandle};
use crate::error::{OlError, ERR_TASK_PANICKED, ERR_TASK_RESTART_LIMIT};
use crate::privacy::PrivacyFilter;
pub const BACKOFF_INITIAL: Duration = Duration::from_secs(1);
pub const BACKOFF_MAX: Duration = Duration::from_secs(60);
pub const HEALTHY_AFTER: Duration = Duration::from_secs(60);
pub const RESTART_LIMIT_WARN_AT: u32 = 5;
const SHUTDOWN_DRAIN: Duration = Duration::from_secs(4);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RestartPolicy {
Always,
OnFailure,
Never,
}
#[derive(Debug, Clone)]
pub struct Backoff {
initial: Duration,
max: Duration,
current: Duration,
rng: u64,
}
impl Default for Backoff {
fn default() -> Self {
Self::new(BACKOFF_INITIAL, BACKOFF_MAX)
}
}
impl Backoff {
pub fn new(initial: Duration, max: Duration) -> Self {
Self {
initial,
max,
current: initial,
rng: seed(),
}
}
pub fn base(&self) -> Duration {
self.current
}
pub fn reset(&mut self) {
self.current = self.initial;
}
pub fn next_delay(&mut self) -> Duration {
let base = self.current;
self.current = (self.current * 2).min(self.max);
self.rng ^= self.rng >> 12;
self.rng ^= self.rng << 25;
self.rng ^= self.rng >> 27;
let frac = ((self.rng >> 11) as f64) / ((1u64 << 53) as f64);
let factor = 0.8 + 0.4 * frac;
Duration::from_secs_f64(base.as_secs_f64() * factor).min(self.max)
}
}
fn seed() -> u64 {
use std::sync::atomic::AtomicU64;
static COUNTER: AtomicU64 = AtomicU64::new(0x9E37_79B9_7F4A_7C15);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0x2545_F491_4F6C_DD1D);
let n = COUNTER.fetch_add(0x9E37_79B9_7F4A_7C15, Ordering::Relaxed);
(nanos ^ n) | 1
}
#[derive(Debug, Clone)]
pub struct TaskSpec {
pub name: &'static str,
pub restart: RestartPolicy,
pub backoff: Backoff,
}
impl TaskSpec {
pub fn new(name: &'static str, restart: RestartPolicy) -> Self {
Self {
name,
restart,
backoff: Backoff::default(),
}
}
pub fn with_backoff(mut self, backoff: Backoff) -> Self {
self.backoff = backoff;
self
}
}
#[derive(Debug)]
pub enum TaskOutcome {
Completed,
Failed {
code: Option<&'static str>,
error: String,
},
}
pub trait IntoOutcome {
fn into_outcome(self) -> TaskOutcome;
}
impl IntoOutcome for () {
fn into_outcome(self) -> TaskOutcome {
TaskOutcome::Completed
}
}
impl IntoOutcome for Result<(), OlError> {
fn into_outcome(self) -> TaskOutcome {
match self {
Ok(()) => TaskOutcome::Completed,
Err(e) => TaskOutcome::Failed {
code: Some(e.code),
error: e.message,
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum TaskState {
Starting = 0,
Running = 1,
Restarting = 2,
Failed = 3,
Stopped = 4,
}
impl TaskState {
fn from_u8(v: u8) -> Self {
match v {
1 => TaskState::Running,
2 => TaskState::Restarting,
3 => TaskState::Failed,
4 => TaskState::Stopped,
_ => TaskState::Starting,
}
}
pub fn as_str(self) -> &'static str {
match self {
TaskState::Starting => "starting",
TaskState::Running => "running",
TaskState::Restarting => "restarting",
TaskState::Failed => "failed",
TaskState::Stopped => "stopped",
}
}
}
pub struct TaskHealth {
name: &'static str,
policy: RestartPolicy,
state: AtomicU8,
restarts: AtomicU32,
consecutive_failures: AtomicU32,
last_transition_unix: AtomicI64,
last_error: Mutex<Option<String>>,
}
impl TaskHealth {
pub fn new(name: &'static str, policy: RestartPolicy) -> Self {
Self {
name,
policy,
state: AtomicU8::new(TaskState::Starting as u8),
restarts: AtomicU32::new(0),
consecutive_failures: AtomicU32::new(0),
last_transition_unix: AtomicI64::new(now_unix()),
last_error: Mutex::new(None),
}
}
pub fn name(&self) -> &'static str {
self.name
}
pub fn policy(&self) -> RestartPolicy {
self.policy
}
pub fn state(&self) -> TaskState {
TaskState::from_u8(self.state.load(Ordering::Relaxed))
}
pub fn restarts(&self) -> u32 {
self.restarts.load(Ordering::Relaxed)
}
pub fn consecutive_failures(&self) -> u32 {
self.consecutive_failures.load(Ordering::Relaxed)
}
pub fn last_error(&self) -> Option<String> {
self.last_error.lock().ok().and_then(|g| g.clone())
}
pub fn last_transition_unix(&self) -> i64 {
self.last_transition_unix.load(Ordering::Relaxed)
}
pub fn is_degraded(&self) -> bool {
self.policy == RestartPolicy::Always && self.state() != TaskState::Running
}
pub fn set_state(&self, state: TaskState) {
self.state.store(state as u8, Ordering::Relaxed);
self.last_transition_unix
.store(now_unix(), Ordering::Relaxed);
}
fn record_restart(&self) {
self.restarts.fetch_add(1, Ordering::Relaxed);
}
fn note_failure(&self, error: &str) -> u32 {
if let Ok(mut guard) = self.last_error.lock() {
*guard = Some(scrub(error));
}
self.consecutive_failures.fetch_add(1, Ordering::Relaxed) + 1
}
fn note_healthy(&self) -> bool {
let had = self.consecutive_failures.swap(0, Ordering::Relaxed) > 0;
if had {
if let Ok(mut guard) = self.last_error.lock() {
*guard = None;
}
}
had
}
pub fn to_json(&self) -> serde_json::Value {
let mut obj = serde_json::json!({
"state": self.state().as_str(),
"restarts": self.restarts(),
});
if let Some(map) = obj.as_object_mut() {
if self.is_degraded() {
map.insert("degraded".into(), true.into());
}
let failures = self.consecutive_failures();
if failures > 0 {
map.insert("consecutive_failures".into(), failures.into());
}
if let Some(err) = self.last_error() {
map.insert("last_error".into(), err.into());
}
}
obj
}
}
#[derive(Default)]
pub struct HealthRegistry {
tasks: Mutex<Vec<Arc<TaskHealth>>>,
}
impl HealthRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn register(&self, name: &'static str, policy: RestartPolicy) -> Arc<TaskHealth> {
let health = Arc::new(TaskHealth::new(name, policy));
if let Ok(mut tasks) = self.tasks.lock() {
tasks.push(health.clone());
}
health
}
pub fn tasks(&self) -> Vec<Arc<TaskHealth>> {
self.tasks
.lock()
.map(|t| t.clone())
.unwrap_or_else(|_| Vec::new())
}
pub fn degraded_names(&self) -> Vec<&'static str> {
self.tasks()
.iter()
.filter(|t| t.is_degraded())
.map(|t| t.name())
.collect()
}
pub fn degraded_count(&self) -> u64 {
self.tasks().iter().filter(|t| t.is_degraded()).count() as u64
}
pub fn is_degraded(&self) -> bool {
self.tasks().iter().any(|t| t.is_degraded())
}
pub fn total_restarts(&self) -> u64 {
self.tasks().iter().map(|t| u64::from(t.restarts())).sum()
}
pub fn subsystems_json(&self) -> serde_json::Value {
let mut map = serde_json::Map::new();
for t in self.tasks() {
map.insert(t.name().to_string(), t.to_json());
}
serde_json::Value::Object(map)
}
}
pub fn spawn_supervised<F, Fut, T>(
registry: &Arc<HealthRegistry>,
spec: TaskSpec,
shutdown: watch::Receiver<bool>,
factory: F,
) -> JoinHandle<()>
where
F: FnMut() -> Fut + Send + 'static,
Fut: Future<Output = T> + Send + 'static,
T: IntoOutcome + Send + 'static,
{
let health = registry.register(spec.name, spec.restart);
supervise(spec, health, shutdown, factory)
}
pub fn supervise<F, Fut, T>(
spec: TaskSpec,
health: Arc<TaskHealth>,
mut shutdown: watch::Receiver<bool>,
mut factory: F,
) -> JoinHandle<()>
where
F: FnMut() -> Fut + Send + 'static,
Fut: Future<Output = T> + Send + 'static,
T: IntoOutcome + Send + 'static,
{
tokio::spawn(async move {
let mut backoff = spec.backoff.clone();
loop {
if *shutdown.borrow() {
health.set_state(TaskState::Stopped);
return;
}
health.set_state(TaskState::Running);
let started = Instant::now();
let mut inner = tokio::spawn(factory());
let step = tokio::select! {
joined = &mut inner => Step::Finished(joined),
_ = shutdown.wait_for(|stop| *stop) => Step::Shutdown,
};
let joined = match step {
Step::Shutdown => {
if tokio::time::timeout(SHUTDOWN_DRAIN, &mut inner)
.await
.is_err()
{
tracing::warn!(
task = spec.name,
"supervised task did not stop within the drain window — aborting it"
);
inner.abort();
}
health.set_state(TaskState::Stopped);
return;
}
Step::Finished(joined) => joined,
};
if started.elapsed() >= HEALTHY_AFTER {
backoff.reset();
if health.note_healthy() {
tracing::info!(
task = spec.name,
"supervised task recovered and is running normally again"
);
}
}
let outcome = match joined {
Ok(value) => value.into_outcome(),
Err(e) if e.is_panic() => TaskOutcome::Failed {
code: Some(ERR_TASK_PANICKED),
error: panic_message(e),
},
Err(_) => {
health.set_state(TaskState::Stopped);
return;
}
};
if *shutdown.borrow() {
health.set_state(TaskState::Stopped);
return;
}
let restart = !matches!(
(&outcome, spec.restart),
(_, RestartPolicy::Never) | (TaskOutcome::Completed, RestartPolicy::OnFailure)
);
match &outcome {
TaskOutcome::Completed => {
tracing::debug!(
task = spec.name,
restart,
"supervised task completed normally"
);
}
TaskOutcome::Failed { code, error } => {
let code = code.unwrap_or(ERR_TASK_PANICKED);
let failures = health.note_failure(error);
if failures == 1 {
tracing::error!(
code,
task = spec.name,
error = %error,
restart,
"supervised task failed — suppressing further warnings until recovery"
);
} else if failures == RESTART_LIMIT_WARN_AT {
tracing::warn!(
code = ERR_TASK_RESTART_LIMIT,
task = spec.name,
consecutive_failures = failures,
error = %error,
"supervised task is failing persistently; still retrying"
);
} else {
tracing::debug!(
code,
task = spec.name,
consecutive_failures = failures,
"supervised task failed again during a degraded streak"
);
}
}
}
if !restart {
health.set_state(match outcome {
TaskOutcome::Completed => TaskState::Stopped,
TaskOutcome::Failed { .. } => TaskState::Failed,
});
return;
}
health.set_state(TaskState::Restarting);
health.record_restart();
let delay = backoff.next_delay();
tokio::select! {
_ = tokio::time::sleep(delay) => {}
_ = shutdown.wait_for(|stop| *stop) => {
health.set_state(TaskState::Stopped);
return;
}
}
}
})
}
enum Step<T> {
Finished(Result<T, JoinError>),
Shutdown,
}
fn panic_message(e: JoinError) -> String {
let payload = e.into_panic();
if let Some(s) = payload.downcast_ref::<&'static str>() {
(*s).to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
"task panicked with a non-string payload".to_string()
}
}
fn scrub(message: &str) -> String {
static FILTER: OnceLock<PrivacyFilter> = OnceLock::new();
let filter = FILTER.get_or_init(|| PrivacyFilter::new(&[]));
let mut value = serde_json::Value::String(message.to_string());
crate::privacy::filter_value(&mut value, filter);
match value {
serde_json::Value::String(s) => s,
_ => String::new(),
}
}
fn now_unix() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::AtomicU32;
fn fast_spec(name: &'static str, restart: RestartPolicy) -> TaskSpec {
TaskSpec::new(name, restart).with_backoff(Backoff::new(
Duration::from_millis(5),
Duration::from_millis(20),
))
}
#[test]
fn backoff_grows_and_caps_at_the_ceiling() {
let mut b = Backoff::default();
assert_eq!(b.base(), BACKOFF_INITIAL);
let mut bases = vec![];
for _ in 0..10 {
bases.push(b.base());
let delay = b.next_delay();
assert!(
delay <= BACKOFF_MAX,
"jittered delay {delay:?} exceeded cap"
);
}
assert_eq!(
bases[..4],
[
Duration::from_secs(1),
Duration::from_secs(2),
Duration::from_secs(4),
Duration::from_secs(8)
]
);
assert_eq!(b.base(), BACKOFF_MAX, "base must saturate at the ceiling");
}
#[test]
fn backoff_reset_returns_to_the_initial_delay() {
let mut b = Backoff::default();
for _ in 0..5 {
b.next_delay();
}
assert!(b.base() > BACKOFF_INITIAL);
b.reset();
assert_eq!(b.base(), BACKOFF_INITIAL);
}
#[test]
fn backoff_jitter_stays_within_twenty_percent() {
let mut b = Backoff::new(Duration::from_secs(10), Duration::from_secs(600));
let d = b.next_delay();
assert!(
d >= Duration::from_secs(8) && d <= Duration::from_secs(12),
"delay {d:?} outside the ±20% band around 10s"
);
}
#[tokio::test]
async fn panicking_task_is_restarted() {
let registry = Arc::new(HealthRegistry::new());
let (_tx, rx) = watch::channel(false);
let runs = Arc::new(AtomicU32::new(0));
let runs_for_task = runs.clone();
let handle = spawn_supervised(
®istry,
fast_spec("panicky", RestartPolicy::OnFailure),
rx,
move || {
let runs = runs_for_task.clone();
async move {
if runs.fetch_add(1, Ordering::SeqCst) == 0 {
panic!("first run explodes");
}
}
},
);
tokio::time::timeout(Duration::from_secs(5), handle)
.await
.expect("supervisor did not settle")
.expect("supervisor task itself must never panic");
assert_eq!(runs.load(Ordering::SeqCst), 2, "task must have run twice");
let health = ®istry.tasks()[0];
assert_eq!(health.restarts(), 1);
assert_eq!(health.state(), TaskState::Stopped);
}
#[tokio::test]
async fn panic_is_recorded_with_its_message() {
let registry = Arc::new(HealthRegistry::new());
let (_tx, rx) = watch::channel(false);
let runs = Arc::new(AtomicU32::new(0));
let runs_for_task = runs.clone();
let handle = spawn_supervised(
®istry,
fast_spec("recorded", RestartPolicy::OnFailure),
rx,
move || {
let runs = runs_for_task.clone();
async move {
if runs.fetch_add(1, Ordering::SeqCst) == 0 {
panic!("boom in the subsystem");
}
}
},
);
let _ = tokio::time::timeout(Duration::from_secs(5), handle).await;
let err = registry.tasks()[0]
.last_error()
.expect("panic message must be recorded");
assert!(err.contains("boom in the subsystem"), "got {err}");
}
#[tokio::test]
async fn never_policy_does_not_restart() {
let registry = Arc::new(HealthRegistry::new());
let (_tx, rx) = watch::channel(false);
let runs = Arc::new(AtomicU32::new(0));
let runs_for_task = runs.clone();
let handle = spawn_supervised::<_, _, ()>(
®istry,
fast_spec("once", RestartPolicy::Never),
rx,
move || {
let runs = runs_for_task.clone();
async move {
runs.fetch_add(1, Ordering::SeqCst);
panic!("still not restarted");
}
},
);
tokio::time::timeout(Duration::from_secs(5), handle)
.await
.expect("supervisor did not settle")
.expect("supervisor task itself must never panic");
assert_eq!(runs.load(Ordering::SeqCst), 1);
assert_eq!(registry.tasks()[0].restarts(), 0);
assert_eq!(registry.tasks()[0].state(), TaskState::Failed);
}
#[tokio::test]
async fn shutdown_stops_the_loop_without_another_respawn() {
let registry = Arc::new(HealthRegistry::new());
let (tx, rx) = watch::channel(false);
let runs = Arc::new(AtomicU32::new(0));
let runs_for_task = runs.clone();
let handle = spawn_supervised(
®istry,
fast_spec("looper", RestartPolicy::Always),
rx.clone(),
move || {
let runs = runs_for_task.clone();
let mut shutdown = rx.clone();
async move {
runs.fetch_add(1, Ordering::SeqCst);
let _ = shutdown.wait_for(|stop| *stop).await;
}
},
);
tokio::time::sleep(Duration::from_millis(50)).await;
let before = runs.load(Ordering::SeqCst);
tx.send(true).expect("shutdown send");
tokio::time::timeout(Duration::from_secs(5), handle)
.await
.expect("supervisor did not stop on shutdown")
.expect("supervisor task itself must never panic");
assert_eq!(
runs.load(Ordering::SeqCst),
before,
"no respawn may happen after the shutdown signal"
);
assert_eq!(registry.tasks()[0].state(), TaskState::Stopped);
}
#[tokio::test]
async fn always_policy_keeps_retrying_a_failing_task() {
let registry = Arc::new(HealthRegistry::new());
let (tx, rx) = watch::channel(false);
let runs = Arc::new(AtomicU32::new(0));
let runs_for_task = runs.clone();
let handle = spawn_supervised(
®istry,
fast_spec("flapper", RestartPolicy::Always),
rx,
move || {
let runs = runs_for_task.clone();
async move {
runs.fetch_add(1, Ordering::SeqCst);
Err::<(), OlError>(OlError::new("OL-9999", "still broken"))
}
},
);
tokio::time::sleep(Duration::from_millis(300)).await;
assert!(
runs.load(Ordering::SeqCst) >= 3,
"Always must keep retrying, saw {} runs",
runs.load(Ordering::SeqCst)
);
assert!(registry.is_degraded(), "a flapping Always task is degraded");
tx.send(true).expect("shutdown send");
let _ = tokio::time::timeout(Duration::from_secs(5), handle).await;
}
#[test]
fn only_always_tasks_count_as_degraded() {
let registry = HealthRegistry::new();
let always = registry.register("boundary", RestartPolicy::Always);
let oneshot = registry.register("update-check", RestartPolicy::OnFailure);
assert!(registry.is_degraded());
assert_eq!(registry.degraded_names(), vec!["boundary"]);
always.set_state(TaskState::Running);
oneshot.set_state(TaskState::Stopped);
assert!(!registry.is_degraded());
assert_eq!(registry.degraded_count(), 0);
}
#[test]
fn subsystems_json_reports_state_and_restarts() {
let registry = HealthRegistry::new();
let h = registry.register("cloud-worker", RestartPolicy::Always);
h.set_state(TaskState::Running);
h.record_restart();
h.record_restart();
let json = registry.subsystems_json();
assert_eq!(json["cloud-worker"]["state"], "running");
assert_eq!(json["cloud-worker"]["restarts"], 2);
assert!(
json["cloud-worker"].get("last_error").is_none(),
"a healthy subsystem must not carry a last_error key"
);
assert_eq!(registry.total_restarts(), 2);
}
#[test]
fn recorded_errors_are_scrubbed() {
let health = TaskHealth::new("leaky", RestartPolicy::Always);
health.note_failure("bind failed for Bearer sk-ant-api03-DEADBEEFDEADBEEFDEADBEEF");
let stored = health.last_error().expect("error stored");
assert!(
!stored.contains("DEADBEEFDEADBEEF"),
"credential must be masked before storage, got {stored}"
);
}
#[test]
fn healthy_run_clears_the_failure_streak_once() {
let health = TaskHealth::new("streaky", RestartPolicy::Always);
assert_eq!(health.note_failure("a"), 1);
assert_eq!(health.note_failure("b"), 2);
assert!(health.note_healthy(), "first clear reports the recovery");
assert!(!health.note_healthy(), "a second clear reports nothing");
assert_eq!(health.consecutive_failures(), 0);
assert!(health.last_error().is_none());
}
}