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]
62 pub fn stall(&self, last_github_contact: Option<DateTime<Utc>>) -> Option<String> {
63 let Self::Stalled { since, pid } = self else {
64 return None;
65 };
66 let contact = last_github_contact.map_or_else(
67 || "it has never reached GitHub".to_owned(),
68 |at| format!("it last reached GitHub at {}", at.to_rfc3339()),
69 );
70 Some(format!(
71 "the service (process {pid}) has made no progress since {}: its loop has not \
72 beaten for over {} minutes, and {contact}. A daemon blocked in a file system call \
73 looks like this, for example on a runner root behind a macOS question nobody has \
74 answered (look for a dialog on the Mac's desktop). If nothing is waiting and the \
75 machine has not just woken from sleep, restart it: `runner-manager service stop` \
76 then `runner-manager service start`",
77 since.to_rfc3339(),
78 STALLED_AFTER.as_secs() / 60
79 ))
80 }
81}
82
83fn path(paths: &AppPaths) -> PathBuf {
84 paths.state_dir().join(HEARTBEAT_FILE)
85}
86
87pub fn beat(paths: &AppPaths, at: DateTime<Utc>) -> Result<(), ServiceError> {
92 let process = ProcessIdentity::of_current_process().map_err(|error| ServiceError::Record {
93 operation: "write",
94 path: path(paths),
95 detail: error.to_string(),
96 })?;
97 write_state_record(
98 &path(paths),
99 &Heartbeat {
100 schema_version: 1,
101 process,
102 at,
103 },
104 )
105}
106
107pub fn stop(paths: &AppPaths) -> Result<(), ServiceError> {
112 remove_state_record(&path(paths))
113}
114
115#[must_use]
117pub fn liveness(paths: &AppPaths, now: DateTime<Utc>) -> Liveness {
118 let Ok(Some(heartbeat)) = read_state_record::<Heartbeat>(&path(paths)) else {
119 return Liveness::Unknown;
120 };
121 let running = !matches!(
126 heartbeat.process.recheck(),
127 Ok(Adoption::Gone | Adoption::PidRecycled { .. })
128 );
129 judge(heartbeat.at, heartbeat.process.pid(), running, now)
130}
131
132fn judge(at: DateTime<Utc>, pid: u32, running: bool, now: DateTime<Utc>) -> Liveness {
133 if !running {
134 Liveness::Exited
135 } else if crate::wsl::fence::elapsed_at_least(at, now, STALLED_AFTER) {
136 Liveness::Stalled { since: at, pid }
137 } else {
138 Liveness::Beating { at }
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 fn at(minute: i64) -> DateTime<Utc> {
147 DateTime::from_timestamp(1_790_000_000 + minute * 60, 0).unwrap()
148 }
149
150 #[test]
151 fn a_running_daemon_that_stopped_beating_is_stalled() {
152 assert_eq!(
153 judge(at(0), 7, true, at(14)),
154 Liveness::Beating { at: at(0) }
155 );
156 assert_eq!(
157 judge(at(0), 7, true, at(15)),
158 Liveness::Stalled {
159 since: at(0),
160 pid: 7
161 }
162 );
163 assert_eq!(judge(at(0), 7, false, at(30)), Liveness::Exited);
164 }
165
166 #[test]
167 fn this_process_beats_and_a_graceful_exit_removes_the_record() {
168 let root = tempfile::tempdir().unwrap();
169 let paths = AppPaths::rooted_at(root.path());
170 paths.create_all().unwrap();
171 assert_eq!(liveness(&paths, Utc::now()), Liveness::Unknown);
172
173 beat(&paths, Utc::now()).unwrap();
174 assert!(matches!(
175 liveness(&paths, Utc::now()),
176 Liveness::Beating { .. }
177 ));
178 let stalled = liveness(&paths, Utc::now() + chrono::Duration::minutes(16));
180 assert!(matches!(stalled, Liveness::Stalled { pid, .. } if pid == std::process::id()));
181 let said = stalled.stall(None).expect("a stall is a problem");
182 assert!(said.contains("made no progress since"), "{said}");
183 assert!(said.contains("never reached GitHub"), "{said}");
184
185 stop(&paths).unwrap();
186 assert_eq!(liveness(&paths, Utc::now()), Liveness::Unknown);
187 }
188}