use super::{ExitObservation, SUPERVISOR, Supervisor, signalling_pid_is_authorized};
use crate::config_types::{HealthCmd, HealthHttp, HealthPort};
use crate::daemon::Daemon;
use crate::daemon_id::DaemonId;
use crate::daemon_status::DaemonStatus;
use crate::env;
use crate::procs::PROCS;
use crate::settings::settings;
use crate::supervisor::lifecycle::spawn_cmd_probe;
use std::collections::HashMap;
use std::time::Duration;
use tokio::task::JoinHandle;
use tokio::time;
impl Supervisor {
pub(crate) async fn manage_health_tasks(&self, tasks: &mut HashMap<DaemonId, JoinHandle<()>>) {
tasks.retain(|id, handle| {
if handle.is_finished() {
debug!("health check task for daemon {id} finished");
false
} else {
true
}
});
let pitchfork_id = DaemonId::pitchfork();
let to_spawn: Vec<DaemonId> = {
let state = self.state_file.lock().await;
state
.daemons
.values()
.filter(|d| {
d.id != pitchfork_id
&& d.status.is_running()
&& d.pid.is_some()
&& (d.health_cmd.is_some()
|| d.health_http.is_some()
|| d.health_port.is_some())
&& !tasks.contains_key(&d.id)
})
.map(|d| d.id.clone())
.collect()
};
for id in to_spawn {
info!("starting health checks for daemon {id}");
let task_id = id.clone();
let handle = tokio::spawn(async move {
SUPERVISOR.run_health_checks(task_id).await;
});
tasks.insert(id, handle);
}
}
async fn run_health_checks(&self, id: DaemonId) {
let mut last_pid: Option<u32> = None;
let mut last_start_time: Option<u64> = None;
let mut consecutive_failures: u32 = 0;
let mut http_client: Option<reqwest::Client> = None;
loop {
let Some(daemon) = self.current_health_target(&id).await else {
debug!("health checks for daemon {id}: not running or not configured, stopping");
return;
};
time::sleep(effective_interval(
&daemon.health_cmd,
&daemon.health_http,
&daemon.health_port,
))
.await;
let Some(daemon) = self.current_health_target(&id).await else {
return;
};
let Some(pid) = daemon.pid else {
return;
};
if process_identity_changed(last_pid, last_start_time, daemon.pid, daemon.start_time) {
consecutive_failures = 0;
last_pid = daemon.pid;
last_start_time = daemon.start_time;
}
let retries =
effective_retries(&daemon.health_cmd, &daemon.health_http, &daemon.health_port);
let mut failed_kinds: Vec<&str> = Vec::new();
if let Some(cmd) = &daemon.health_cmd
&& !health_cmd_probe(&id, &daemon, cmd).await
{
failed_kinds.push("cmd");
}
if let Some(http) = &daemon.health_http {
if http_client.is_none() {
http_client = Some(supervisor_http_client());
}
if let Some(client) = http_client.as_ref()
&& !health_http_probe(&id, http, client).await
{
failed_kinds.push("http");
}
}
if let Some(health_port) = daemon.health_port.as_ref()
&& let Some(port) = health_port.as_port()
&& !health_port_probe(&id, port, effective_port_timeout(health_port)).await
{
failed_kinds.push("port");
}
if failed_kinds.is_empty() {
consecutive_failures = 0;
continue;
}
consecutive_failures += 1;
warn!(
"daemon {id} health check failed ({}): {consecutive_failures}/{retries} consecutive failures",
failed_kinds.join("/"),
);
if consecutive_failures >= retries {
let reason = format!(
"due to health check failure ({consecutive_failures} consecutive failures)"
);
self.kill_daemon_as_crash(&id, pid, daemon.start_time, &reason)
.await;
return;
}
}
}
async fn current_health_target(&self, id: &DaemonId) -> Option<Daemon> {
let daemon = self.get_daemon(id).await?;
if daemon.status.is_running()
&& daemon.pid.is_some()
&& (daemon.health_cmd.is_some()
|| daemon.health_http.is_some()
|| daemon.health_port.is_some())
{
Some(daemon)
} else {
None
}
}
pub(crate) async fn kill_daemon_as_crash(
&self,
id: &DaemonId,
pid: u32,
expected_start_time: Option<u64>,
reason: &str,
) {
info!("killing daemon {id} (pid {pid}) {reason}");
let daemon = match self.get_daemon(id).await {
Some(daemon) if daemon.pid == Some(pid) => daemon,
_ => {
warn!("daemon {id} no longer owns pid {pid}; not killing it {reason}");
return;
}
};
if !recorded_identity_matches(&daemon, pid, expected_start_time) {
warn!(
"daemon {id} restarted since the failing check (recorded start_time {:?}, expected {:?}); not killing it {reason}",
daemon.start_time, expected_start_time
);
return;
}
if !signalling_pid_is_authorized(expected_start_time, PROCS.start_time(pid)) {
warn!(
"pid {pid} recorded for daemon {id} belongs to another process now; not killing it {reason}"
);
self.finalize_if_pid(
id,
pid,
DaemonStatus::Errored(-1),
ExitObservation::Unobserved,
)
.await;
return;
}
let stop_cfg = daemon.stop_signal.unwrap_or_default();
let stop_signal: i32 = stop_cfg.signal.into();
let Some(expected_start_time) = expected_start_time else {
warn!(
"daemon {id} has no recorded start time; cannot securely kill pid {pid} {reason}"
);
return;
};
let kill_result = PROCS
.kill_process_group_if_start_time_matches_async(
pid,
Some(expected_start_time),
stop_signal,
stop_cfg.timeout,
)
.await;
match kill_result {
Ok(true) => {}
Ok(false) => {
warn!("could not kill daemon {id} (pid {pid}) {reason}; retaining running state");
}
Err(err) => {
error!("failed to kill daemon {id} (pid {pid}) {reason}: {err}");
}
}
}
}
fn recorded_identity_matches(daemon: &Daemon, pid: u32, expected_start_time: Option<u64>) -> bool {
daemon.pid == Some(pid) && daemon.start_time == expected_start_time
}
fn process_identity_changed(
last_pid: Option<u32>,
last_start_time: Option<u64>,
current_pid: Option<u32>,
current_start_time: Option<u64>,
) -> bool {
last_pid != current_pid || last_start_time != current_start_time
}
pub(crate) fn supervisor_http_client() -> reqwest::Client {
reqwest::Client::builder().build().unwrap_or_default()
}
fn effective_interval(
cmd: &Option<HealthCmd>,
http: &Option<HealthHttp>,
port: &Option<HealthPort>,
) -> Duration {
cmd.as_ref()
.and_then(|c| c.interval)
.or_else(|| http.as_ref().and_then(|h| h.interval))
.or_else(|| port.as_ref().and_then(|p| p.interval))
.unwrap_or_else(|| settings().supervisor_health_check_interval())
}
fn effective_cmd_timeout(cmd: &HealthCmd) -> Duration {
cmd.timeout
.unwrap_or_else(|| settings().supervisor_health_cmd_timeout())
}
fn effective_http_timeout(http: &HealthHttp) -> Duration {
http.timeout
.unwrap_or_else(|| settings().supervisor_health_http_timeout())
}
fn effective_port_timeout(port: &HealthPort) -> Duration {
port.timeout
.unwrap_or_else(|| settings().supervisor_health_port_timeout())
}
fn effective_retries(
cmd: &Option<HealthCmd>,
http: &Option<HealthHttp>,
port: &Option<HealthPort>,
) -> u32 {
[
cmd.as_ref().and_then(|c| c.retries),
http.as_ref().and_then(|h| h.retries),
port.as_ref().and_then(|p| p.retries),
]
.into_iter()
.flatten()
.min()
.unwrap_or_else(|| {
settings()
.supervisor
.health_check_retries
.clamp(1, u32::MAX as i64) as u32
})
}
async fn health_cmd_probe(id: &DaemonId, daemon: &Daemon, cmd: &HealthCmd) -> bool {
let dir = daemon.dir.as_deref().unwrap_or_else(|| env::CWD.as_path());
let probe = spawn_cmd_probe(
id,
&cmd.run,
dir,
daemon.retry_count,
daemon.env.as_ref(),
&daemon.resolved_port,
);
let timeout = effective_cmd_timeout(cmd);
match tokio::time::timeout(timeout, probe.result_rx).await {
Ok(Ok(Ok(status))) => status.success(),
Ok(_) => false,
Err(_) => {
let _ = probe.cancel_tx.send(());
false
}
}
}
async fn health_http_probe(id: &DaemonId, http: &HealthHttp, client: &reqwest::Client) -> bool {
let timeout = effective_http_timeout(http);
let response = match tokio::time::timeout(timeout, client.get(&http.url).send()).await {
Ok(Ok(response)) => response,
Ok(Err(e)) => {
debug!("daemon {id} health check (http) request failed: {e}");
return false;
}
Err(_) => {
debug!("daemon {id} health check (http) timed out after {timeout:?}");
return false;
}
};
let status = response.status().as_u16();
if http.status.is_empty() {
(200..300).contains(&status)
} else {
http.status.contains(&status)
}
}
async fn health_port_probe(id: &DaemonId, port: u16, timeout: Duration) -> bool {
match tokio::time::timeout(timeout, tokio::net::TcpStream::connect(("127.0.0.1", port))).await {
Ok(Ok(_)) => true,
Ok(Err(e)) => {
debug!("daemon {id} health check (port) connect to {port} failed: {e}");
false
}
Err(_) => {
debug!("daemon {id} health check (port) connect to {port} timed out after {timeout:?}");
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn health_cmd(
run: &str,
interval: Option<Duration>,
timeout: Option<Duration>,
retries: Option<u32>,
) -> Option<HealthCmd> {
Some(HealthCmd {
run: run.into(),
interval,
timeout,
retries,
})
}
fn health_http(
url: &str,
status: Vec<u16>,
interval: Option<Duration>,
timeout: Option<Duration>,
retries: Option<u32>,
) -> Option<HealthHttp> {
Some(HealthHttp {
url: url.into(),
status,
interval,
timeout,
retries,
})
}
fn health_port(
port: u16,
interval: Option<Duration>,
retries: Option<u32>,
) -> Option<HealthPort> {
Some(HealthPort {
port: Some(port),
template: None,
interval,
retries,
timeout: None,
})
}
#[test]
fn effective_interval_defaults_to_10s() {
assert_eq!(
effective_interval(&None, &None, &None),
settings().supervisor_health_check_interval()
);
assert_eq!(
effective_interval(
&health_cmd("true", None, None, None),
&None,
&health_port(8443, None, None)
),
settings().supervisor_health_check_interval()
);
}
#[test]
fn effective_interval_prefers_cmd_then_http_then_port_override() {
let cmd_interval = Duration::from_secs(2);
let http_interval = Duration::from_secs(7);
let port_interval = Duration::from_secs(11);
assert_eq!(
effective_interval(
&health_cmd("true", Some(cmd_interval), None, None),
&None,
&None
),
cmd_interval
);
assert_eq!(
effective_interval(
&None,
&health_http("http://x", vec![], Some(http_interval), None, None),
&health_port(8443, Some(port_interval), None),
),
http_interval
);
assert_eq!(
effective_interval(&None, &None, &health_port(8443, Some(port_interval), None),),
port_interval
);
assert_eq!(
effective_interval(
&health_cmd("true", Some(cmd_interval), None, None),
&health_http("http://x", vec![], Some(http_interval), None, None),
&health_port(8443, Some(port_interval), None),
),
cmd_interval
);
}
#[test]
fn effective_cmd_timeout_defaults_and_overrides() {
assert_eq!(
effective_cmd_timeout(&HealthCmd::new("true")),
settings().supervisor_health_cmd_timeout()
);
let override_ = Duration::from_secs(3);
assert_eq!(
effective_cmd_timeout(&HealthCmd {
run: "true".into(),
interval: None,
timeout: Some(override_),
retries: None,
}),
override_
);
}
#[test]
fn effective_http_timeout_defaults_and_overrides() {
assert_eq!(
effective_http_timeout(&HealthHttp::new("http://x")),
settings().supervisor_health_http_timeout()
);
let override_ = Duration::from_secs(2);
assert_eq!(
effective_http_timeout(&HealthHttp {
url: "http://x".into(),
status: vec![],
interval: None,
timeout: Some(override_),
retries: None,
}),
override_
);
}
#[test]
fn effective_retries_defaults_and_takes_strictest_budget() {
assert_eq!(
effective_retries(&None, &None, &None),
settings().supervisor.health_check_retries.max(1) as u32
);
assert_eq!(
effective_retries(&health_cmd("true", None, None, Some(2)), &None, &None),
2
);
assert_eq!(
effective_retries(
&None,
&health_http("http://x", vec![], None, None, Some(5)),
&None
),
5
);
assert_eq!(
effective_retries(&None, &None, &health_port(8443, None, Some(4))),
4
);
assert_eq!(
effective_retries(
&health_cmd("true", None, None, Some(5)),
&health_http("http://x", vec![], None, None, Some(2)),
&health_port(8443, None, Some(3)),
),
2
);
assert_eq!(
effective_retries(
&health_cmd("true", None, None, None),
&health_http("http://x", vec![], None, None, Some(1)),
&None,
),
1
);
}
#[test]
fn effective_port_timeout_defaults_and_overrides() {
assert_eq!(
effective_port_timeout(&HealthPort::new(8443)),
settings().supervisor_health_port_timeout()
);
let override_ = Duration::from_secs(9);
assert_eq!(
effective_port_timeout(&HealthPort {
port: Some(8443),
template: None,
interval: None,
retries: None,
timeout: Some(override_),
}),
override_
);
}
#[test]
fn process_identity_changed_resets_on_reused_pid() {
assert!(process_identity_changed(
Some(42),
Some(100),
Some(42),
Some(200)
));
assert!(!process_identity_changed(
Some(42),
Some(100),
Some(42),
Some(100)
));
assert!(process_identity_changed(
Some(42),
Some(100),
Some(43),
Some(100)
));
assert!(process_identity_changed(None, None, Some(42), Some(100)));
}
fn daemon_with(pid: Option<u32>, start_time: Option<u64>) -> Daemon {
Daemon {
id: DaemonId::new("ns", "x"),
pid,
start_time,
..Daemon::default()
}
}
#[test]
fn recorded_identity_matches_on_same_generation() {
assert!(recorded_identity_matches(
&daemon_with(Some(42), Some(100)),
42,
Some(100)
));
}
#[test]
fn recorded_identity_mismatches_on_different_pid() {
assert!(!recorded_identity_matches(
&daemon_with(Some(42), Some(100)),
43,
Some(100)
));
}
#[test]
fn recorded_identity_mismatches_on_reused_pid_with_new_start_time() {
assert!(!recorded_identity_matches(
&daemon_with(Some(42), Some(200)),
42,
Some(100)
));
}
#[test]
fn recorded_identity_mismatches_when_record_lacks_start_time() {
assert!(!recorded_identity_matches(
&daemon_with(Some(42), Some(100)),
42,
None
));
}
#[tokio::test]
async fn health_port_probe_connects_to_listening_port() {
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
.await
.unwrap();
let port = listener.local_addr().unwrap().port();
assert!(health_port_probe(&DaemonId::new("ns", "x"), port, Duration::from_secs(5)).await);
drop(listener);
assert!(!health_port_probe(&DaemonId::new("ns", "x"), port, Duration::from_secs(5)).await);
}
}