use std::future::Future;
use std::io;
use std::time::Duration;
pub const CHILD_ENV_MARKER: &str = "CORDIS_SUPERVISED";
pub const EXIT_RESTART: i32 = 51;
pub const EXIT_QUIT: i32 = 52;
pub const EXIT_BOOT: i32 = 53;
const RAPID_RESTART_WINDOW: Duration = Duration::from_secs(30);
const RAPID_RESTART_LIMIT: usize = 5;
const HEALTHY_RUN_DURATION: Duration = Duration::from_secs(10 * 60);
const UNHEALTHY_RUN_DURATION: Duration = Duration::from_secs(10);
const BACKOFF_INITIAL_DELAY: Duration = Duration::from_millis(100);
const BACKOFF_MAX_DELAY: Duration = Duration::from_secs(5);
pub const WORKER_SHUTDOWN_GRACE: Duration = Duration::from_secs(10);
pub async fn wait_with_grace(child: &mut tokio::process::Child) -> Option<i32> {
match tokio::time::timeout(WORKER_SHUTDOWN_GRACE, child.wait()).await {
Ok(Ok(status)) => status.code(),
Ok(Err(_)) => None,
Err(_) => {
tracing::warn!("worker exceeded shutdown grace, killed");
let _ = child.kill().await;
child.wait().await.ok().and_then(|s| s.code())
}
}
}
fn next_backoff(consecutive_unhealthy: u32) -> Duration {
let shift = consecutive_unhealthy.min(16);
BACKOFF_INITIAL_DELAY
.checked_mul(1u32 << shift)
.unwrap_or(BACKOFF_MAX_DELAY)
.min(BACKOFF_MAX_DELAY)
}
fn now() -> u64 {
#[cfg(test)]
if let Some(ms) = NOW_OVERRIDE.lock().clone() {
return ms;
}
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(test)]
static NOW_OVERRIDE: parking_lot::Mutex<Option<u64>> = parking_lot::Mutex::new(None);
#[cfg(test)]
static BACKOFF_DELAYS: parking_lot::Mutex<Vec<Duration>> = parking_lot::Mutex::new(Vec::new());
pub struct SupervisedChild {
pub child: std::process::Child,
pub stdin: std::process::ChildStdin,
}
pub fn is_supervised() -> bool {
std::env::var_os(CHILD_ENV_MARKER).is_some()
}
pub async fn supervise<F, Fut>(run_child: F) -> Result<(), io::Error>
where
F: Fn() -> Fut,
Fut: Future<Output = Option<i32>>,
{
if is_supervised() {
return Ok(());
}
let mut restarts: Vec<u64> = Vec::new();
let mut spawned_at = now();
let mut consecutive_unhealthy: u32 = 0;
loop {
let code = run_child().await;
let ran_for = now().saturating_sub(spawned_at);
let healthy_run = ran_for >= UNHEALTHY_RUN_DURATION.as_millis() as u64;
if healthy_run {
consecutive_unhealthy = 0;
}
match code {
Some(EXIT_RESTART) => {}
_ => return Ok(()),
}
if ran_for >= HEALTHY_RUN_DURATION.as_millis() as u64 {
restarts.clear();
tracing::info!(
ran_for_ms = ran_for,
"supervisor: long-lived worker exited cleanly; restart backoff reset"
);
}
if !healthy_run {
let delay = next_backoff(consecutive_unhealthy);
tracing::warn!(
delay_ms = delay.as_millis() as u64,
consecutive_unhealthy,
"supervisor: worker exited before proving health; backing off before respawn"
);
#[cfg(test)]
BACKOFF_DELAYS.lock().push(delay);
#[cfg(not(test))]
tokio::time::sleep(delay).await;
consecutive_unhealthy += 1;
}
let stamp = now();
restarts.retain(|at| stamp.saturating_sub(*at) < RAPID_RESTART_WINDOW.as_millis() as u64);
restarts.push(stamp);
if restarts.len() >= RAPID_RESTART_LIMIT {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"rapid restart loop detected",
));
}
spawned_at = now();
}
}
pub fn spawn_self_supervised() -> Result<SupervisedChild, io::Error> {
use std::process::{Command, Stdio};
let exe = std::env::current_exe()?;
let mut command = Command::new(exe);
command
.args(std::env::args_os().skip(1))
.env(CHILD_ENV_MARKER, "1")
.stdin(Stdio::piped())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
let mut child = command.spawn()?;
let stdin = child.stdin.take().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"child standard input pipe was not created",
)
})?;
Ok(SupervisedChild { child, stdin })
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
static ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
#[derive(Clone)]
struct Script {
statuses: Arc<Vec<Option<i32>>>,
calls: Arc<AtomicUsize>,
}
impl Script {
fn new(statuses: Vec<Option<i32>>) -> Self {
Self {
statuses: Arc::new(statuses),
calls: Arc::new(AtomicUsize::new(0)),
}
}
fn calls(&self) -> usize {
self.calls.load(Ordering::SeqCst)
}
async fn step(self) -> Option<i32> {
let nth = self.calls.fetch_add(1, Ordering::SeqCst);
match self.statuses.get(nth) {
Some(code) => *code,
None => Some(0),
}
}
}
#[tokio::test]
async fn child_exit_codes_drive_loop() {
let _guard = ENV_LOCK.lock().await;
std::env::remove_var(CHILD_ENV_MARKER);
let script = Script::new(vec![Some(EXIT_RESTART), Some(0)]);
let result = supervise(|| script.clone().step()).await;
assert!(result.is_ok());
assert_eq!(script.calls(), 2);
}
#[tokio::test]
async fn rapid_restart_cap_trips() {
let _guard = ENV_LOCK.lock().await;
std::env::remove_var(CHILD_ENV_MARKER);
let script = Script::new(vec![Some(EXIT_RESTART); RAPID_RESTART_LIMIT]);
let result = supervise(|| script.clone().step()).await;
let err = result.expect_err("five rapid restarts must stop the loop");
assert!(err.to_string().contains("rapid restart loop"));
assert_eq!(script.calls(), RAPID_RESTART_LIMIT);
}
#[derive(Clone)]
struct ClockScript {
statuses: Arc<Vec<Option<i32>>>,
exits: Arc<Vec<u64>>,
calls: Arc<AtomicUsize>,
}
impl ClockScript {
fn new(statuses: Vec<Option<i32>>, exits: Vec<u64>) -> Self {
Self {
statuses: Arc::new(statuses),
exits: Arc::new(exits),
calls: Arc::new(AtomicUsize::new(0)),
}
}
fn calls(&self) -> usize {
self.calls.load(Ordering::SeqCst)
}
async fn step(self) -> Option<i32> {
let nth = self.calls.fetch_add(1, Ordering::SeqCst);
if let Some(at) = self.exits.get(nth) {
*NOW_OVERRIDE.lock() = Some(*at);
}
match self.statuses.get(nth) {
Some(code) => *code,
None => Some(0),
}
}
}
#[tokio::test]
async fn long_lived_run_resets_rapid_restart_ladder() {
let _guard = ENV_LOCK.lock().await;
std::env::remove_var(CHILD_ENV_MARKER);
const HEALTHY_MS: u64 = HEALTHY_RUN_DURATION.as_millis() as u64;
const BASE: u64 = 1_000_000_000;
let exits = vec![
BASE + 1,
BASE + 2,
BASE + 3,
BASE + 3 + HEALTHY_MS,
BASE + 3 + HEALTHY_MS + 4,
];
let script = ClockScript::new(vec![Some(EXIT_RESTART); 5], exits);
*NOW_OVERRIDE.lock() = Some(BASE);
let result = supervise(|| script.clone().step()).await;
*NOW_OVERRIDE.lock() = None;
assert!(
result.is_ok(),
"two post-reset crashes must stay under the cap: {result:?}"
);
assert_eq!(script.calls(), 6);
}
#[tokio::test]
async fn all_rapid_runs_without_reset_still_trip_cap() {
let _guard = ENV_LOCK.lock().await;
std::env::remove_var(CHILD_ENV_MARKER);
const BASE: u64 = 2_000_000_000;
let exits: Vec<u64> = (1..=5).map(|i| BASE + i).collect();
let script = ClockScript::new(vec![Some(EXIT_RESTART); 5], exits);
*NOW_OVERRIDE.lock() = Some(BASE);
let result = supervise(|| script.clone().step()).await;
*NOW_OVERRIDE.lock() = None;
let err = result.expect_err("five rapid restarts must stop the loop");
assert!(err.to_string().contains("rapid restart loop"));
assert_eq!(script.calls(), RAPID_RESTART_LIMIT);
}
#[tokio::test]
async fn supervised_mode_short_circuits() {
let _guard = ENV_LOCK.lock().await;
std::env::set_var(CHILD_ENV_MARKER, "1");
assert!(is_supervised());
let script = Script::new(Vec::new());
let result = supervise(|| script.clone().step()).await;
assert!(result.is_ok());
assert_eq!(script.calls(), 0);
std::env::remove_var(CHILD_ENV_MARKER);
}
#[test]
fn shutdown_grace_is_ten_seconds() {
assert_eq!(WORKER_SHUTDOWN_GRACE, Duration::from_secs(10));
}
#[tokio::test]
async fn wait_with_grace_returns_fast_child_code() {
let mut child = tokio::process::Command::new("true")
.stdin(std::process::Stdio::null())
.spawn()
.expect("spawn true");
let code = wait_with_grace(&mut child).await;
assert_eq!(code, Some(0));
}
#[tokio::test]
async fn wait_with_grace_kills_child_after_timeout() {
let mut child = tokio::process::Command::new("sleep")
.arg("60")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.spawn()
.expect("spawn sleep");
let code = wait_with_grace(&mut child).await;
assert_eq!(code, None);
}
#[test]
fn backoff_doubles_and_caps() {
let cases = [
(0u32, Duration::from_millis(100)),
(1, Duration::from_millis(200)),
(2, Duration::from_millis(400)),
(3, Duration::from_millis(800)),
(4, Duration::from_millis(1600)),
(5, Duration::from_millis(3200)),
(6, Duration::from_secs(5)),
(7, Duration::from_secs(5)),
(100, Duration::from_secs(5)),
(u32::MAX, Duration::from_secs(5)),
];
for (n, expected) in cases {
assert_eq!(
next_backoff(n),
expected,
"next_backoff({n}) must be {expected:?}"
);
}
}
#[tokio::test]
async fn healthy_run_resets_backoff_counter() {
let _guard = ENV_LOCK.lock().await;
std::env::remove_var(CHILD_ENV_MARKER);
const HEALTHY_MS: u64 = HEALTHY_RUN_DURATION.as_millis() as u64;
const BASE: u64 = 3_000_000_000;
BACKOFF_DELAYS.lock().clear();
const UNHEALTHY_MS: u64 = UNHEALTHY_RUN_DURATION.as_millis() as u64;
let exits = vec![
BASE + 1,
BASE + 2,
BASE + 2 + HEALTHY_MS,
BASE + 2 + HEALTHY_MS + UNHEALTHY_MS - 1,
];
let script = ClockScript::new(
vec![
Some(EXIT_RESTART),
Some(EXIT_RESTART),
Some(EXIT_RESTART),
Some(EXIT_RESTART),
],
exits,
);
*NOW_OVERRIDE.lock() = Some(BASE);
let result = supervise(|| script.clone().step()).await;
*NOW_OVERRIDE.lock() = None;
assert!(result.is_ok(), "exhausted script ends clean: {result:?}");
assert_eq!(
*BACKOFF_DELAYS.lock(),
vec![
Duration::from_millis(100),
Duration::from_millis(200),
Duration::from_millis(100),
]
);
BACKOFF_DELAYS.lock().clear();
}
}