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}