Skip to main content

acp_cli/queue/
lease.rs

1use serde::{Deserialize, Serialize};
2
3use crate::session::scoping::session_dir;
4
5/// Lease file stored at `~/.acp-cli/sessions/<key>.lease`.
6///
7/// The queue owner writes this file on startup and updates the heartbeat
8/// periodically. Other processes read it to determine whether a live owner
9/// already exists for a given session key.
10#[derive(Debug, Serialize, Deserialize)]
11pub struct LeaseFile {
12    /// PID of the queue owner process.
13    pub pid: u32,
14    /// Unix timestamp (seconds) when the owner started.
15    pub start_time: u64,
16    /// Unix timestamp (seconds) of the last heartbeat update.
17    pub last_heartbeat: u64,
18}
19
20impl LeaseFile {
21    /// Write a new lease file for `session_key` with the current PID and timestamp.
22    pub fn write(session_key: &str) -> std::io::Result<()> {
23        let path = lease_path(session_key);
24        if let Some(parent) = path.parent() {
25            std::fs::create_dir_all(parent)?;
26        }
27        let now = now_secs();
28        let lease = LeaseFile {
29            pid: std::process::id(),
30            start_time: now,
31            last_heartbeat: now,
32        };
33        let json = serde_json::to_string_pretty(&lease)
34            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
35        std::fs::write(&path, json)
36    }
37
38    /// Read the lease file for `session_key`. Returns `None` if the file is
39    /// missing or cannot be parsed.
40    pub fn read(session_key: &str) -> Option<LeaseFile> {
41        let path = lease_path(session_key);
42        let contents = std::fs::read_to_string(&path).ok()?;
43        serde_json::from_str(&contents).ok()
44    }
45
46    /// Update the `last_heartbeat` timestamp in an existing lease file.
47    pub fn update_heartbeat(session_key: &str) -> std::io::Result<()> {
48        let path = lease_path(session_key);
49        let contents = std::fs::read_to_string(&path)?;
50        let mut lease: LeaseFile = serde_json::from_str(&contents)
51            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
52        lease.last_heartbeat = now_secs();
53        let json = serde_json::to_string_pretty(&lease)
54            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
55        std::fs::write(&path, json)
56    }
57
58    /// Remove the lease file for `session_key` (best-effort).
59    pub fn remove(session_key: &str) {
60        let _ = std::fs::remove_file(lease_path(session_key));
61    }
62
63    /// Check whether the lease is still valid:
64    /// - The heartbeat is within `ttl_secs` of the current time.
65    /// - The process identified by `pid` is still alive.
66    pub fn is_valid(&self, ttl_secs: u64) -> bool {
67        let now = now_secs();
68        if now.saturating_sub(self.last_heartbeat) > ttl_secs {
69            return false;
70        }
71        is_process_alive(self.pid)
72    }
73}
74
75/// Return the lease file path for a session key.
76fn lease_path(session_key: &str) -> std::path::PathBuf {
77    session_dir().join(format!("{session_key}.lease"))
78}
79
80/// Current time as seconds since the Unix epoch.
81fn now_secs() -> u64 {
82    std::time::SystemTime::now()
83        .duration_since(std::time::UNIX_EPOCH)
84        .unwrap_or_default()
85        .as_secs()
86}
87
88/// Check whether a process with the given PID is alive (POSIX `kill(pid, 0)`).
89fn is_process_alive(pid: u32) -> bool {
90    let ret = unsafe { libc::kill(pid as libc::pid_t, 0) };
91    if ret == 0 {
92        return true;
93    }
94    std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    #[test]
102    fn current_process_is_alive() {
103        assert!(is_process_alive(std::process::id()));
104    }
105
106    #[test]
107    fn expired_lease_is_invalid() {
108        let lease = LeaseFile {
109            pid: std::process::id(),
110            start_time: 1000,
111            last_heartbeat: 1000,
112        };
113        // TTL of 60s, but heartbeat is from epoch time 1000 — long expired.
114        assert!(!lease.is_valid(60));
115    }
116
117    #[test]
118    fn fresh_lease_with_live_pid_is_valid() {
119        let now = now_secs();
120        let lease = LeaseFile {
121            pid: std::process::id(),
122            start_time: now,
123            last_heartbeat: now,
124        };
125        assert!(lease.is_valid(60));
126    }
127
128    #[test]
129    fn lease_with_dead_pid_is_invalid() {
130        let now = now_secs();
131        let lease = LeaseFile {
132            pid: 4_000_000, // almost certainly unused
133            start_time: now,
134            last_heartbeat: now,
135        };
136        assert!(!lease.is_valid(60));
137    }
138}