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/// Linux process birth tick. Paired with the boot ID, it prevents a recycled
85/// PID from inheriting a writer's lease or process-tree membership.
86#[cfg(target_os = "linux")]
87pub fn process_birth(pid: u32) -> Option<String> {
88    let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
89    let fields = stat.rsplit_once(") ")?.1;
90    let tick = fields.split_whitespace().nth(19)?;
91    Some(tick.to_owned())
92}
93
94#[cfg(target_os = "macos")]
95pub fn process_birth(pid: u32) -> Option<String> {
96    let pid = i32::try_from(pid).ok()?;
97    let size = i32::try_from(std::mem::size_of::<libc::proc_bsdinfo>()).ok()?;
98    let mut info = std::mem::MaybeUninit::<libc::proc_bsdinfo>::uninit();
99    let read = unsafe {
100        libc::proc_pidinfo(
101            pid,
102            libc::PROC_PIDTBSDINFO,
103            0,
104            info.as_mut_ptr().cast(),
105            size,
106        )
107    };
108    if read != size {
109        return None;
110    }
111    let info = unsafe { info.assume_init() };
112    Some(format!(
113        "{}:{}",
114        info.pbi_start_tvsec, info.pbi_start_tvusec
115    ))
116}
117
118#[cfg(not(any(target_os = "linux", target_os = "macos")))]
119pub fn process_birth(_pid: u32) -> Option<String> {
120    None
121}
122
123#[cfg(not(unix))]
124pub fn process_alive(_pid: u32) -> bool {
125    // Windows path — we don't have a kill(0) primitive without pulling
126    // the Win32 process query in here. Default to Alive; the terminal-
127    // status TTL remains the backstop.
128    true
129}
130
131/// Evaluate a heartbeat lease, using PID and boot identity as early death
132/// signals when a long-lived owner process was explicitly recorded.
133pub fn reservation_liveness_at(
134    pid: Option<u32>,
135    recorded_boot_id: Option<&str>,
136    heartbeat_at: Option<DateTime<Utc>>,
137    now: DateTime<Utc>,
138) -> Liveness {
139    if pid.is_some_and(|pid| !process_alive(pid)) {
140        return Liveness::Dead;
141    }
142
143    if matches!(
144        (recorded_boot_id, current_boot_id()),
145        (Some(recorded), Some(current)) if recorded != current
146    ) {
147        return Liveness::Dead;
148    }
149
150    match heartbeat_at {
151        Some(heartbeat) if now <= heartbeat + AGENT_LEASE_DURATION => Liveness::Alive,
152        Some(_) => Liveness::Dead,
153        None => Liveness::Dead,
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn process_alive_returns_true_for_self() {
163        assert!(process_alive(std::process::id()));
164    }
165
166    #[test]
167    fn process_alive_returns_false_for_pid_zero() {
168        assert!(!process_alive(0));
169    }
170
171    #[test]
172    fn process_alive_returns_false_for_unlikely_pid() {
173        // PID 0x7fff_ffff is reserved on Linux and never assignable.
174        // On macOS pids cap below 100k by default, so this is also
175        // safely never-allocated. We accept the result for either case
176        // since the test exists to ensure the ESRCH path is reachable.
177        assert!(!process_alive(0x7fff_ffff));
178    }
179
180    #[test]
181    fn reservation_is_alive_from_fresh_heartbeat_without_pid() {
182        let now = Utc::now();
183        assert_eq!(
184            reservation_liveness_at(None, None, Some(now), now),
185            Liveness::Alive
186        );
187    }
188
189    #[test]
190    fn reservation_is_dead_when_boot_id_mismatches() {
191        let now = Utc::now();
192        let pid = std::process::id();
193        let liveness = reservation_liveness_at(
194            Some(pid),
195            Some("definitely-not-the-current-boot-id"),
196            Some(now),
197            now,
198        );
199        if current_boot_id().is_some() {
200            assert_eq!(liveness, Liveness::Dead);
201        } else {
202            assert_eq!(liveness, Liveness::Alive);
203        }
204    }
205
206    #[test]
207    fn reservation_is_alive_when_lease_and_process_are_current() {
208        let now = Utc::now();
209        let pid = std::process::id();
210        let boot = current_boot_id();
211        assert_eq!(
212            reservation_liveness_at(Some(pid), boot.as_deref(), Some(now), now),
213            Liveness::Alive
214        );
215    }
216
217    #[test]
218    fn reservation_is_dead_when_pid_is_dead() {
219        let now = Utc::now();
220        let liveness = reservation_liveness_at(
221            Some(0x7fff_ffff),
222            current_boot_id().as_deref(),
223            Some(now),
224            now,
225        );
226        assert_eq!(liveness, Liveness::Dead);
227    }
228
229    #[test]
230    fn reservation_is_dead_when_heartbeat_lease_expires() {
231        let now = Utc::now();
232        assert_eq!(
233            reservation_liveness_at(
234                None,
235                None,
236                Some(now - AGENT_LEASE_DURATION - Duration::seconds(1)),
237                now,
238            ),
239            Liveness::Dead
240        );
241    }
242
243    #[test]
244    fn reservation_is_dead_without_heartbeat() {
245        assert_eq!(
246            reservation_liveness_at(None, None, None, Utc::now()),
247            Liveness::Dead
248        );
249    }
250}