Skip to main content

objects/store/
liveness.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Heartbeat-lease liveness for agent reservations.
3
4use chrono::{DateTime, Duration, Utc};
5
6pub const AGENT_LEASE_DURATION: Duration = Duration::minutes(5);
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum Liveness {
10    /// The heartbeat lease is current and any recorded process is alive.
11    Alive,
12    /// The lease expired, the process exited, or the host rebooted.
13    Dead,
14}
15
16impl std::fmt::Display for Liveness {
17    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18        match self {
19            Self::Alive => write!(f, "alive"),
20            Self::Dead => write!(f, "dead"),
21        }
22    }
23}
24
25/// Best-effort current boot identifier.
26///
27/// - Linux: `/proc/sys/kernel/random/boot_id`.
28/// - macOS: a stable prefix of `sysctl -n kern.boottime` (the `{ sec = …, usec = … }`
29///   half is stable across invocations on the same boot; the trailing
30///   human-readable date is not).
31/// - Everything else: `None`.
32#[cfg(target_os = "linux")]
33pub fn current_boot_id() -> Option<String> {
34    std::fs::read_to_string("/proc/sys/kernel/random/boot_id")
35        .ok()
36        .map(|value| value.trim().to_string())
37        .filter(|value| !value.is_empty())
38}
39
40#[cfg(target_os = "macos")]
41pub fn current_boot_id() -> Option<String> {
42    std::process::Command::new("sysctl")
43        .arg("-n")
44        .arg("kern.boottime")
45        .output()
46        .ok()
47        .filter(|output| output.status.success())
48        .and_then(|output| String::from_utf8(output.stdout).ok())
49        .map(|value| {
50            let trimmed = value.trim();
51            let cutoff = trimmed
52                .find('}')
53                .map(|idx| idx + 1)
54                .unwrap_or(trimmed.len());
55            trimmed[..cutoff].to_string()
56        })
57        .filter(|value| !value.is_empty())
58}
59
60#[cfg(not(any(target_os = "linux", target_os = "macos")))]
61pub fn current_boot_id() -> Option<String> {
62    None
63}
64
65/// `true` if the process identified by `pid` is still running. ESRCH
66/// from `kill(pid, 0)` is treated as dead. Any other error (notably
67/// EPERM — the process exists but is owned by a different user) is
68/// treated as alive: "alive in another uid namespace" still means the
69/// reservation might be valid.
70#[cfg(unix)]
71pub fn process_alive(pid: u32) -> bool {
72    let pid = pid as libc::pid_t;
73    if pid <= 0 {
74        return false;
75    }
76    let result = unsafe { libc::kill(pid, 0) };
77    if result == 0 {
78        return true;
79    }
80    let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
81    errno != libc::ESRCH
82}
83
84#[cfg(not(unix))]
85pub fn process_alive(_pid: u32) -> bool {
86    // Windows path — we don't have a kill(0) primitive without pulling
87    // the Win32 process query in here. Default to Alive; the terminal-
88    // status TTL remains the backstop.
89    true
90}
91
92/// Evaluate a heartbeat lease, using PID and boot identity as early death
93/// signals when a long-lived owner process was explicitly recorded.
94pub fn reservation_liveness_at(
95    pid: Option<u32>,
96    recorded_boot_id: Option<&str>,
97    heartbeat_at: Option<DateTime<Utc>>,
98    now: DateTime<Utc>,
99) -> Liveness {
100    if pid.is_some_and(|pid| !process_alive(pid)) {
101        return Liveness::Dead;
102    }
103
104    if matches!(
105        (recorded_boot_id, current_boot_id()),
106        (Some(recorded), Some(current)) if recorded != current
107    ) {
108        return Liveness::Dead;
109    }
110
111    match heartbeat_at {
112        Some(heartbeat) if now <= heartbeat + AGENT_LEASE_DURATION => Liveness::Alive,
113        Some(_) => Liveness::Dead,
114        None => Liveness::Dead,
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn process_alive_returns_true_for_self() {
124        assert!(process_alive(std::process::id()));
125    }
126
127    #[test]
128    fn process_alive_returns_false_for_pid_zero() {
129        assert!(!process_alive(0));
130    }
131
132    #[test]
133    fn process_alive_returns_false_for_unlikely_pid() {
134        // PID 0x7fff_ffff is reserved on Linux and never assignable.
135        // On macOS pids cap below 100k by default, so this is also
136        // safely never-allocated. We accept the result for either case
137        // since the test exists to ensure the ESRCH path is reachable.
138        assert!(!process_alive(0x7fff_ffff));
139    }
140
141    #[test]
142    fn reservation_is_alive_from_fresh_heartbeat_without_pid() {
143        let now = Utc::now();
144        assert_eq!(
145            reservation_liveness_at(None, None, Some(now), now),
146            Liveness::Alive
147        );
148    }
149
150    #[test]
151    fn reservation_is_dead_when_boot_id_mismatches() {
152        let now = Utc::now();
153        let pid = std::process::id();
154        let liveness = reservation_liveness_at(
155            Some(pid),
156            Some("definitely-not-the-current-boot-id"),
157            Some(now),
158            now,
159        );
160        if current_boot_id().is_some() {
161            assert_eq!(liveness, Liveness::Dead);
162        } else {
163            assert_eq!(liveness, Liveness::Alive);
164        }
165    }
166
167    #[test]
168    fn reservation_is_alive_when_lease_and_process_are_current() {
169        let now = Utc::now();
170        let pid = std::process::id();
171        let boot = current_boot_id();
172        assert_eq!(
173            reservation_liveness_at(Some(pid), boot.as_deref(), Some(now), now),
174            Liveness::Alive
175        );
176    }
177
178    #[test]
179    fn reservation_is_dead_when_pid_is_dead() {
180        let now = Utc::now();
181        let liveness = reservation_liveness_at(
182            Some(0x7fff_ffff),
183            current_boot_id().as_deref(),
184            Some(now),
185            now,
186        );
187        assert_eq!(liveness, Liveness::Dead);
188    }
189
190    #[test]
191    fn reservation_is_dead_when_heartbeat_lease_expires() {
192        let now = Utc::now();
193        assert_eq!(
194            reservation_liveness_at(
195                None,
196                None,
197                Some(now - AGENT_LEASE_DURATION - Duration::seconds(1)),
198                now,
199            ),
200            Liveness::Dead
201        );
202    }
203
204    #[test]
205    fn reservation_is_dead_without_heartbeat() {
206        assert_eq!(
207            reservation_liveness_at(None, None, None, Utc::now()),
208            Liveness::Dead
209        );
210    }
211}