use std::path::PathBuf;
use std::time::Duration;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::paths::AppPaths;
use crate::process::{Adoption, ProcessIdentity};
use crate::service::{ServiceError, read_state_record, remove_state_record, write_state_record};
pub const HEARTBEAT_FILE: &str = "daemon-heartbeat.toml";
pub const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(60);
pub const STALLED_AFTER: Duration = Duration::from_secs(15 * HEARTBEAT_INTERVAL.as_secs());
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct Heartbeat {
schema_version: u32,
process: ProcessIdentity,
at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Liveness {
Unknown,
Beating { at: DateTime<Utc> },
Stalled { since: DateTime<Utc>, pid: u32 },
Exited,
}
impl Liveness {
#[must_use]
pub fn stall(&self, last_github_contact: Option<DateTime<Utc>>) -> Option<String> {
let Self::Stalled { since, pid } = self else {
return None;
};
let contact = last_github_contact.map_or_else(
|| "it has never reached GitHub".to_owned(),
|at| format!("it last reached GitHub at {}", at.to_rfc3339()),
);
Some(format!(
"the service (process {pid}) has made no progress since {}: its loop has not \
beaten for over {} minutes, and {contact}. A daemon blocked in a file system call \
looks like this, for example on a runner root behind a macOS question nobody has \
answered (look for a dialog on the Mac's desktop). If nothing is waiting and the \
machine has not just woken from sleep, restart it: `runner-manager service stop` \
then `runner-manager service start`",
since.to_rfc3339(),
STALLED_AFTER.as_secs() / 60
))
}
}
fn path(paths: &AppPaths) -> PathBuf {
paths.state_dir().join(HEARTBEAT_FILE)
}
pub fn beat(paths: &AppPaths, at: DateTime<Utc>) -> Result<(), ServiceError> {
let process = ProcessIdentity::of_current_process().map_err(|error| ServiceError::Record {
operation: "write",
path: path(paths),
detail: error.to_string(),
})?;
write_state_record(
&path(paths),
&Heartbeat {
schema_version: 1,
process,
at,
},
)
}
pub fn stop(paths: &AppPaths) -> Result<(), ServiceError> {
remove_state_record(&path(paths))
}
#[must_use]
pub fn liveness(paths: &AppPaths, now: DateTime<Utc>) -> Liveness {
let Ok(Some(heartbeat)) = read_state_record::<Heartbeat>(&path(paths)) else {
return Liveness::Unknown;
};
let running = !matches!(
heartbeat.process.recheck(),
Ok(Adoption::Gone | Adoption::PidRecycled { .. })
);
judge(heartbeat.at, heartbeat.process.pid(), running, now)
}
fn judge(at: DateTime<Utc>, pid: u32, running: bool, now: DateTime<Utc>) -> Liveness {
if !running {
Liveness::Exited
} else if crate::wsl::fence::elapsed_at_least(at, now, STALLED_AFTER) {
Liveness::Stalled { since: at, pid }
} else {
Liveness::Beating { at }
}
}
#[cfg(test)]
mod tests {
use super::*;
fn at(minute: i64) -> DateTime<Utc> {
DateTime::from_timestamp(1_790_000_000 + minute * 60, 0).unwrap()
}
#[test]
fn a_running_daemon_that_stopped_beating_is_stalled() {
assert_eq!(
judge(at(0), 7, true, at(14)),
Liveness::Beating { at: at(0) }
);
assert_eq!(
judge(at(0), 7, true, at(15)),
Liveness::Stalled {
since: at(0),
pid: 7
}
);
assert_eq!(judge(at(0), 7, false, at(30)), Liveness::Exited);
}
#[test]
fn this_process_beats_and_a_graceful_exit_removes_the_record() {
let root = tempfile::tempdir().unwrap();
let paths = AppPaths::rooted_at(root.path());
paths.create_all().unwrap();
assert_eq!(liveness(&paths, Utc::now()), Liveness::Unknown);
beat(&paths, Utc::now()).unwrap();
assert!(matches!(
liveness(&paths, Utc::now()),
Liveness::Beating { .. }
));
let stalled = liveness(&paths, Utc::now() + chrono::Duration::minutes(16));
assert!(matches!(stalled, Liveness::Stalled { pid, .. } if pid == std::process::id()));
let said = stalled.stall(None).expect("a stall is a problem");
assert!(said.contains("made no progress since"), "{said}");
assert!(said.contains("never reached GitHub"), "{said}");
stop(&paths).unwrap();
assert_eq!(liveness(&paths, Utc::now()), Liveness::Unknown);
}
}