runner_manager_platform/
daemon_heartbeat.rs1use std::path::PathBuf;
17use std::time::Duration;
18
19use chrono::{DateTime, Utc};
20use serde::{Deserialize, Serialize};
21
22use crate::paths::AppPaths;
23use crate::process::{Adoption, ProcessIdentity};
24use crate::service::{ServiceError, read_state_record, remove_state_record, write_state_record};
25
26pub const HEARTBEAT_FILE: &str = "daemon-heartbeat.toml";
28
29pub const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(60);
31
32pub const STALLED_AFTER: Duration = Duration::from_secs(15 * HEARTBEAT_INTERVAL.as_secs());
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38struct Heartbeat {
39 schema_version: u32,
40 process: ProcessIdentity,
41 at: DateTime<Utc>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum Liveness {
47 Unknown,
50 Beating { at: DateTime<Utc> },
52 Stalled { since: DateTime<Utc>, pid: u32 },
54 Exited,
56}
57
58impl Liveness {
59 #[must_use]
63 pub fn stall(
64 &self,
65 last_github_contact: Option<DateTime<Utc>>,
66 restart: &str,
67 ) -> Option<String> {
68 let Self::Stalled { since, pid } = self else {
69 return None;
70 };
71 let contact = last_github_contact.map_or_else(
72 || "it has never reached GitHub".to_owned(),
73 |at| format!("it last reached GitHub at {}", at.to_rfc3339()),
74 );
75 Some(format!(
76 "the service (process {pid}) has made no progress since {}: its loop has not \
77 beaten for over {} minutes, and {contact}. A daemon blocked in a file system call \
78 looks like this, for example on a runner root behind a macOS question nobody has \
79 answered (look for a dialog on the Mac's desktop). If nothing is waiting and the \
80 machine has not just woken from sleep, restart it: `{restart}`",
81 since.to_rfc3339(),
82 STALLED_AFTER.as_secs() / 60
83 ))
84 }
85}
86
87fn path(paths: &AppPaths) -> PathBuf {
88 paths.state_dir().join(HEARTBEAT_FILE)
89}
90
91pub fn beat(paths: &AppPaths, at: DateTime<Utc>) -> Result<(), ServiceError> {
96 let process = ProcessIdentity::of_current_process().map_err(|error| ServiceError::Record {
97 operation: "write",
98 path: path(paths),
99 detail: error.to_string(),
100 })?;
101 write_state_record(
102 &path(paths),
103 &Heartbeat {
104 schema_version: 1,
105 process,
106 at,
107 },
108 )
109}
110
111pub fn stop(paths: &AppPaths) -> Result<(), ServiceError> {
116 remove_state_record(&path(paths))
117}
118
119#[must_use]
121pub fn liveness(paths: &AppPaths, now: DateTime<Utc>) -> Liveness {
122 let Ok(Some(heartbeat)) = read_state_record::<Heartbeat>(&path(paths)) else {
123 return Liveness::Unknown;
124 };
125 let running = !matches!(
130 heartbeat.process.recheck(),
131 Ok(Adoption::Gone | Adoption::PidRecycled { .. })
132 );
133 judge(heartbeat.at, heartbeat.process.pid(), running, now)
134}
135
136fn judge(at: DateTime<Utc>, pid: u32, running: bool, now: DateTime<Utc>) -> Liveness {
137 if !running {
138 Liveness::Exited
139 } else if crate::wsl::fence::elapsed_at_least(at, now, STALLED_AFTER) {
140 Liveness::Stalled { since: at, pid }
141 } else {
142 Liveness::Beating { at }
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149
150 fn at(minute: i64) -> DateTime<Utc> {
151 DateTime::from_timestamp(1_790_000_000 + minute * 60, 0).unwrap()
152 }
153
154 #[test]
155 fn a_running_daemon_that_stopped_beating_is_stalled() {
156 assert_eq!(
157 judge(at(0), 7, true, at(14)),
158 Liveness::Beating { at: at(0) }
159 );
160 assert_eq!(
161 judge(at(0), 7, true, at(15)),
162 Liveness::Stalled {
163 since: at(0),
164 pid: 7
165 }
166 );
167 assert_eq!(judge(at(0), 7, false, at(30)), Liveness::Exited);
168 }
169
170 #[test]
171 fn this_process_beats_and_a_graceful_exit_removes_the_record() {
172 let root = tempfile::tempdir().unwrap();
173 let paths = AppPaths::rooted_at(root.path());
174 paths.create_all().unwrap();
175 assert_eq!(liveness(&paths, Utc::now()), Liveness::Unknown);
176
177 beat(&paths, Utc::now()).unwrap();
178 assert!(matches!(
179 liveness(&paths, Utc::now()),
180 Liveness::Beating { .. }
181 ));
182 let stalled = liveness(&paths, Utc::now() + chrono::Duration::minutes(16));
184 assert!(matches!(stalled, Liveness::Stalled { pid, .. } if pid == std::process::id()));
185 let said = stalled
186 .stall(None, "the-restart-command")
187 .expect("a stall is a problem");
188 assert!(said.contains("made no progress since"), "{said}");
189 assert!(said.contains("`the-restart-command`"), "{said}");
190 assert!(said.contains("never reached GitHub"), "{said}");
191
192 stop(&paths).unwrap();
193 assert_eq!(liveness(&paths, Utc::now()), Liveness::Unknown);
194 }
195}