Skip to main content

truth_mirror/
watcher.rs

1//! Queue-tied watcher lifecycle: single-flight lock, crash-safe staleness, and
2//! detached spawn.
3//!
4//! The watcher's lifetime is bound to the review queue, not to any agent session
5//! or terminal. [`ensure_watcher`] is an idempotent check-and-spawn: if a live
6//! watcher already owns the state dir it does nothing, otherwise it spawns
7//! `truth-mirror watch --until-empty` as a detached background process and returns
8//! immediately. It is safe to call from a git hook — fast, quiet on success, and
9//! never blocking.
10//!
11//! Single-flight is enforced with a lock file ([`WATCHER_LOCK_FILE`]) recorded in
12//! the state dir. The lock carries a [`ProcessIdentity`] — a pid plus a start-time
13//! token — so a dead watcher never wedges the next spawn and a reused pid cannot
14//! fool the liveness probe: the recorded start token must match the live process's
15//! start token for the lock to count as held.
16
17use std::{
18    fs,
19    io::{self, Write},
20    path::{Path, PathBuf},
21    process::Stdio,
22    time::{SystemTime, UNIX_EPOCH},
23};
24
25use serde::{Deserialize, Serialize};
26use thiserror::Error;
27
28use crate::{reviewer::ReviewQueue, time::unix_now};
29
30/// File name of the single-flight watcher lock inside the state dir.
31pub const WATCHER_LOCK_FILE: &str = "watcher.lock";
32const WATCHER_RECLAIM_DIR: &str = "watcher.lock.reclaim";
33
34/// A just-written lock is treated as held even if its recorded owner cannot yet
35/// be confirmed alive, to cover the window where a parent `ensure-watcher` has
36/// claimed the lock and is about to hand it to its detached child (the parent may
37/// exit before a racing caller probes it). Without this settling grace, two
38/// `ensure-watcher` calls racing within milliseconds could both judge the other's
39/// fresh lock "stale" and each spawn a watcher. Kept small so a genuinely crashed
40/// spawn is still reclaimed promptly.
41const LOCK_SETTLING_SECS: u64 = 10;
42
43#[derive(Debug, Error)]
44pub enum WatcherError {
45    #[error("watcher lock io error: {0}")]
46    Io(io::Error),
47    #[error("watcher lock json error: {0}")]
48    Json(serde_json::Error),
49    #[error("failed to resolve the truth-mirror executable path: {0}")]
50    ResolveExe(io::Error),
51    #[error("failed to spawn detached watcher: {0}")]
52    Spawn(io::Error),
53    #[error("failed to read review queue: {0}")]
54    Queue(crate::reviewer::ReviewerError),
55}
56
57/// A process's identity strong enough to survive pid reuse.
58///
59/// `pid` alone is not enough: the OS recycles pids, so a *different* process could
60/// later hold the pid recorded in a stale lock. `start_token` is an opaque,
61/// process-specific string (the OS-reported start time) that changes when the pid
62/// is reused, so a lock is only "held" when both the pid is alive AND its live
63/// start token matches the recorded one.
64#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
65pub struct ProcessIdentity {
66    pub pid: u32,
67    /// OS-reported process start marker. Empty when the platform could not report
68    /// one; liveness then degrades to a bare pid-alive check (documented tradeoff).
69    #[serde(default)]
70    pub start_token: String,
71}
72
73impl ProcessIdentity {
74    /// The identity of the current process.
75    pub fn current() -> Self {
76        let pid = std::process::id();
77        Self {
78            start_token: probe_start_token(pid).unwrap_or_default(),
79            pid,
80        }
81    }
82}
83
84/// On-disk single-flight lock. Records who owns the watcher slot and when it was
85/// claimed. Serialized as pretty JSON so it is human-inspectable in the state dir.
86#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
87pub struct WatcherLock {
88    pub identity: ProcessIdentity,
89    pub created_at_unix: u64,
90}
91
92impl WatcherLock {
93    fn for_current() -> Self {
94        Self {
95            identity: ProcessIdentity::current(),
96            created_at_unix: unix_now(),
97        }
98    }
99}
100
101/// Outcome of probing whether a recorded lock owner is still alive.
102///
103/// Kept as a distinct type (rather than a bare `bool`) so callers can treat
104/// `Unknown` conservatively — an unprobeable owner is not silently reclaimed.
105#[derive(Clone, Copy, Debug, Eq, PartialEq)]
106pub enum Liveness {
107    /// The recorded owner is alive and its start token matches: the lock is held.
108    Alive,
109    /// The recorded owner is gone (pid dead) or was replaced (pid reused): the
110    /// lock is stale and may be reclaimed.
111    Stale,
112}
113
114/// Decide whether a recorded lock owner is still the live watcher.
115///
116/// Pure over its inputs — the probed identity is injected — so the staleness
117/// decision is unit-testable without any real processes:
118/// - pid not alive        → `Stale`
119/// - pid alive, tokens differ (pid reuse) → `Stale`
120/// - pid alive, tokens match, or either token empty → `Alive`
121///
122/// When either token is empty (platform could not report a start marker) the
123/// decision falls back to bare pid liveness, which is the best signal available.
124pub fn decide_liveness(recorded: &ProcessIdentity, probed: Option<ProcessIdentity>) -> Liveness {
125    let Some(probed) = probed else {
126        return Liveness::Stale;
127    };
128    if probed.pid != recorded.pid {
129        return Liveness::Stale;
130    }
131    if recorded.start_token.is_empty() || probed.start_token.is_empty() {
132        // No start marker to compare on at least one side; pid is alive, so treat
133        // as held rather than risk reclaiming a live watcher.
134        return Liveness::Alive;
135    }
136    if probed.start_token == recorded.start_token {
137        Liveness::Alive
138    } else {
139        Liveness::Stale
140    }
141}
142
143/// Whether a lock counts as held right now: either its recorded owner probes
144/// [`Liveness::Alive`], or the lock is still within its settling window (a spawn is
145/// in flight). `now` is injected so the settling decision is unit-testable.
146///
147/// The settling clause is one-directional safety: it can only make a fresh lock
148/// look *more* held, never reclaim a live one, so it cannot spawn a duplicate.
149fn lock_is_held_at(lock: &WatcherLock, probed: Option<ProcessIdentity>, now: u64) -> bool {
150    if decide_liveness(&lock.identity, probed) == Liveness::Alive {
151        return true;
152    }
153    now.saturating_sub(lock.created_at_unix) < LOCK_SETTLING_SECS
154}
155
156/// Whether a lock counts as held now, probing the live process and clock.
157fn lock_is_held(lock: &WatcherLock) -> bool {
158    let probed = probe_identity(lock.identity.pid);
159    lock_is_held_at(lock, probed, unix_now())
160}
161
162/// Probe the live identity for `pid`, or `None` if no such process exists.
163fn probe_identity(pid: u32) -> Option<ProcessIdentity> {
164    if !pid_is_alive(pid) {
165        return None;
166    }
167    Some(ProcessIdentity {
168        pid,
169        start_token: probe_start_token(pid).unwrap_or_default(),
170    })
171}
172
173/// Whether a live watcher currently owns the lock in `state_dir`.
174///
175/// Reads the lock (if any) and probes its recorded owner. A missing, unreadable,
176/// or stale lock all mean "no live owner". Never mutates the lock.
177pub fn watcher_is_alive(state_dir: &Path) -> bool {
178    match read_lock(state_dir) {
179        Ok(Some(lock)) => lock_is_held(&lock),
180        _ => false,
181    }
182}
183
184fn lock_path(state_dir: &Path) -> PathBuf {
185    state_dir.join(WATCHER_LOCK_FILE)
186}
187
188#[derive(Clone, Debug, Eq, PartialEq)]
189enum LockRead {
190    Missing,
191    Valid(WatcherLock),
192    Corrupt { modified_at_unix: u64 },
193}
194
195impl LockRead {
196    fn is_held_at(&self, now: u64) -> bool {
197        match self {
198            LockRead::Missing => false,
199            LockRead::Valid(lock) => lock_is_held_at(lock, probe_identity(lock.identity.pid), now),
200            LockRead::Corrupt { modified_at_unix } => {
201                corrupt_lock_is_held_at(*modified_at_unix, now)
202            }
203        }
204    }
205}
206
207fn corrupt_lock_is_held_at(modified_at_unix: u64, now: u64) -> bool {
208    now.saturating_sub(modified_at_unix) < LOCK_SETTLING_SECS
209}
210
211fn read_lock(state_dir: &Path) -> Result<Option<WatcherLock>, WatcherError> {
212    match read_lock_state(state_dir)? {
213        LockRead::Valid(lock) => Ok(Some(lock)),
214        LockRead::Missing | LockRead::Corrupt { .. } => Ok(None),
215    }
216}
217
218fn read_lock_state(state_dir: &Path) -> Result<LockRead, WatcherError> {
219    let path = lock_path(state_dir);
220    match fs::read_to_string(&path) {
221        Ok(contents) => match serde_json::from_str(contents.trim()) {
222            Ok(lock) => Ok(LockRead::Valid(lock)),
223            Err(_) => Ok(LockRead::Corrupt {
224                modified_at_unix: file_modified_at_unix(&path),
225            }),
226        },
227        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(LockRead::Missing),
228        Err(error) => Err(WatcherError::Io(error)),
229    }
230}
231
232fn file_modified_at_unix(path: &Path) -> u64 {
233    fs::metadata(path)
234        .and_then(|metadata| metadata.modified())
235        .ok()
236        .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
237        .map_or_else(unix_now, |duration| duration.as_secs())
238}
239
240fn write_lock(state_dir: &Path, lock: &WatcherLock) -> Result<(), WatcherError> {
241    fs::create_dir_all(state_dir).map_err(WatcherError::Io)?;
242    let bytes = serde_json::to_vec_pretty(lock).map_err(WatcherError::Json)?;
243    let temp_path = temp_lock_path(state_dir);
244    {
245        let mut file = fs::File::create(&temp_path).map_err(WatcherError::Io)?;
246        file.write_all(&bytes).map_err(WatcherError::Io)?;
247        file.write_all(b"\n").map_err(WatcherError::Io)?;
248        file.sync_all().map_err(WatcherError::Io)?;
249    }
250    fs::rename(&temp_path, lock_path(state_dir)).map_err(WatcherError::Io)
251}
252
253fn temp_lock_path(state_dir: &Path) -> PathBuf {
254    let nanos = SystemTime::now()
255        .duration_since(UNIX_EPOCH)
256        .map_or(0, |duration| duration.as_nanos());
257    state_dir.join(format!(
258        "{WATCHER_LOCK_FILE}.{}.{}.tmp",
259        std::process::id(),
260        nanos
261    ))
262}
263
264/// Result of attempting to claim the single-flight watcher lock.
265#[derive(Clone, Copy, Debug, Eq, PartialEq)]
266pub enum LockClaim {
267    /// This process now owns the lock (it was free or the prior owner was stale).
268    Acquired,
269    /// A live watcher already owns the lock; the caller should stand down.
270    HeldByLiveWatcher,
271}
272
273/// Try to claim the watcher lock for the current process, atomically and
274/// crash-safe.
275///
276/// The claim uses an O_EXCL create as the racing gate: at most one of two
277/// concurrent `create_new` calls succeeds, so two `ensure-watcher` invocations
278/// racing (e.g. two pushes) resolve to exactly one [`LockClaim::Acquired`]. If the
279/// exclusive create loses the race because a lock file already exists, the loser
280/// inspects the existing owner:
281/// - a live owner ⇒ [`LockClaim::HeldByLiveWatcher`] (stand down),
282/// - a stale owner ⇒ overwrite in place and take it ([`LockClaim::Acquired`]).
283///
284/// Reclaiming a stale lock never blocks on the dead owner, so a crashed watcher
285/// can never wedge the next spawn.
286pub fn try_acquire_lock(state_dir: &Path) -> Result<LockClaim, WatcherError> {
287    fs::create_dir_all(state_dir).map_err(WatcherError::Io)?;
288    let lock = WatcherLock::for_current();
289    let bytes = serde_json::to_vec_pretty(&lock).map_err(WatcherError::Json)?;
290
291    match fs::OpenOptions::new()
292        .write(true)
293        .create_new(true)
294        .open(lock_path(state_dir))
295    {
296        Ok(mut file) => {
297            file.write_all(&bytes).map_err(WatcherError::Io)?;
298            file.write_all(b"\n").map_err(WatcherError::Io)?;
299            file.sync_all().map_err(WatcherError::Io)?;
300            Ok(LockClaim::Acquired)
301        }
302        Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
303            // Lost the create race — inspect the incumbent. A held lock (live owner
304            // OR still settling after a fresh claim) means stand down; only a truly
305            // stale lock is reclaimed. An unparseable lock means the winner is
306            // mid-write (it created the file with O_EXCL but has not written its
307            // bytes yet): stand down rather than clobber an in-flight claim.
308            reclaim_existing_lock(state_dir, &lock)
309        }
310        Err(error) => Err(WatcherError::Io(error)),
311    }
312}
313
314fn reclaim_existing_lock(state_dir: &Path, lock: &WatcherLock) -> Result<LockClaim, WatcherError> {
315    let now = unix_now();
316    let state = read_lock_state(state_dir)?;
317    if state.is_held_at(now) {
318        return Ok(LockClaim::HeldByLiveWatcher);
319    }
320
321    let Some(_guard) = try_reclaim_guard(state_dir)? else {
322        return Ok(LockClaim::HeldByLiveWatcher);
323    };
324
325    let state = read_lock_state(state_dir)?;
326    if state.is_held_at(unix_now()) {
327        return Ok(LockClaim::HeldByLiveWatcher);
328    }
329
330    match fs::remove_file(lock_path(state_dir)) {
331        Ok(()) => {}
332        Err(error) if error.kind() == io::ErrorKind::NotFound => {}
333        Err(error) => return Err(WatcherError::Io(error)),
334    }
335
336    match fs::OpenOptions::new()
337        .write(true)
338        .create_new(true)
339        .open(lock_path(state_dir))
340    {
341        Ok(mut file) => {
342            let bytes = serde_json::to_vec_pretty(lock).map_err(WatcherError::Json)?;
343            file.write_all(&bytes).map_err(WatcherError::Io)?;
344            file.write_all(b"\n").map_err(WatcherError::Io)?;
345            file.sync_all().map_err(WatcherError::Io)?;
346            Ok(LockClaim::Acquired)
347        }
348        Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
349            Ok(LockClaim::HeldByLiveWatcher)
350        }
351        Err(error) => Err(WatcherError::Io(error)),
352    }
353}
354
355struct ReclaimGuard {
356    path: PathBuf,
357}
358
359impl Drop for ReclaimGuard {
360    fn drop(&mut self) {
361        let _ = fs::remove_dir(&self.path);
362    }
363}
364
365fn try_reclaim_guard(state_dir: &Path) -> Result<Option<ReclaimGuard>, WatcherError> {
366    let path = state_dir.join(WATCHER_RECLAIM_DIR);
367    match fs::create_dir(&path) {
368        Ok(()) => Ok(Some(ReclaimGuard { path })),
369        Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
370            if corrupt_lock_is_held_at(file_modified_at_unix(&path), unix_now()) {
371                return Ok(None);
372            }
373            match fs::remove_dir(&path) {
374                Ok(()) => {}
375                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
376                Err(error) => return Err(WatcherError::Io(error)),
377            }
378            match fs::create_dir(&path) {
379                Ok(()) => Ok(Some(ReclaimGuard { path })),
380                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => Ok(None),
381                Err(error) => Err(WatcherError::Io(error)),
382            }
383        }
384        Err(error) => Err(WatcherError::Io(error)),
385    }
386}
387
388/// Release the watcher lock iff the current process still owns it.
389///
390/// A watcher that reclaimed a stale slot or lost a race must not delete a lock now
391/// held by a *different* live watcher, so this is a no-op unless the recorded
392/// identity matches the current process.
393pub fn release_lock_if_owned(state_dir: &Path) -> Result<(), WatcherError> {
394    let Some(lock) = read_lock(state_dir)? else {
395        return Ok(());
396    };
397    if !identity_matches_owner(&lock.identity, &ProcessIdentity::current()) {
398        return Ok(());
399    }
400    match fs::remove_file(lock_path(state_dir)) {
401        Ok(()) => {}
402        Err(error) if error.kind() == io::ErrorKind::NotFound => {}
403        Err(error) => return Err(WatcherError::Io(error)),
404    }
405    Ok(())
406}
407
408fn identity_matches_owner(recorded: &ProcessIdentity, current: &ProcessIdentity) -> bool {
409    recorded.pid == current.pid
410        && (recorded.start_token.is_empty()
411            || current.start_token.is_empty()
412            || recorded.start_token == current.start_token)
413}
414
415/// Idempotent check-and-spawn for the queue-tied watcher.
416///
417/// If a live watcher already owns `state_dir`, returns without spawning. Otherwise
418/// claims the single-flight lock and spawns a detached `truth-mirror watch
419/// --until-empty` that outlives this process (and its terminal / git hook), then
420/// returns immediately. Quiet on success; safe to call after every enqueue.
421///
422/// The spawned watcher — not this caller — is responsible for releasing the lock
423/// when it exits, so this returning `Acquired` means "a watcher is now starting",
424/// not "a watcher is done".
425pub fn ensure_watcher(state_dir: &Path) -> Result<LockClaim, WatcherError> {
426    ensure_watcher_with_spawner(state_dir, spawn_detached_watcher)
427}
428
429fn ensure_watcher_with_spawner(
430    state_dir: &Path,
431    spawn: impl FnOnce(&Path) -> Result<u32, WatcherError>,
432) -> Result<LockClaim, WatcherError> {
433    // Fast path: a live watcher already owns the slot, so do nothing without even
434    // touching the lock file. Keeps hook latency negligible in the common case.
435    if watcher_is_alive(state_dir) {
436        return Ok(LockClaim::HeldByLiveWatcher);
437    }
438
439    match try_acquire_lock(state_dir)? {
440        LockClaim::HeldByLiveWatcher => Ok(LockClaim::HeldByLiveWatcher),
441        LockClaim::Acquired => {
442            // We hold the lock, but this parent is about to exit — so immediately
443            // re-point the lock at the detached child's identity. Otherwise a
444            // second `ensure-watcher` racing right after this one could see a lock
445            // owned by our (now-dead) pid, judge it stale, and spawn a *second*
446            // watcher. Recording the child's identity closes that window without
447            // depending on the child having started up yet.
448            let child_pid = match spawn(state_dir) {
449                Ok(pid) => pid,
450                Err(error) => {
451                    if let Err(release_error) = release_lock_if_owned(state_dir) {
452                        eprintln!(
453                            "truth-mirror ensure-watcher: failed to release lock after spawn failure: {release_error}"
454                        );
455                    }
456                    return Err(error);
457                }
458            };
459            let child_lock = WatcherLock {
460                identity: ProcessIdentity {
461                    start_token: probe_start_token(child_pid).unwrap_or_default(),
462                    pid: child_pid,
463                },
464                created_at_unix: unix_now(),
465            };
466            write_lock(state_dir, &child_lock)?;
467            Ok(LockClaim::Acquired)
468        }
469    }
470}
471
472/// Spawn `truth-mirror watch --until-empty` detached from this process, returning
473/// the child's pid.
474///
475/// Detachment is platform-specific but kept thin:
476/// - unix: a new process group ([`process_group(0)`]) so the child is not killed
477///   when the parent's terminal / git hook exits, with stdio redirected to null.
478/// - windows: `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP` creation flags.
479///
480/// The state dir is passed explicitly so the child watches the same queue.
481fn spawn_detached_watcher(state_dir: &Path) -> Result<u32, WatcherError> {
482    let exe = std::env::current_exe().map_err(WatcherError::ResolveExe)?;
483    let mut command = std::process::Command::new(exe);
484    command
485        .arg("--state-dir")
486        .arg(state_dir)
487        .arg("watch")
488        .arg("--until-empty")
489        .stdin(Stdio::null())
490        .stdout(Stdio::null())
491        .stderr(Stdio::null());
492
493    detach(&mut command);
494
495    let child = command.spawn().map_err(WatcherError::Spawn)?;
496    Ok(child.id())
497}
498
499#[cfg(unix)]
500fn detach(command: &mut std::process::Command) {
501    use std::os::unix::process::CommandExt as _;
502    // Own process group: the child survives the parent terminal / hook exiting.
503    command.process_group(0);
504}
505
506#[cfg(windows)]
507fn detach(command: &mut std::process::Command) {
508    use std::os::windows::process::CommandExt as _;
509    // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW.
510    const DETACHED_PROCESS: u32 = 0x0000_0008;
511    const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
512    const CREATE_NO_WINDOW: u32 = 0x0800_0000;
513    command.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW);
514}
515
516#[cfg(not(any(unix, windows)))]
517fn detach(_command: &mut std::process::Command) {}
518
519/// Probe whether `pid` is alive.
520#[cfg(unix)]
521pub fn pid_is_alive(pid: u32) -> bool {
522    std::process::Command::new("kill")
523        .arg("-0")
524        .arg(pid.to_string())
525        .stdout(Stdio::null())
526        .stderr(Stdio::null())
527        .status()
528        .map(|status| status.success())
529        .unwrap_or(false)
530}
531
532#[cfg(windows)]
533pub fn pid_is_alive(pid: u32) -> bool {
534    // `tasklist` filtered to the pid prints a row iff the process exists.
535    let output = std::process::Command::new("tasklist")
536        .args(["/FI", &format!("PID eq {pid}"), "/NH", "/FO", "CSV"])
537        .stderr(Stdio::null())
538        .output();
539    match output {
540        Ok(output) if output.status.success() => {
541            let text = String::from_utf8_lossy(&output.stdout);
542            text.contains(&pid.to_string())
543        }
544        _ => false,
545    }
546}
547
548#[cfg(not(any(unix, windows)))]
549pub fn pid_is_alive(_pid: u32) -> bool {
550    false
551}
552
553/// OS-reported start marker for `pid`, used as the pid-reuse-defeating token.
554#[cfg(unix)]
555fn probe_start_token(pid: u32) -> Option<String> {
556    // `ps -o lstart=` prints the absolute process start timestamp on both Linux
557    // and macOS. It changes when the pid is reused, which is exactly the identity
558    // signal we need. Empty/failed output degrades to bare pid liveness.
559    let output = std::process::Command::new("ps")
560        .args(["-o", "lstart=", "-p", &pid.to_string()])
561        .stderr(Stdio::null())
562        .output()
563        .ok()?;
564    if !output.status.success() {
565        return None;
566    }
567    let token = String::from_utf8_lossy(&output.stdout).trim().to_owned();
568    (!token.is_empty()).then_some(token)
569}
570
571#[cfg(windows)]
572fn probe_start_token(pid: u32) -> Option<String> {
573    // Query the process creation date; it changes on pid reuse. `wmic` is gone
574    // from current Windows 11 installs, so use CIM through PowerShell instead.
575    let script = format!(
576        "$p = Get-CimInstance Win32_Process -Filter 'ProcessId = {pid}' -ErrorAction Stop; \
577         if ($null -ne $p) {{ $p.CreationDate.ToUniversalTime().ToString('o') }}"
578    );
579    let output = std::process::Command::new("powershell")
580        .args(["-NoProfile", "-Command", &script])
581        .stderr(Stdio::null())
582        .output()
583        .ok()?;
584    if !output.status.success() {
585        return None;
586    }
587    parse_powershell_start_token_output(&output.stdout)
588}
589
590#[cfg(any(windows, test))]
591fn parse_powershell_start_token_output(stdout: &[u8]) -> Option<String> {
592    let text = String::from_utf8_lossy(stdout);
593    let token = text
594        .lines()
595        .find_map(|line| {
596            let trimmed = line.trim();
597            (!trimmed.is_empty()).then_some(trimmed)
598        })
599        .map(str::trim)
600        .map(str::to_owned)?;
601    (!token.is_empty()).then_some(token)
602}
603
604#[cfg(not(any(unix, windows)))]
605fn probe_start_token(_pid: u32) -> Option<String> {
606    None
607}
608
609/// Summarize the watcher lock for `status` output: `None` when no live watcher,
610/// otherwise the owning pid.
611pub fn live_watcher_pid(state_dir: &Path) -> Option<u32> {
612    match read_lock(state_dir) {
613        Ok(Some(lock)) => lock_is_held(&lock).then_some(lock.identity.pid),
614        _ => None,
615    }
616}
617
618/// Whether the review queue in `state_dir` currently has pending items.
619pub fn queue_has_pending(state_dir: &Path) -> Result<bool, WatcherError> {
620    let summary = ReviewQueue::new(state_dir)
621        .summary()
622        .map_err(WatcherError::Queue)?;
623    Ok(summary.pending_count > 0)
624}
625
626#[cfg(test)]
627mod tests {
628    use super::*;
629    use std::sync::{
630        Arc, Barrier,
631        atomic::{AtomicUsize, Ordering},
632    };
633
634    fn identity(pid: u32, token: &str) -> ProcessIdentity {
635        ProcessIdentity {
636            pid,
637            start_token: token.to_owned(),
638        }
639    }
640
641    #[test]
642    fn dead_pid_is_stale() {
643        let recorded = identity(4242, "Mon Jan 1 00:00:00 2026");
644        assert_eq!(decide_liveness(&recorded, None), Liveness::Stale);
645    }
646
647    #[test]
648    fn reused_pid_with_different_start_token_is_stale() {
649        let recorded = identity(4242, "Mon Jan 1 00:00:00 2026");
650        let probed = identity(4242, "Tue Feb 2 11:11:11 2026");
651        assert_eq!(decide_liveness(&recorded, Some(probed)), Liveness::Stale);
652    }
653
654    #[test]
655    fn same_pid_and_start_token_is_alive() {
656        let recorded = identity(4242, "Mon Jan 1 00:00:00 2026");
657        let probed = recorded.clone();
658        assert_eq!(decide_liveness(&recorded, Some(probed)), Liveness::Alive);
659    }
660
661    #[test]
662    fn empty_start_token_falls_back_to_pid_liveness() {
663        let recorded = identity(4242, "");
664        let probed = identity(4242, "some-token");
665        assert_eq!(decide_liveness(&recorded, Some(probed)), Liveness::Alive);
666
667        let recorded = identity(4242, "recorded-token");
668        let probed = identity(4242, "");
669        assert_eq!(decide_liveness(&recorded, Some(probed)), Liveness::Alive);
670    }
671
672    #[test]
673    fn different_pid_is_stale_even_with_matching_token() {
674        let recorded = identity(4242, "same-token");
675        let probed = identity(9999, "same-token");
676        assert_eq!(decide_liveness(&recorded, Some(probed)), Liveness::Stale);
677    }
678
679    #[test]
680    fn acquire_then_reacquire_by_live_current_process_stands_down() {
681        let temp = tempfile::tempdir().unwrap();
682        let state = temp.path();
683
684        // First claim succeeds via the exclusive create.
685        assert_eq!(try_acquire_lock(state).unwrap(), LockClaim::Acquired);
686
687        // The current process IS the recorded owner and is alive, so a second
688        // claim from this same process observes a live owner and stands down.
689        assert_eq!(
690            try_acquire_lock(state).unwrap(),
691            LockClaim::HeldByLiveWatcher
692        );
693
694        // And a liveness check agrees.
695        assert!(watcher_is_alive(state));
696        assert_eq!(live_watcher_pid(state), Some(std::process::id()));
697    }
698
699    #[test]
700    fn empty_or_corrupt_lock_is_held_only_during_settling_window() {
701        assert!(corrupt_lock_is_held_at(1_000, 1_000));
702        assert!(corrupt_lock_is_held_at(
703            1_000,
704            1_000 + LOCK_SETTLING_SECS - 1
705        ));
706        assert!(!corrupt_lock_is_held_at(1_000, 1_000 + LOCK_SETTLING_SECS));
707    }
708
709    #[test]
710    fn empty_lock_file_does_not_error_or_get_reclaimed_while_settling() {
711        let temp = tempfile::tempdir().unwrap();
712        let state = temp.path();
713        fs::create_dir_all(state).unwrap();
714        fs::write(lock_path(state), "").unwrap();
715
716        assert!(matches!(
717            read_lock_state(state).unwrap(),
718            LockRead::Corrupt { .. }
719        ));
720        assert_eq!(
721            try_acquire_lock(state).unwrap(),
722            LockClaim::HeldByLiveWatcher
723        );
724    }
725
726    #[test]
727    fn stale_lock_from_dead_pid_is_reclaimed() {
728        let temp = tempfile::tempdir().unwrap();
729        let state = temp.path();
730
731        // Plant a lock owned by a pid that is (almost certainly) dead, with a
732        // start token so liveness cannot silently pass on the fallback path.
733        let stale = WatcherLock {
734            identity: identity(4_000_000_000, "definitely-not-a-live-token"),
735            created_at_unix: 1,
736        };
737        write_lock(state, &stale).unwrap();
738        assert!(!watcher_is_alive(state));
739
740        // The next claim reclaims the stale slot rather than wedging.
741        assert_eq!(try_acquire_lock(state).unwrap(), LockClaim::Acquired);
742        assert_eq!(live_watcher_pid(state), Some(std::process::id()));
743    }
744
745    #[test]
746    fn stale_lock_with_unknown_start_token_from_dead_pid_is_reclaimed() {
747        let temp = tempfile::tempdir().unwrap();
748        let state = temp.path();
749
750        let stale = WatcherLock {
751            identity: identity(4_000_000_000, ""),
752            created_at_unix: 1,
753        };
754        write_lock(state, &stale).unwrap();
755
756        assert_eq!(try_acquire_lock(state).unwrap(), LockClaim::Acquired);
757        assert_eq!(live_watcher_pid(state), Some(std::process::id()));
758    }
759
760    #[test]
761    fn threaded_stale_lock_reclaim_race_yields_exactly_one_winner() {
762        const THREADS: usize = 8;
763
764        let temp = tempfile::tempdir().unwrap();
765        let state = Arc::new(temp.path().to_path_buf());
766        let stale = WatcherLock {
767            identity: identity(4_000_000_000, "definitely-not-a-live-token"),
768            created_at_unix: 1,
769        };
770        write_lock(state.as_ref(), &stale).unwrap();
771
772        let barrier = Arc::new(Barrier::new(THREADS));
773        let acquired = Arc::new(AtomicUsize::new(0));
774        let held = Arc::new(AtomicUsize::new(0));
775        let mut threads = Vec::with_capacity(THREADS);
776
777        for _ in 0..THREADS {
778            let state = Arc::clone(&state);
779            let barrier = Arc::clone(&barrier);
780            let acquired = Arc::clone(&acquired);
781            let held = Arc::clone(&held);
782            threads.push(std::thread::spawn(move || {
783                barrier.wait();
784                match try_acquire_lock(state.as_ref()).unwrap() {
785                    LockClaim::Acquired => {
786                        acquired.fetch_add(1, Ordering::SeqCst);
787                    }
788                    LockClaim::HeldByLiveWatcher => {
789                        held.fetch_add(1, Ordering::SeqCst);
790                    }
791                }
792            }));
793        }
794
795        for thread in threads {
796            thread.join().unwrap();
797        }
798
799        assert_eq!(acquired.load(Ordering::SeqCst), 1);
800        assert_eq!(held.load(Ordering::SeqCst), THREADS - 1);
801    }
802
803    #[test]
804    fn release_is_a_noop_when_not_owner() {
805        let temp = tempfile::tempdir().unwrap();
806        let state = temp.path();
807
808        let other = WatcherLock {
809            identity: identity(4_000_000_000, "other-token"),
810            created_at_unix: 1,
811        };
812        write_lock(state, &other).unwrap();
813
814        // We are not the recorded owner, so release leaves the lock intact.
815        release_lock_if_owned(state).unwrap();
816        assert!(read_lock(state).unwrap().is_some());
817    }
818
819    #[test]
820    fn release_removes_lock_when_owner() {
821        let temp = tempfile::tempdir().unwrap();
822        let state = temp.path();
823
824        assert_eq!(try_acquire_lock(state).unwrap(), LockClaim::Acquired);
825        release_lock_if_owned(state).unwrap();
826        assert!(read_lock(state).unwrap().is_none());
827    }
828
829    #[test]
830    fn release_removes_lock_when_owner_start_token_is_unknown() {
831        let temp = tempfile::tempdir().unwrap();
832        let state = temp.path();
833
834        let current = WatcherLock {
835            identity: identity(std::process::id(), ""),
836            created_at_unix: 1,
837        };
838        write_lock(state, &current).unwrap();
839
840        release_lock_if_owned(state).unwrap();
841        assert!(read_lock(state).unwrap().is_none());
842    }
843
844    #[test]
845    fn ensure_watcher_releases_claimed_lock_when_spawn_fails() {
846        let temp = tempfile::tempdir().unwrap();
847        let state = temp.path();
848
849        let error = ensure_watcher_with_spawner(state, |_| {
850            Err(WatcherError::Spawn(io::Error::other("spawn failed")))
851        })
852        .unwrap_err();
853
854        assert!(matches!(error, WatcherError::Spawn(_)));
855        assert!(read_lock(state).unwrap().is_none());
856        assert_eq!(try_acquire_lock(state).unwrap(), LockClaim::Acquired);
857    }
858
859    #[test]
860    fn powershell_start_token_parser_uses_first_nonempty_line() {
861        let output = b"\r\n  2026-07-09T01:02:03.0000000Z  \r\n";
862
863        assert_eq!(
864            parse_powershell_start_token_output(output).as_deref(),
865            Some("2026-07-09T01:02:03.0000000Z")
866        );
867    }
868
869    #[test]
870    fn powershell_start_token_parser_rejects_empty_output() {
871        assert_eq!(parse_powershell_start_token_output(b"\r\n  \n"), None);
872    }
873
874    #[test]
875    fn current_identity_has_our_pid() {
876        assert_eq!(ProcessIdentity::current().pid, std::process::id());
877    }
878
879    #[test]
880    fn fresh_lock_with_dead_owner_is_held_during_settling_window() {
881        // A just-claimed lock whose recorded pid can't be confirmed alive (the
882        // parent that claimed it may have already exited) still counts as held
883        // while settling, so a racing caller stands down instead of double-spawning.
884        let lock = WatcherLock {
885            identity: identity(4_000_000_000, "dead-token"),
886            created_at_unix: 1_000,
887        };
888        // now == created (elapsed 0) → within settling → held.
889        assert!(lock_is_held_at(&lock, None, 1_000));
890        // Just before the settling boundary → still held.
891        assert!(lock_is_held_at(&lock, None, 1_000 + LOCK_SETTLING_SECS - 1));
892    }
893
894    #[test]
895    fn stale_lock_past_settling_window_is_not_held() {
896        let lock = WatcherLock {
897            identity: identity(4_000_000_000, "dead-token"),
898            created_at_unix: 1_000,
899        };
900        // At/after the settling boundary with a dead owner → reclaimable.
901        assert!(!lock_is_held_at(&lock, None, 1_000 + LOCK_SETTLING_SECS));
902        assert!(!lock_is_held_at(&lock, None, 5_000));
903    }
904
905    #[test]
906    fn live_owner_is_held_regardless_of_lock_age() {
907        // Even an old lock is held if its recorded owner is alive with a matching
908        // start token — settling only ever *adds* held-ness, never removes it.
909        let lock = WatcherLock {
910            identity: identity(4242, "live-token"),
911            created_at_unix: 1,
912        };
913        let probed = identity(4242, "live-token");
914        assert!(lock_is_held_at(&lock, Some(probed), 9_999_999));
915    }
916}