Skip to main content

recursive/
session_lock.rs

1//! Per-session sentinel lock (Goal 151).
2//!
3//! Pulled out of `session.rs` so the file there focuses on
4//! [`crate::session::SessionWriter`] / `SessionReader` semantics. The
5//! implementation is unchanged — see the blame on `session.rs` for
6//! historical context. All names re-exported from `crate::session` so
7//! external paths like `recursive::session::SessionLock` keep working.
8
9use std::path::{Path, PathBuf};
10
11// Crate-internal so the test suite under `src/session.rs` can construct
12// custom sentinel files for stale-lock recovery tests.
13pub(crate) const SESSION_LOCK_FILE: &str = ".lock";
14
15/// Error type carried inside [`std::io::Error::other`] when
16/// [`SessionLock::acquire`] refuses because another live process
17/// holds the lock.
18#[derive(Debug, Clone)]
19pub struct SessionLockBusy {
20    pub pid: u32,
21    pub hostname: String,
22    pub started_at_unix: u64,
23    pub session_dir: PathBuf,
24}
25
26impl std::fmt::Display for SessionLockBusy {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        write!(
29            f,
30            "session at {} is being written by another process \
31             (pid {}, host {}, started {}). \
32             If you believe this is stale, remove {}/{} and retry.",
33            self.session_dir.display(),
34            self.pid,
35            self.hostname,
36            self.started_at_unix,
37            self.session_dir.display(),
38            SESSION_LOCK_FILE,
39        )
40    }
41}
42
43impl std::error::Error for SessionLockBusy {}
44
45/// Parsed contents of a `.lock` sentinel file.
46///
47/// `pub(crate)` so the test suite in `src/session.rs` can construct custom
48/// sentinels for stale-lock recovery / cross-host abort tests.
49pub(crate) struct SentinelInfo {
50    pub(crate) pid: u32,
51    pub(crate) hostname: String,
52    pub(crate) started_at_unix: u64,
53}
54
55impl SentinelInfo {
56    /// Build a sentinel describing this process. Newlines and
57    /// carriage returns in the hostname are stripped so they can't
58    /// break the line-delimited format.
59    fn for_self() -> Self {
60        Self {
61            pid: std::process::id(),
62            hostname: current_hostname(),
63            started_at_unix: std::time::SystemTime::now()
64                .duration_since(std::time::UNIX_EPOCH)
65                .map(|d| d.as_secs())
66                .unwrap_or(0),
67        }
68    }
69
70    pub(crate) fn parse(text: &str) -> Option<Self> {
71        let mut lines = text.lines();
72        let pid: u32 = lines.next()?.trim().parse().ok()?;
73        let hostname = lines.next()?.trim().to_string();
74        let started_at_unix: u64 = lines.next().unwrap_or("0").trim().parse().unwrap_or(0);
75        Some(Self {
76            pid,
77            hostname,
78            started_at_unix,
79        })
80    }
81
82    pub(crate) fn serialise(&self) -> String {
83        format!(
84            "{}\n{}\n{}\n",
85            self.pid, self.hostname, self.started_at_unix
86        )
87    }
88}
89
90/// Return our hostname as a single-line string with newlines stripped
91/// (so it can't break the `\n`-delimited sentinel format).
92///
93/// Tries `$HOSTNAME` / `$COMPUTERNAME` first, then falls back to
94/// invoking `hostname(1)`. Cheap (~1ms) and only called at lock
95/// acquire/release time.
96pub(crate) fn current_hostname() -> String {
97    let raw = std::env::var("HOSTNAME")
98        .or_else(|_| std::env::var("COMPUTERNAME"))
99        .unwrap_or_else(|_| {
100            std::process::Command::new("hostname")
101                .output()
102                .ok()
103                .and_then(|o| String::from_utf8(o.stdout).ok())
104                .unwrap_or_default()
105        });
106    raw.replace(['\n', '\r'], "_").trim().to_string()
107}
108
109/// Check whether `pid` is alive on the local host using `kill(2)`
110/// with signal 0 (the "exists?" probe).
111///
112/// Implementation: spawns `/bin/kill -0 <pid>`. This avoids needing
113/// a `libc` direct dependency and is fast enough at lock-acquire
114/// time. On non-Unix platforms, conservatively assumes alive — we
115/// would rather refuse a resume than corrupt a session. Power
116/// users on those platforms can remove `.lock` manually.
117fn is_pid_alive(pid: u32) -> bool {
118    #[cfg(target_os = "linux")]
119    {
120        // On Linux, /proc/<pid> exists iff the process is alive.
121        // This avoids the ambiguity of kill(1) exit codes for out-of-range PIDs.
122        std::path::Path::new(&format!("/proc/{pid}")).exists()
123    }
124    #[cfg(all(unix, not(target_os = "linux")))]
125    {
126        std::process::Command::new("/bin/kill")
127            .arg("-0")
128            .arg(pid.to_string())
129            .stdout(std::process::Stdio::null())
130            .stderr(std::process::Stdio::null())
131            .status()
132            .map(|s| s.success())
133            .unwrap_or(false)
134    }
135    #[cfg(target_os = "windows")]
136    {
137        // tasklist /FI "PID eq <pid>" /NH /FO CSV prints a CSV row
138        // when the process exists and "INFO: No tasks are running which
139        // match the specified criteria." when it does not. Exit code
140        // alone is ambiguous (no-match is still exit 0), so inspect
141        // stdout. If `tasklist` itself is unavailable we fall back to
142        // the conservative "assume alive" policy: better to refuse a
143        // resume than to overwrite a live session's sentinel.
144        let output = std::process::Command::new("tasklist")
145            .args(["/FI", &format!("PID eq {pid}"), "/NH", "/FO", "CSV"])
146            .output();
147        match output {
148            Ok(o) => {
149                let stdout = String::from_utf8_lossy(&o.stdout);
150                !stdout.contains("INFO: No tasks")
151            }
152            Err(_) => true,
153        }
154    }
155    #[cfg(not(any(unix, target_os = "windows")))]
156    {
157        let _ = pid;
158        true
159    }
160}
161
162/// RAII guard preventing concurrent writes to the same JSONL session.
163///
164/// Owns the sentinel file `<session_dir>/.lock` for the lifetime of
165/// the guard. On `Drop`, the sentinel is best-effort removed.
166///
167/// Stale-lock recovery: if the sentinel exists, its hostname matches
168/// ours, and the recorded pid is **not** alive, `acquire` overwrites
169/// it (logging a warning to stderr). If the hostname differs,
170/// `acquire` refuses regardless of pid liveness — pid namespaces
171/// across hosts aren't comparable.
172///
173/// **Why not `flock(2)`**: see g151 design — the sentinel approach
174/// gives a better error message ("pid 12345 on host X is still
175/// running") and avoids NFS / iCloud / Dropbox flakiness.
176#[derive(Debug)]
177pub struct SessionLock {
178    lock_path: PathBuf,
179}
180
181impl SessionLock {
182    /// Acquire the lock for `session_dir`. Returns
183    /// [`std::io::Error`] wrapping a [`SessionLockBusy`] when
184    /// another live process holds it (or when an unrecoverable
185    /// cross-host lock is detected).
186    pub fn acquire(session_dir: &Path) -> std::io::Result<Self> {
187        let lock_path = session_dir.join(SESSION_LOCK_FILE);
188
189        if lock_path.is_file() {
190            match std::fs::read_to_string(&lock_path) {
191                Ok(text) => {
192                    if let Some(info) = SentinelInfo::parse(&text) {
193                        let our_host = current_hostname();
194                        if info.hostname != our_host {
195                            return Err(std::io::Error::other(SessionLockBusy {
196                                pid: info.pid,
197                                hostname: info.hostname,
198                                started_at_unix: info.started_at_unix,
199                                session_dir: session_dir.to_path_buf(),
200                            }));
201                        }
202                        if is_pid_alive(info.pid) {
203                            return Err(std::io::Error::other(SessionLockBusy {
204                                pid: info.pid,
205                                hostname: info.hostname,
206                                started_at_unix: info.started_at_unix,
207                                session_dir: session_dir.to_path_buf(),
208                            }));
209                        }
210                        // Stale: pid dead on our host. Recover.
211                        eprintln!(
212                            "warning: recovered stale session lock at {} \
213                             (pid {} not running)",
214                            lock_path.display(),
215                            info.pid,
216                        );
217                    }
218                    // Parse failed — corrupt sentinel. Treat as
219                    // recoverable (overwrite) rather than abort.
220                }
221                Err(_) => {
222                    // Read failed — treat as recoverable.
223                }
224            }
225        }
226
227        // (Re)write sentinel with our info.
228        let info = SentinelInfo::for_self();
229        if let Some(parent) = lock_path.parent() {
230            std::fs::create_dir_all(parent)?;
231        }
232        std::fs::write(&lock_path, info.serialise())?;
233
234        Ok(Self { lock_path })
235    }
236
237    /// Path to the sentinel file. Mostly useful for tests / error
238    /// messages.
239    pub fn path(&self) -> &Path {
240        &self.lock_path
241    }
242}
243
244impl Drop for SessionLock {
245    fn drop(&mut self) {
246        let _ = std::fs::remove_file(&self.lock_path);
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    //! Tests that exercise the sentinel internals live here. The
253    //! "live PID blocks acquire" case stays in `session.rs` because it
254    //! only needs the public API.
255
256    use super::*;
257
258    #[test]
259    fn lock_dead_pid_recovered() {
260        let tmp = crate::test_util::IsolatedWorkspace::new();
261        let session_dir = tmp.path().join("session-B");
262        std::fs::create_dir_all(&session_dir).unwrap();
263
264        // Forge a stale lock with a pid that's almost certainly
265        // dead. We use u32::MAX which is well past any valid pid
266        // on Linux/macOS (PID_MAX_LIMIT is 2^22 by default).
267        let stale = SentinelInfo {
268            pid: u32::MAX,
269            hostname: current_hostname(),
270            started_at_unix: 0,
271        };
272        std::fs::write(session_dir.join(SESSION_LOCK_FILE), stale.serialise()).unwrap();
273
274        // Recovery should succeed and overwrite the sentinel.
275        let lock = SessionLock::acquire(&session_dir).unwrap();
276        let raw = std::fs::read_to_string(lock.path()).unwrap();
277        let parsed = SentinelInfo::parse(&raw).unwrap();
278        assert_eq!(parsed.pid, std::process::id());
279    }
280
281    #[test]
282    fn lock_cross_host_aborts() {
283        let tmp = crate::test_util::IsolatedWorkspace::new();
284        let session_dir = tmp.path().join("session-C");
285        std::fs::create_dir_all(&session_dir).unwrap();
286
287        // Forge a lock from a different host. Even though the pid
288        // is dead, cross-host pid checks aren't safe → refuse.
289        let cross = SentinelInfo {
290            pid: u32::MAX,
291            hostname: "definitely-not-our-host-123".to_string(),
292            started_at_unix: 0,
293        };
294        std::fs::write(session_dir.join(SESSION_LOCK_FILE), cross.serialise()).unwrap();
295
296        let err = SessionLock::acquire(&session_dir).expect_err("cross-host should fail");
297        assert!(
298            err.to_string().contains("definitely-not-our-host-123"),
299            "expected cross-host error to mention recorded host, got: {err}"
300        );
301    }
302
303    #[test]
304    fn lock_released_on_drop() {
305        let tmp = crate::test_util::IsolatedWorkspace::new();
306        let session_dir = tmp.path().join("session-D");
307        std::fs::create_dir_all(&session_dir).unwrap();
308
309        let lock = SessionLock::acquire(&session_dir).unwrap();
310        assert!(lock.path().exists());
311        drop(lock);
312        // Drop must remove the sentinel file.
313        assert!(
314            !session_dir.join(SESSION_LOCK_FILE).exists(),
315            "sentinel must be removed on Drop"
316        );
317
318        // A fresh acquire should succeed.
319        let _lock2 = SessionLock::acquire(&session_dir).unwrap();
320    }
321}