Skip to main content

kimetsu_brain/
lock.rs

1use std::fs::{self, OpenOptions};
2use std::io::Write;
3use std::path::{Path, PathBuf};
4use std::time::{Duration, Instant};
5
6use kimetsu_core::KimetsuResult;
7use kimetsu_core::ids::RunId;
8use kimetsu_core::paths::ProjectPaths;
9use serde::{Deserialize, Serialize};
10use time::OffsetDateTime;
11
12/// How long `acquire` blocks waiting for a held lock before giving up.
13const ACQUIRE_TIMEOUT: Duration = Duration::from_secs(15);
14
15/// How long to sleep between poll attempts.
16const POLL_INTERVAL: Duration = Duration::from_millis(150);
17
18/// A short write op whose lock is older than this is certainly from a dead
19/// holder (quick writes complete in milliseconds).
20const STALE_SHORT_OP_AGE: Duration = Duration::from_secs(120);
21
22#[derive(Debug)]
23pub struct ProjectLock {
24    path: PathBuf,
25    active: bool,
26}
27
28#[derive(Debug, Serialize, Deserialize)]
29struct LockPayload {
30    pid: u32,
31    command: String,
32    run_id: Option<String>,
33    #[serde(with = "time::serde::rfc3339")]
34    started_at: OffsetDateTime,
35}
36
37impl ProjectLock {
38    /// Acquire the writer lock, blocking until it is free or the default
39    /// timeout elapses.  Stale locks (dead PID, corrupt payload) are
40    /// reclaimed automatically.
41    pub fn acquire(
42        paths: &ProjectPaths,
43        command: impl Into<String>,
44        run_id: Option<RunId>,
45    ) -> KimetsuResult<Self> {
46        acquire_with_timeout(paths, command, run_id, ACQUIRE_TIMEOUT)
47    }
48
49    pub fn release(mut self) -> KimetsuResult<()> {
50        self.active = false;
51        match fs::remove_file(&self.path) {
52            Ok(()) => Ok(()),
53            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
54            Err(err) => Err(err.into()),
55        }
56    }
57}
58
59impl Drop for ProjectLock {
60    fn drop(&mut self) {
61        if self.active {
62            let _ = fs::remove_file(&self.path);
63        }
64    }
65}
66
67/// Inner implementation that accepts an explicit timeout so tests can pass a
68/// short one without waiting 15 s.
69pub(crate) fn acquire_with_timeout(
70    paths: &ProjectPaths,
71    command: impl Into<String>,
72    run_id: Option<RunId>,
73    timeout: Duration,
74) -> KimetsuResult<ProjectLock> {
75    fs::create_dir_all(&paths.kimetsu_dir)?;
76    let command: String = command.into();
77    let payload = LockPayload {
78        pid: std::process::id(),
79        command: command.clone(),
80        run_id: run_id.map(|id| id.to_string()),
81        started_at: OffsetDateTime::now_utc(),
82    };
83    let serialized = serde_json::to_string_pretty(&payload)?;
84    let deadline = Instant::now() + timeout;
85
86    loop {
87        match OpenOptions::new()
88            .write(true)
89            .create_new(true)
90            .open(&paths.lock_file)
91        {
92            Ok(mut file) => {
93                file.write_all(serialized.as_bytes())?;
94                file.sync_all()?;
95                return Ok(ProjectLock {
96                    path: paths.lock_file.clone(),
97                    active: true,
98                });
99            }
100            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
101                // Check if the existing lock is stale (dead holder or corrupt).
102                if lock_is_stale(&paths.lock_file) {
103                    // Best-effort removal; if another racer beats us here the
104                    // next loop iteration will retry create_new.
105                    let _ = fs::remove_file(&paths.lock_file);
106                    continue;
107                }
108
109                if Instant::now() >= deadline {
110                    let existing = fs::read_to_string(&paths.lock_file).unwrap_or_default();
111                    return Err(format!(
112                        "project writer lock held (timed out after {}s); \
113                         if the holder crashed, run `kimetsu lock clear`.\n{existing}",
114                        timeout.as_secs()
115                    )
116                    .into());
117                }
118
119                std::thread::sleep(POLL_INTERVAL);
120            }
121            Err(e) => return Err(e.into()),
122        }
123    }
124}
125
126/// Returns `true` when the lock at `lock_file` should be reclaimed.
127///
128/// Staleness criteria (conservative — when in doubt, return `false`):
129/// * Payload cannot be parsed (corrupt / empty) → stale.
130/// * Holder PID is no longer alive → stale.
131/// * PID liveness is indeterminate AND the lock is implausibly old for a
132///   short write command → stale (age safety-net).
133fn lock_is_stale(lock_file: &Path) -> bool {
134    let content = match fs::read_to_string(lock_file) {
135        Ok(s) => s,
136        Err(_) => return true, // unreadable → treat as stale
137    };
138
139    let payload: LockPayload = match serde_json::from_str(&content) {
140        Ok(p) => p,
141        Err(_) => return true, // corrupt → treat as stale
142    };
143
144    match process_alive(payload.pid) {
145        ProcessLiveness::Dead => true,
146        ProcessLiveness::Alive => false,
147        ProcessLiveness::Indeterminate => {
148            // Fall back to the age safety-net for short-lived commands.
149            is_short_op_too_old(&payload)
150        }
151    }
152}
153
154/// Returns `true` when `payload` looks like a quick write operation whose
155/// `started_at` timestamp is implausibly far in the past.
156fn is_short_op_too_old(payload: &LockPayload) -> bool {
157    let age_secs = (OffsetDateTime::now_utc() - payload.started_at).whole_seconds();
158    if age_secs < 0 {
159        return false; // clock skew — be conservative
160    }
161    let age = Duration::from_secs(age_secs as u64);
162    if age < STALE_SHORT_OP_AGE {
163        return false;
164    }
165    // Heuristic: agent-run commands (long-lived) contain "run" or "record" or
166    // "ingest".  Everything else (memory add/propose/edit/undo/invalidate,
167    // brain config, etc.) is a quick write.
168    let cmd = payload.command.to_ascii_lowercase();
169    let is_long_op = cmd.contains("run") || cmd.contains("record") || cmd.contains("ingest");
170    !is_long_op
171}
172
173#[derive(Debug, PartialEq, Eq)]
174enum ProcessLiveness {
175    Alive,
176    Dead,
177    /// Only constructed on Windows / non-unix-non-windows targets. unix's
178    /// `kill(pid, 0)` collapses to `Alive` (any non-ESRCH errno, e.g. EPERM) or
179    /// `Dead` (ESRCH), so it never yields this on unix — hence the unix-only
180    /// dead-code allow.
181    #[cfg_attr(unix, allow(dead_code))]
182    Indeterminate,
183}
184
185/// Check whether a process with `pid` is still alive.
186///
187/// Conservative: when we cannot determine liveness, return `Indeterminate`
188/// rather than `Dead` so we don't accidentally reclaim a live lock.
189fn process_alive(pid: u32) -> ProcessLiveness {
190    #[cfg(unix)]
191    {
192        process_alive_unix(pid)
193    }
194    #[cfg(windows)]
195    {
196        process_alive_windows(pid)
197    }
198    #[cfg(not(any(unix, windows)))]
199    {
200        let _ = pid;
201        ProcessLiveness::Indeterminate
202    }
203}
204
205#[cfg(unix)]
206fn process_alive_unix(pid: u32) -> ProcessLiveness {
207    // SAFETY: kill(pid, 0) is a standard POSIX probe: it performs permission
208    // checks without sending a signal.  A return value of 0 means the process
209    // exists and we have permission; ESRCH means no such process (dead).
210    // Any other errno (e.g. EPERM) means the process exists but we lack
211    // permission — treat as Alive.
212    unsafe extern "C" {
213        fn kill(pid: i32, sig: i32) -> i32;
214    }
215    unsafe {
216        let rc = kill(pid as i32, 0);
217        if rc == 0 {
218            return ProcessLiveness::Alive;
219        }
220        // Check errno.
221        let errno = *libc_errno();
222        if errno == 3 {
223            // ESRCH = 3 on Linux/macOS
224            ProcessLiveness::Dead
225        } else {
226            ProcessLiveness::Alive // EPERM or other → process exists
227        }
228    }
229}
230
231/// Portable errno accessor for unix (avoids the `libc` crate).
232#[cfg(unix)]
233unsafe fn libc_errno() -> *mut i32 {
234    // On Linux glibc the TLS errno is accessed via __errno_location().
235    // On macOS it's __error().  Both are in the C standard library.
236    #[cfg(target_os = "macos")]
237    unsafe extern "C" {
238        fn __error() -> *mut i32;
239    }
240    #[cfg(target_os = "macos")]
241    return unsafe { __error() };
242
243    #[cfg(not(target_os = "macos"))]
244    unsafe extern "C" {
245        fn __errno_location() -> *mut i32;
246    }
247    #[cfg(not(target_os = "macos"))]
248    return unsafe { __errno_location() };
249}
250
251#[cfg(windows)]
252fn process_alive_windows(pid: u32) -> ProcessLiveness {
253    // SAFETY: We use Win32 to probe PID liveness.
254    //
255    // Strategy:
256    //   1. OpenProcess(SYNCHRONIZE, ...) — if NULL: check last-error.
257    //      ERROR_INVALID_PARAMETER (87) → PID doesn't exist → Dead.
258    //      ERROR_ACCESS_DENIED (5)      → process exists but no access → Alive.
259    //      Anything else                → Indeterminate (conservative).
260    //   2. Got a handle: call WaitForSingleObject(handle, 0).
261    //      WAIT_OBJECT_0 (0) → process is signaled/exited → Dead.
262    //      WAIT_TIMEOUT (258) or other → process is running → Alive.
263    //   This correctly handles zombie processes (handle obtained but process
264    //   already exited — WFSO immediately returns WAIT_OBJECT_0).
265    unsafe extern "system" {
266        fn OpenProcess(desired_access: u32, inherit_handle: i32, pid: u32) -> isize;
267        fn CloseHandle(handle: isize) -> i32;
268        fn GetLastError() -> u32;
269        fn WaitForSingleObject(handle: isize, milliseconds: u32) -> u32;
270    }
271
272    const SYNCHRONIZE: u32 = 0x0010_0000;
273    const ERROR_INVALID_PARAMETER: u32 = 87;
274    const ERROR_ACCESS_DENIED: u32 = 5;
275    const WAIT_OBJECT_0: u32 = 0;
276    const WAIT_TIMEOUT: u32 = 258;
277
278    unsafe {
279        let handle = OpenProcess(SYNCHRONIZE, 0, pid);
280        if handle == 0 {
281            let err = GetLastError();
282            return match err {
283                ERROR_INVALID_PARAMETER => ProcessLiveness::Dead,
284                ERROR_ACCESS_DENIED => ProcessLiveness::Alive,
285                _ => ProcessLiveness::Indeterminate,
286            };
287        }
288        // We have a handle — use WaitForSingleObject with 0 timeout to
289        // distinguish a zombie (exited but handle not yet closed) from a live
290        // process.  A signaled process object means it has exited.
291        let wait_result = WaitForSingleObject(handle, 0);
292        CloseHandle(handle);
293        match wait_result {
294            WAIT_OBJECT_0 => ProcessLiveness::Dead,
295            WAIT_TIMEOUT => ProcessLiveness::Alive,
296            _ => ProcessLiveness::Indeterminate,
297        }
298    }
299}
300
301pub fn clear_force(paths: &ProjectPaths) -> KimetsuResult<bool> {
302    match fs::remove_file(&paths.lock_file) {
303        Ok(()) => Ok(true),
304        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
305        Err(err) => Err(err.into()),
306    }
307}
308
309// ---------------------------------------------------------------------------
310// Tests
311// ---------------------------------------------------------------------------
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use kimetsu_core::paths::ProjectPaths;
317    use std::sync::{Arc, Barrier};
318    use std::time::Instant;
319
320    /// RAII temp directory that removes itself on drop.
321    struct TempDir(PathBuf);
322
323    impl TempDir {
324        fn new() -> Self {
325            use std::sync::atomic::{AtomicU64, Ordering};
326            static CTR: AtomicU64 = AtomicU64::new(0);
327            let n = CTR.fetch_add(1, Ordering::Relaxed);
328            let pid = std::process::id();
329            let dir = std::env::temp_dir().join(format!("kimetsu-lock-test-{pid}-{n}"));
330            fs::create_dir_all(&dir).expect("create temp dir");
331            TempDir(dir)
332        }
333
334        fn path(&self) -> &Path {
335            &self.0
336        }
337    }
338
339    impl Drop for TempDir {
340        fn drop(&mut self) {
341            let _ = fs::remove_dir_all(&self.0);
342        }
343    }
344
345    /// Build a fresh `ProjectPaths` rooted at `dir`.
346    fn make_paths(dir: &TempDir) -> ProjectPaths {
347        ProjectPaths::at_root(dir.path())
348    }
349
350    // -----------------------------------------------------------------------
351    // T1 — Concurrent acquire serializes (no failure)
352    // -----------------------------------------------------------------------
353    #[test]
354    fn concurrent_acquire_serializes() {
355        let dir = TempDir::new();
356        let paths = make_paths(&dir);
357        fs::create_dir_all(&paths.kimetsu_dir).unwrap();
358
359        let paths = Arc::new(paths);
360        // Barrier so both threads enter the acquire window at roughly the same time.
361        let barrier = Arc::new(Barrier::new(2));
362        let errors = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
363
364        let mut handles = Vec::new();
365        for i in 0..2 {
366            let p = Arc::clone(&paths);
367            let b = Arc::clone(&barrier);
368            let errs = Arc::clone(&errors);
369            let h = std::thread::spawn(move || {
370                b.wait(); // race to the acquire
371                match acquire_with_timeout(
372                    &p,
373                    format!("test-thread-{i}"),
374                    None,
375                    Duration::from_secs(10),
376                ) {
377                    Ok(lock) => {
378                        std::thread::sleep(Duration::from_millis(30));
379                        lock.release().unwrap();
380                    }
381                    Err(e) => {
382                        errs.lock().unwrap().push(e.to_string());
383                    }
384                }
385            });
386            handles.push(h);
387        }
388
389        for h in handles {
390            h.join().unwrap();
391        }
392
393        let errs = errors.lock().unwrap();
394        assert!(
395            errs.is_empty(),
396            "expected both threads to succeed; errors: {errs:?}"
397        );
398    }
399
400    // -----------------------------------------------------------------------
401    // T2 — Stale lock with dead PID is reclaimed automatically
402    // -----------------------------------------------------------------------
403    #[test]
404    fn stale_lock_dead_pid_is_reclaimed() {
405        let dir = TempDir::new();
406        let paths = make_paths(&dir);
407        fs::create_dir_all(&paths.kimetsu_dir).unwrap();
408
409        // Spawn a trivial child process, wait for it to fully exit, capture its PID.
410        let mut child = std::process::Command::new(if cfg!(windows) { "cmd" } else { "true" })
411            .args(if cfg!(windows) {
412                &["/c", "exit", "0"][..]
413            } else {
414                &[][..]
415            })
416            .spawn()
417            .expect("spawn child");
418        let dead_pid = child.id();
419        child.wait().expect("wait for child to exit");
420        // Give the OS a moment to fully reap the process object.
421        std::thread::sleep(Duration::from_millis(200));
422
423        // Write a stale lock file with the dead PID.
424        let stale_payload = serde_json::json!({
425            "pid": dead_pid,
426            "command": "memory add",
427            "run_id": null,
428            "started_at": "2000-01-01T00:00:00Z"
429        });
430        fs::write(&paths.lock_file, stale_payload.to_string()).unwrap();
431
432        // acquire should reclaim the stale lock and succeed.
433        let lock = acquire_with_timeout(&paths, "test", None, Duration::from_secs(5))
434            .expect("should reclaim stale lock and succeed");
435        lock.release().unwrap();
436    }
437
438    // -----------------------------------------------------------------------
439    // T3 — Corrupt lock file is treated as stale and reclaimed
440    // -----------------------------------------------------------------------
441    #[test]
442    fn corrupt_lock_is_reclaimed() {
443        let dir = TempDir::new();
444        let paths = make_paths(&dir);
445        fs::create_dir_all(&paths.kimetsu_dir).unwrap();
446
447        // Write garbage into the lock file.
448        fs::write(&paths.lock_file, b"not json at all!!!\x00\x01\x02").unwrap();
449
450        let lock = acquire_with_timeout(&paths, "test", None, Duration::from_secs(5))
451            .expect("should reclaim corrupt lock and succeed");
452        lock.release().unwrap();
453    }
454
455    // -----------------------------------------------------------------------
456    // T4 — Live-held lock times out and returns Err (bounded wait)
457    // -----------------------------------------------------------------------
458    #[test]
459    fn live_held_lock_times_out() {
460        let dir = TempDir::new();
461        let paths = make_paths(&dir);
462        fs::create_dir_all(&paths.kimetsu_dir).unwrap();
463
464        let paths = Arc::new(paths);
465
466        // Hold the lock from a background thread and never release it during the
467        // timeout window.
468        let barrier = Arc::new(Barrier::new(2));
469        let paths2 = Arc::clone(&paths);
470        let b2 = Arc::clone(&barrier);
471        let holder = std::thread::spawn(move || {
472            let lock = acquire_with_timeout(&paths2, "holder", None, Duration::from_secs(5))
473                .expect("holder should acquire");
474            b2.wait(); // signal: lock is held
475            // Hold for long enough that the waiter definitely times out.
476            std::thread::sleep(Duration::from_secs(3));
477            lock.release().unwrap();
478        });
479
480        barrier.wait(); // wait until the holder has the lock
481
482        let short_timeout = Duration::from_millis(350);
483        let t0 = Instant::now();
484        let result = acquire_with_timeout(&paths, "waiter", None, short_timeout);
485        let elapsed = t0.elapsed();
486
487        // Must have returned an error (timed out).
488        assert!(result.is_err(), "expected Err, got Ok");
489        let msg = result.unwrap_err().to_string();
490        assert!(
491            msg.contains("timed out"),
492            "error message should mention 'timed out', got: {msg}"
493        );
494
495        // Must NOT have failed instantly — the waiter should have polled for
496        // close to the timeout duration.
497        assert!(
498            elapsed >= short_timeout.saturating_sub(Duration::from_millis(50)),
499            "waiter returned too quickly (elapsed {elapsed:?}, expected ~{short_timeout:?})"
500        );
501
502        holder.join().unwrap();
503    }
504
505    // -----------------------------------------------------------------------
506    // T5 — process_alive: current pid is Alive; dead child pid is Dead
507    // -----------------------------------------------------------------------
508    #[test]
509    fn process_alive_current_is_alive() {
510        let my_pid = std::process::id();
511        assert_eq!(
512            process_alive(my_pid),
513            ProcessLiveness::Alive,
514            "current process should be Alive"
515        );
516    }
517
518    #[test]
519    fn process_alive_dead_pid_is_dead() {
520        // Spawn a child that exits immediately, capture its PID, wait for it.
521        let mut child = std::process::Command::new(if cfg!(windows) { "cmd" } else { "true" })
522            .args(if cfg!(windows) {
523                &["/c", "exit", "0"][..]
524            } else {
525                &[][..]
526            })
527            .spawn()
528            .expect("spawn child");
529        let pid = child.id();
530        child.wait().expect("wait for child");
531
532        // Give the OS a moment to fully reap.
533        std::thread::sleep(Duration::from_millis(100));
534
535        let liveness = process_alive(pid);
536        // On Windows PIDs can be recycled quickly, so we allow Indeterminate
537        // as a safe fallback; Dead is the expected answer.
538        assert!(
539            matches!(
540                liveness,
541                ProcessLiveness::Dead | ProcessLiveness::Indeterminate
542            ),
543            "dead child PID should be Dead or Indeterminate, got {liveness:?}"
544        );
545    }
546}