1use 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
30pub const WATCHER_LOCK_FILE: &str = "watcher.lock";
32const WATCHER_RECLAIM_DIR: &str = "watcher.lock.reclaim";
33
34const 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#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
65pub struct ProcessIdentity {
66 pub pid: u32,
67 #[serde(default)]
70 pub start_token: String,
71}
72
73impl ProcessIdentity {
74 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#[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
106pub enum Liveness {
107 Alive,
109 Stale,
112}
113
114pub 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 return Liveness::Alive;
135 }
136 if probed.start_token == recorded.start_token {
137 Liveness::Alive
138 } else {
139 Liveness::Stale
140 }
141}
142
143fn 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
156fn 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
162fn 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
173pub 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
266pub enum LockClaim {
267 Acquired,
269 HeldByLiveWatcher,
271}
272
273pub 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 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
388pub 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
415pub 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 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 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
472fn 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 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 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#[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 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#[cfg(unix)]
555fn probe_start_token(pid: u32) -> Option<String> {
556 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 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
609pub 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
618pub 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 assert_eq!(try_acquire_lock(state).unwrap(), LockClaim::Acquired);
686
687 assert_eq!(
690 try_acquire_lock(state).unwrap(),
691 LockClaim::HeldByLiveWatcher
692 );
693
694 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 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 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 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, ¤t).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 let lock = WatcherLock {
885 identity: identity(4_000_000_000, "dead-token"),
886 created_at_unix: 1_000,
887 };
888 assert!(lock_is_held_at(&lock, None, 1_000));
890 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 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 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}