1use std::collections::HashMap;
2use std::fmt;
3use std::fs::{self, File, OpenOptions};
4use std::io::{self, Write};
5use std::path::{Path, PathBuf};
6use std::sync::{
7 atomic::{AtomicBool, AtomicU64, Ordering},
8 mpsc, Arc, Mutex, OnceLock,
9};
10use std::thread::{self, JoinHandle};
11use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
12
13use serde::{Deserialize, Serialize};
14
15use crate::{slog_debug, slog_error, slog_info, slog_warn};
16
17pub const HEARTBEAT_INTERVAL_MS: u64 = 5_000;
18pub const STALE_HEARTBEAT_MS: u64 = 15_000;
19pub const LIVE_OWNER_WARN_MS: u64 = 600_000;
20pub const POLL_INTERVAL_MS: u64 = 100;
21
22const MAX_TRANSIENT_CREATE_RETRIES: u32 = 50;
30const RECLAIM_TOKEN_MALFORMED_STALE_AGE: Duration = Duration::from_secs(60);
31const RECLAIM_BLOCK_LOG_INTERVAL: Duration = Duration::from_secs(60);
32const DEAD_RECLAIM_INITIAL_BACKOFF_MS: u64 = 250;
33const DEAD_RECLAIM_MAX_BACKOFF_MS: u64 = 5_000;
34const RECLAIM_TOKEN_SWEEP_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
35
36const RECLAIM_TOKEN_SWEEP_DOMAINS: &[&str] = &[
41 "index", "callgraph", "inspect", "semantic", "symbols", "checkpoints", ".aft", ];
49const RECLAIM_TOKEN_SWEEP_MAX_DOMAIN_DEPTH: usize = 2;
50const ROOT_RECLAIM_TOKEN_PATHS: &[&str] = &[".trusted-filter-projects.json.lock.reclaim"];
52const FIXED_BACKUP_HARNESS_DIRS: &[&str] = &["opencode", "pi", "runner"];
55const BACKUP_HARNESS_DIR_PREFIXES: &[&str] = &["mcp--", "fed--"];
56
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58enum ReclaimTokenState {
59 Alive,
60 DeadForeignHost,
61 Malformed,
62 Dead,
63}
64
65impl ReclaimTokenState {
66 fn as_str(self) -> &'static str {
67 match self {
68 Self::Alive => "alive",
69 Self::DeadForeignHost => "dead-foreign-host",
70 Self::Malformed => "malformed",
71 Self::Dead => "dead",
72 }
73 }
74}
75
76#[derive(Clone, Debug, PartialEq, Eq)]
77struct ReclaimTokenHeld {
78 pid: Option<u32>,
79 state: ReclaimTokenState,
80}
81
82#[derive(Debug)]
83enum ReclaimResult {
84 Removed,
85 Blocked(ReclaimTokenHeld),
86 Unchanged,
87}
88
89#[derive(Clone, Debug)]
90struct ReclaimBlockLogRecord {
91 last_emitted: Instant,
92 suppressed: u64,
93}
94
95static RECLAIM_BLOCK_LOGS: OnceLock<Mutex<HashMap<PathBuf, ReclaimBlockLogRecord>>> =
96 OnceLock::new();
97static RECLAIM_TOKEN_SWEEP_LAST_RUN: OnceLock<Mutex<Option<Instant>>> = OnceLock::new();
98
99fn is_transient_create_contention(error: &io::Error) -> bool {
105 if error.kind() == io::ErrorKind::PermissionDenied {
106 return true;
107 }
108 #[cfg(windows)]
109 {
110 if let Some(code) = error.raw_os_error() {
114 if code == 32 || code == 5 {
115 return true;
116 }
117 }
118 }
119 false
120}
121
122#[derive(Clone, Copy, Debug)]
123struct LockConfig {
124 heartbeat_interval_ms: u64,
125 stale_heartbeat_ms: u64,
126 live_owner_warn_ms: u64,
127 poll_interval_ms: u64,
128}
129
130impl LockConfig {
131 fn cross_host_stale_heartbeat_ms(self) -> u64 {
132 self.stale_heartbeat_ms.saturating_mul(5)
133 }
134}
135
136impl Default for LockConfig {
137 fn default() -> Self {
138 Self {
139 heartbeat_interval_ms: HEARTBEAT_INTERVAL_MS,
140 stale_heartbeat_ms: STALE_HEARTBEAT_MS,
141 live_owner_warn_ms: LIVE_OWNER_WARN_MS,
142 poll_interval_ms: POLL_INTERVAL_MS,
143 }
144 }
145}
146
147#[derive(Clone, Debug, PartialEq, Eq)]
148struct ProcessIdentity {
149 start_time: u64,
150 boot_id: Option<String>,
151}
152
153#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
154struct LockMetadata {
155 pid: u32,
156 hostname: String,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
160 process_start_time: Option<u64>,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
165 boot_id: Option<String>,
166 created_at_ms: u64,
167 heartbeat_at_ms: u64,
168 #[serde(default)]
171 writer_epoch: String,
172}
173
174pub fn acquire(path: &Path) -> Result<LockGuard, AcquireError> {
179 acquire_with_config(path, None, LockConfig::default())
180}
181
182pub fn try_acquire(path: &Path, timeout: Duration) -> Result<LockGuard, AcquireError> {
184 acquire_with_config(path, Some(timeout), LockConfig::default())
185}
186
187pub fn try_acquire_once(path: &Path) -> Result<LockGuard, AcquireError> {
193 try_acquire(path, Duration::ZERO)
194}
195
196pub struct LockGuard {
197 path: PathBuf,
198 metadata: LockMetadata,
199 shutdown: Arc<AtomicBool>,
200 heartbeat_failed: Arc<AtomicBool>,
201 heartbeat_done: mpsc::Receiver<()>,
202 heartbeat: Option<JoinHandle<()>>,
203}
204
205impl LockGuard {
206 pub fn path(&self) -> &Path {
207 &self.path
208 }
209
210 pub fn writer_epoch(&self) -> &str {
211 &self.metadata.writer_epoch
212 }
213
214 pub fn verify_writer_epoch(&self) -> io::Result<bool> {
218 if self.heartbeat_failed.load(Ordering::Acquire) {
219 return Ok(false);
220 }
221 match read_lock_metadata(&self.path) {
222 Ok(metadata) => Ok(lock_identity_matches(&metadata, &self.metadata)),
223 Err(ReadLockError::Io(error)) if error.kind() == io::ErrorKind::NotFound => Ok(false),
224 Err(ReadLockError::Io(error)) => Err(error),
225 Err(ReadLockError::Malformed(_)) => Ok(false),
226 }
227 }
228}
229
230impl Drop for LockGuard {
231 fn drop(&mut self) {
232 self.shutdown.store(true, Ordering::Release);
256 if let Some(handle) = self.heartbeat.take() {
257 handle.thread().unpark();
258 let _ = handle.join();
259 }
260 while self.heartbeat_done.try_recv().is_ok() {}
264
265 match remove_lock_if_owned(&self.path, &self.metadata) {
266 Ok(true) => slog_debug!("released filesystem lock at {}", self.path.display()),
267 Ok(false) => {}
268 Err(error) => slog_warn!(
269 "failed to release filesystem lock at {}: {}",
270 self.path.display(),
271 error
272 ),
273 }
274 }
275}
276
277#[derive(Debug)]
278pub enum AcquireError {
279 Io(io::Error),
280 Timeout,
281}
282
283impl fmt::Display for AcquireError {
284 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
285 match self {
286 AcquireError::Io(error) => write!(f, "filesystem lock I/O error: {error}"),
287 AcquireError::Timeout => write!(f, "timed out acquiring filesystem lock"),
288 }
289 }
290}
291
292impl std::error::Error for AcquireError {}
293
294impl From<io::Error> for AcquireError {
295 fn from(error: io::Error) -> Self {
296 AcquireError::Io(error)
297 }
298}
299
300fn acquire_with_config(
301 path: &Path,
302 timeout: Option<Duration>,
303 config: LockConfig,
304) -> Result<LockGuard, AcquireError> {
305 let deadline = timeout.map(|timeout| Instant::now() + timeout);
306 let hostname = current_hostname();
307 let mut warned_live_owner = false;
308 let mut warned_stale_live_owner = false;
309 let mut transient_create_failures: u32 = 0;
310 let mut attempted_once = false;
311 let mut dead_reclaim_blocked_attempts = 0_u32;
312 let mut immediate_retry_budget = 0_u8;
315
316 loop {
317 if attempted_once {
318 if immediate_retry_budget > 0 {
319 immediate_retry_budget -= 1;
320 } else if let Some(deadline) = deadline {
321 if Instant::now() >= deadline {
322 return Err(AcquireError::Timeout);
323 }
324 }
325 }
326 attempted_once = true;
327
328 match create_new_lock(path, &hostname, config) {
329 Ok(guard) => return Ok(guard),
330 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
332 Err(error) if is_transient_create_contention(&error) => {
338 transient_create_failures += 1;
339 if transient_create_failures > MAX_TRANSIENT_CREATE_RETRIES {
340 return Err(error.into());
341 }
342 sleep_until_retry(deadline, config.poll_interval_ms)?;
343 continue;
344 }
345 Err(error) => return Err(error.into()),
346 }
347 transient_create_failures = 0;
348
349 let metadata = match read_lock_metadata(path) {
350 Ok(metadata) => metadata,
351 Err(ReadLockError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
352 immediate_retry_budget = 1;
353 continue;
354 }
355 Err(ReadLockError::Io(error)) => return Err(error.into()),
356 Err(ReadLockError::Malformed(error)) => {
357 sleep_until_retry(deadline, config.poll_interval_ms)?;
361 match read_lock_metadata(path) {
362 Ok(_) => continue,
363 Err(ReadLockError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
364 continue;
365 }
366 Err(ReadLockError::Io(error)) => return Err(error.into()),
367 Err(ReadLockError::Malformed(_)) => {}
368 }
369 slog_warn!(
370 "removing malformed filesystem lock at {}: {}",
371 path.display(),
372 error
373 );
374 remove_lock_file(path)?;
375 immediate_retry_budget = 1;
376 continue;
377 }
378 };
379
380 let now = now_ms();
381 let since_heartbeat = now.saturating_sub(metadata.heartbeat_at_ms);
382
383 if metadata.hostname != hostname {
384 dead_reclaim_blocked_attempts = 0;
385 let cross_host_stale_ms = config.cross_host_stale_heartbeat_ms();
386 if since_heartbeat > cross_host_stale_ms {
387 match reclaim_lock_file(path, &metadata)? {
388 ReclaimResult::Removed => {
389 slog_warn!(
390 "reclaimed cross-host filesystem lock at {} from host {} after stale heartbeat ({}ms > {}ms)",
391 path.display(),
392 metadata.hostname,
393 since_heartbeat,
394 cross_host_stale_ms
395 );
396 immediate_retry_budget = 1;
397 }
398 ReclaimResult::Blocked(holder) => {
399 log_reclaim_blocked(path, &holder);
400 sleep_until_retry(deadline, config.poll_interval_ms)?;
401 }
402 ReclaimResult::Unchanged => {
403 sleep_until_retry(deadline, config.poll_interval_ms)?;
404 }
405 }
406 continue;
407 }
408 sleep_until_retry(deadline, config.poll_interval_ms)?;
409 continue;
410 }
411
412 if !lock_owner_is_alive(&metadata) {
413 match reclaim_lock_file(path, &metadata)? {
414 ReclaimResult::Removed => {
415 slog_warn!(
416 "removing filesystem lock at {} from dead or recycled PID {}",
417 path.display(),
418 metadata.pid
419 );
420 immediate_retry_budget = 1;
421 dead_reclaim_blocked_attempts = 0;
422 }
423 ReclaimResult::Blocked(holder) => {
424 log_reclaim_blocked(path, &holder);
425 let backoff_ms = dead_reclaim_backoff_ms(
426 dead_reclaim_blocked_attempts,
427 config.poll_interval_ms,
428 );
429 dead_reclaim_blocked_attempts = dead_reclaim_blocked_attempts.saturating_add(1);
430 sleep_until_retry(deadline, backoff_ms)?;
431 }
432 ReclaimResult::Unchanged => {
433 dead_reclaim_blocked_attempts = 0;
434 sleep_until_retry(deadline, config.poll_interval_ms)?;
435 }
436 }
437 continue;
438 }
439 dead_reclaim_blocked_attempts = 0;
440
441 if since_heartbeat > config.stale_heartbeat_ms && !warned_stale_live_owner {
442 slog_warn!(
451 "filesystem lock at {} held by live PID {} has stale heartbeat ({}ms); NOT breaking",
452 path.display(),
453 metadata.pid,
454 since_heartbeat
455 );
456 warned_stale_live_owner = true;
457 }
458
459 let held_for = now.saturating_sub(metadata.created_at_ms);
460 if held_for > config.live_owner_warn_ms && !warned_live_owner {
461 slog_warn!(
462 "filesystem lock at {} held >10min by live heartbeating PID {}; NOT breaking",
463 path.display(),
464 metadata.pid
465 );
466 warned_live_owner = true;
467 }
468
469 sleep_until_retry(deadline, config.poll_interval_ms)?;
470 }
471}
472
473fn create_new_lock(path: &Path, hostname: &str, config: LockConfig) -> io::Result<LockGuard> {
474 let now = now_ms();
475 let pid = std::process::id();
476 let process_identity = process_identity(pid);
477 let metadata = LockMetadata {
478 pid,
479 hostname: hostname.to_string(),
480 process_start_time: process_identity
481 .as_ref()
482 .map(|identity| identity.start_time),
483 boot_id: process_identity.and_then(|identity| identity.boot_id),
484 created_at_ms: now,
485 heartbeat_at_ms: now,
486 writer_epoch: format!("{pid}-{}", now_nanos()),
487 };
488
489 create_lock_file_atomically(path, &metadata)?;
490
491 let shutdown = Arc::new(AtomicBool::new(false));
492 let heartbeat_failed = Arc::new(AtomicBool::new(false));
493 let (done_tx, done_rx) = mpsc::channel();
494 let heartbeat_path = path.to_path_buf();
495 let heartbeat_metadata = metadata.clone();
496 let heartbeat_shutdown = Arc::clone(&shutdown);
497 let heartbeat_failed_for_thread = Arc::clone(&heartbeat_failed);
498 let heartbeat = thread::Builder::new()
499 .name("aft-fs-lock-heartbeat".to_string())
500 .spawn(move || {
501 let heartbeat_shutdown_for_run = Arc::clone(&heartbeat_shutdown);
502 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
503 run_heartbeat(
504 heartbeat_path,
505 heartbeat_metadata,
506 heartbeat_shutdown_for_run,
507 config,
508 );
509 }));
510 if result.is_err() || !heartbeat_shutdown.load(Ordering::Acquire) {
511 heartbeat_failed_for_thread.store(true, Ordering::Release);
512 }
513 let _ = done_tx.send(());
514 })?;
515
516 slog_debug!("acquired filesystem lock at {}", path.display());
517
518 Ok(LockGuard {
519 path: path.to_path_buf(),
520 metadata,
521 shutdown,
522 heartbeat_failed,
523 heartbeat_done: done_rx,
524 heartbeat: Some(heartbeat),
525 })
526}
527
528fn run_heartbeat(
529 path: PathBuf,
530 owner: LockMetadata,
531 shutdown: Arc<AtomicBool>,
532 config: LockConfig,
533) {
534 let stale_intervals = config
539 .stale_heartbeat_ms
540 .checked_div(config.heartbeat_interval_ms.max(1))
541 .unwrap_or(3)
542 .max(1);
543 let mut consecutive_transient_failures: u64 = 0;
544
545 loop {
546 thread::park_timeout(Duration::from_millis(config.heartbeat_interval_ms));
547 if shutdown.load(Ordering::Acquire) {
548 return;
549 }
550
551 match heartbeat_once(&path, &owner) {
552 Ok(()) => {
553 if consecutive_transient_failures > 0 {
554 slog_info!(
555 "filesystem lock at {} heartbeat recovered after {} transient failure(s)",
556 path.display(),
557 consecutive_transient_failures
558 );
559 consecutive_transient_failures = 0;
560 }
561 }
562 Err(error) if heartbeat_error_is_terminal(&error) => {
563 slog_error!(
568 "{}; stopping heartbeat",
569 terminal_heartbeat_message(&path, &error)
570 );
571 return;
572 }
573 Err(error) => {
574 consecutive_transient_failures += 1;
584 log_transient_heartbeat_failure(
585 &path,
586 &transient_heartbeat_reason(&error),
587 consecutive_transient_failures,
588 stale_intervals,
589 );
590 }
591 }
592 }
593}
594
595fn heartbeat_error_is_terminal(error: &HeartbeatError) -> bool {
601 matches!(error, HeartbeatError::LockGone | HeartbeatError::NotOwner)
602}
603
604fn terminal_heartbeat_message(path: &Path, error: &HeartbeatError) -> String {
605 match error {
606 HeartbeatError::LockGone => {
607 format!("filesystem lock at {} disappeared", path.display())
608 }
609 HeartbeatError::NotOwner => format!(
610 "filesystem lock at {} is no longer owned by this guard",
611 path.display()
612 ),
613 HeartbeatError::Io(error) => {
615 format!("filesystem lock at {} I/O error: {error}", path.display())
616 }
617 HeartbeatError::Malformed(error) => {
618 format!(
619 "filesystem lock at {} became malformed: {error}",
620 path.display()
621 )
622 }
623 }
624}
625
626fn transient_heartbeat_reason(error: &HeartbeatError) -> String {
627 match error {
628 HeartbeatError::Io(error) => format!("I/O error: {error}"),
629 HeartbeatError::Malformed(error) => format!("became malformed: {error}"),
630 HeartbeatError::LockGone => "lock disappeared".to_string(),
631 HeartbeatError::NotOwner => "lock no longer owned".to_string(),
632 }
633}
634
635fn log_transient_heartbeat_failure(
640 path: &Path,
641 reason: &str,
642 consecutive_failures: u64,
643 stale_intervals: u64,
644) {
645 if consecutive_failures < stale_intervals {
646 slog_warn!(
647 "transient failure to heartbeat filesystem lock at {}: {}; retrying (attempt {})",
648 path.display(),
649 reason,
650 consecutive_failures
651 );
652 } else if consecutive_failures == stale_intervals {
653 slog_error!(
654 "filesystem lock at {} has failed {} consecutive heartbeats: {}; \
655 the lock may now be reclaimed by another owner — continuing to retry",
656 path.display(),
657 consecutive_failures,
658 reason
659 );
660 }
661}
662
663fn heartbeat_once(path: &Path, owner: &LockMetadata) -> Result<(), HeartbeatError> {
664 let mut metadata = match read_lock_metadata(path) {
665 Ok(metadata) => metadata,
666 Err(ReadLockError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
667 return Err(HeartbeatError::LockGone);
668 }
669 Err(ReadLockError::Io(error)) => return Err(HeartbeatError::Io(error)),
670 Err(ReadLockError::Malformed(error)) => return Err(HeartbeatError::Malformed(error)),
671 };
672
673 if !lock_identity_matches(&metadata, owner) {
674 return Err(HeartbeatError::NotOwner);
675 }
676
677 metadata.heartbeat_at_ms = now_ms();
678 atomic_write_lock_metadata(path, &metadata).map_err(HeartbeatError::Io)
679}
680
681#[derive(Debug)]
682enum HeartbeatError {
683 Io(io::Error),
684 LockGone,
685 Malformed(serde_json::Error),
686 NotOwner,
687}
688
689#[derive(Debug)]
690enum ReadLockError {
691 Io(io::Error),
692 Malformed(serde_json::Error),
693}
694
695fn read_lock_metadata(path: &Path) -> Result<LockMetadata, ReadLockError> {
696 let bytes = fs::read(path).map_err(ReadLockError::Io)?;
697 serde_json::from_slice(&bytes).map_err(ReadLockError::Malformed)
698}
699
700#[cfg(unix)]
701fn open_new_lock_file(path: &Path) -> io::Result<File> {
702 use std::os::unix::fs::OpenOptionsExt;
703
704 OpenOptions::new()
705 .write(true)
706 .create_new(true)
707 .mode(0o600)
708 .open(path)
709}
710
711#[cfg(not(unix))]
712fn open_new_lock_file(path: &Path) -> io::Result<File> {
713 OpenOptions::new().write(true).create_new(true).open(path)
714}
715
716fn write_lock_metadata_to_file(file: &mut File, metadata: &LockMetadata) -> io::Result<()> {
717 serde_json::to_writer(&mut *file, metadata).map_err(io::Error::other)?;
718 file.write_all(b"\n")?;
719 file.sync_all()
720}
721
722fn create_lock_file_atomically(path: &Path, metadata: &LockMetadata) -> io::Result<()> {
723 let tmp_path = temp_path_for_lock(path);
724 let result = (|| {
725 let mut file = open_new_lock_file(&tmp_path)?;
726 write_lock_metadata_to_file(&mut file, metadata)?;
727 drop(file);
728
729 fs::hard_link(&tmp_path, path)?;
730 sync_parent(path);
731 Ok(())
732 })();
733
734 let _ = fs::remove_file(&tmp_path);
735 result
736}
737
738fn atomic_write_lock_metadata(path: &Path, metadata: &LockMetadata) -> io::Result<()> {
739 let tmp_path = temp_path_for_lock(path);
740 let write_result = (|| {
741 let mut file = open_new_lock_file(&tmp_path)?;
742 write_lock_metadata_to_file(&mut file, metadata)?;
743 drop(file);
744
745 rename_over(&tmp_path, path)?;
746 sync_parent(path);
747 Ok(())
748 })();
749
750 if write_result.is_err() {
751 let _ = fs::remove_file(&tmp_path);
752 }
753
754 write_result
755}
756
757#[cfg(any(windows, test))]
758fn rename_over_with(
759 from: &Path,
760 to: &Path,
761 replace: impl FnOnce(&Path, &Path) -> io::Result<()>,
762) -> io::Result<()> {
763 replace(from, to)
764}
765
766#[cfg(windows)]
767pub(crate) fn rename_over(from: &Path, to: &Path) -> io::Result<()> {
768 rename_over_with(from, to, |from, to| fs::rename(from, to))
778}
779
780#[cfg(not(windows))]
781pub(crate) fn rename_over(from: &Path, to: &Path) -> io::Result<()> {
782 fs::rename(from, to)
783}
784
785static TEMP_LOCK_COUNTER: AtomicU64 = AtomicU64::new(0);
798
799fn temp_path_for_lock(path: &Path) -> PathBuf {
800 let file_name = path
801 .file_name()
802 .and_then(|name| name.to_str())
803 .unwrap_or("lock");
804 let seq = TEMP_LOCK_COUNTER.fetch_add(1, Ordering::Relaxed);
805 path.with_file_name(format!(
806 ".{file_name}.tmp.{}.{}.{}",
807 std::process::id(),
808 now_nanos(),
809 seq
810 ))
811}
812
813fn lock_identity_matches(left: &LockMetadata, right: &LockMetadata) -> bool {
814 left.pid == right.pid
815 && left.hostname == right.hostname
816 && left.process_start_time == right.process_start_time
817 && left.boot_id == right.boot_id
818 && left.created_at_ms == right.created_at_ms
819 && left.writer_epoch == right.writer_epoch
820}
821
822fn remove_lock_if_owned(path: &Path, owner: &LockMetadata) -> io::Result<bool> {
823 let metadata = match read_lock_metadata(path) {
824 Ok(metadata) => metadata,
825 Err(ReadLockError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
826 return Ok(false);
827 }
828 Err(ReadLockError::Io(error)) => return Err(error),
829 Err(ReadLockError::Malformed(_)) => return Ok(false),
830 };
831
832 if lock_identity_matches(&metadata, owner) {
833 remove_lock_file(path)?;
834 Ok(true)
835 } else {
836 Ok(false)
837 }
838}
839
840fn remove_lock_file(path: &Path) -> io::Result<()> {
841 match fs::remove_file(path) {
842 Ok(()) => Ok(()),
843 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
844 Err(error) => Err(error),
845 }
846}
847
848fn reclaim_lock_file(path: &Path, judged: &LockMetadata) -> io::Result<ReclaimResult> {
857 let token = match acquire_reclaim_token(path)? {
858 ReclaimTokenAcquire::Acquired(token) => token,
859 ReclaimTokenAcquire::Held(holder) => return Ok(ReclaimResult::Blocked(holder)),
860 };
861 let _token = token;
862 match read_lock_metadata(path) {
863 Ok(current) => {
864 if lock_identity_matches(¤t, judged) {
865 remove_lock_file(path)?;
866 Ok(ReclaimResult::Removed)
867 } else {
868 Ok(ReclaimResult::Unchanged)
870 }
871 }
872 Err(ReadLockError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
874 Ok(ReclaimResult::Unchanged)
875 }
876 Err(ReadLockError::Malformed(_)) => Ok(ReclaimResult::Unchanged),
878 Err(ReadLockError::Io(error)) => Err(error),
879 }
880}
881
882struct ReclaimTokenGuard {
883 path: PathBuf,
884}
885
886impl Drop for ReclaimTokenGuard {
887 fn drop(&mut self) {
888 let _ = fs::remove_file(&self.path);
889 sync_parent(&self.path);
890 }
891}
892
893fn acquire_reclaim_token(lock_path: &Path) -> io::Result<ReclaimTokenAcquire> {
894 let token_path = reclaim_token_path(lock_path);
895 let metadata = current_reclaim_token_metadata();
896
897 match create_reclaim_token(&token_path, &metadata) {
898 Ok(guard) => return Ok(ReclaimTokenAcquire::Acquired(guard)),
899 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
900 Err(error) => return Err(error),
901 }
902
903 let stale = match inspect_reclaim_token(&token_path)? {
904 ExistingReclaimToken::Held(holder) => return Ok(ReclaimTokenAcquire::Held(holder)),
905 ExistingReclaimToken::StaleValid(owner) => remove_lock_if_owned(&token_path, &owner)?,
906 ExistingReclaimToken::StaleMalformed => remove_malformed_reclaim_token(&token_path)?,
907 ExistingReclaimToken::Missing => true,
908 };
909 if !stale {
910 return inspect_reclaim_token_as_held(&token_path);
911 }
912
913 match create_reclaim_token(&token_path, &metadata) {
917 Ok(guard) => Ok(ReclaimTokenAcquire::Acquired(guard)),
918 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
919 inspect_reclaim_token_as_held(&token_path)
920 }
921 Err(error) => Err(error),
922 }
923}
924
925enum ReclaimTokenAcquire {
926 Acquired(ReclaimTokenGuard),
927 Held(ReclaimTokenHeld),
928}
929
930enum ExistingReclaimToken {
931 Held(ReclaimTokenHeld),
932 StaleValid(LockMetadata),
933 StaleMalformed,
934 Missing,
935}
936
937fn current_reclaim_token_metadata() -> LockMetadata {
938 let pid = std::process::id();
939 let process_identity = process_identity(pid);
940 let now = now_ms();
941 LockMetadata {
942 pid,
943 hostname: current_hostname(),
944 process_start_time: process_identity
945 .as_ref()
946 .map(|identity| identity.start_time),
947 boot_id: process_identity.and_then(|identity| identity.boot_id),
948 created_at_ms: now,
949 heartbeat_at_ms: now,
950 writer_epoch: format!("reclaim-{pid}-{}", now_nanos()),
951 }
952}
953
954fn create_reclaim_token(
955 token_path: &Path,
956 metadata: &LockMetadata,
957) -> io::Result<ReclaimTokenGuard> {
958 let mut file = open_new_lock_file(token_path)?;
959 if let Err(error) = write_lock_metadata_to_file(&mut file, metadata) {
960 let _ = fs::remove_file(token_path);
961 return Err(error);
962 }
963 sync_parent(token_path);
964 Ok(ReclaimTokenGuard {
965 path: token_path.to_path_buf(),
966 })
967}
968
969fn inspect_reclaim_token(token_path: &Path) -> io::Result<ExistingReclaimToken> {
970 match read_lock_metadata(token_path) {
971 Ok(metadata) if metadata.hostname != current_hostname() => {
972 Ok(ExistingReclaimToken::Held(ReclaimTokenHeld {
973 pid: Some(metadata.pid),
974 state: ReclaimTokenState::DeadForeignHost,
975 }))
976 }
977 Ok(metadata) if lock_owner_is_alive(&metadata) => {
978 Ok(ExistingReclaimToken::Held(ReclaimTokenHeld {
979 pid: Some(metadata.pid),
980 state: ReclaimTokenState::Alive,
981 }))
982 }
983 Ok(metadata) => Ok(ExistingReclaimToken::StaleValid(metadata)),
984 Err(ReadLockError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
985 Ok(ExistingReclaimToken::Missing)
986 }
987 Err(ReadLockError::Io(error)) => Err(error),
988 Err(ReadLockError::Malformed(_)) => {
989 let old_enough = fs::metadata(token_path)
990 .and_then(|metadata| metadata.modified())
991 .ok()
992 .and_then(|modified| SystemTime::now().duration_since(modified).ok())
993 .is_some_and(|age| age > RECLAIM_TOKEN_MALFORMED_STALE_AGE);
994 if old_enough {
995 Ok(ExistingReclaimToken::StaleMalformed)
996 } else {
997 Ok(ExistingReclaimToken::Held(ReclaimTokenHeld {
998 pid: malformed_token_pid(token_path),
999 state: ReclaimTokenState::Malformed,
1000 }))
1001 }
1002 }
1003 }
1004}
1005
1006fn inspect_reclaim_token_as_held(token_path: &Path) -> io::Result<ReclaimTokenAcquire> {
1007 let holder = match inspect_reclaim_token(token_path)? {
1008 ExistingReclaimToken::Held(holder) => holder,
1009 ExistingReclaimToken::StaleValid(metadata) => ReclaimTokenHeld {
1010 pid: Some(metadata.pid),
1011 state: ReclaimTokenState::Dead,
1012 },
1013 ExistingReclaimToken::StaleMalformed | ExistingReclaimToken::Missing => ReclaimTokenHeld {
1014 pid: malformed_token_pid(token_path),
1015 state: ReclaimTokenState::Malformed,
1016 },
1017 };
1018 Ok(ReclaimTokenAcquire::Held(holder))
1019}
1020
1021fn malformed_token_pid(token_path: &Path) -> Option<u32> {
1022 let bytes = fs::read(token_path).ok()?;
1023 serde_json::from_slice::<serde_json::Value>(&bytes)
1024 .ok()?
1025 .get("pid")?
1026 .as_u64()
1027 .and_then(|pid| u32::try_from(pid).ok())
1028}
1029
1030fn remove_malformed_reclaim_token(token_path: &Path) -> io::Result<bool> {
1031 match fs::remove_file(token_path) {
1032 Ok(()) => {
1033 sync_parent(token_path);
1034 Ok(true)
1035 }
1036 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(true),
1037 Err(error) => Err(error),
1038 }
1039}
1040
1041pub(crate) fn sweep_stale_reclaim_tokens(root: &Path) -> io::Result<Option<usize>> {
1045 let last_run = RECLAIM_TOKEN_SWEEP_LAST_RUN.get_or_init(|| Mutex::new(None));
1046 sweep_stale_reclaim_tokens_at(root, Instant::now(), last_run)
1047}
1048
1049fn sweep_stale_reclaim_tokens_at(
1050 root: &Path,
1051 now: Instant,
1052 last_run: &Mutex<Option<Instant>>,
1053) -> io::Result<Option<usize>> {
1054 {
1055 let mut last_run = last_run
1056 .lock()
1057 .map_err(|_| io::Error::other("reclaim-token sweep cadence mutex poisoned"))?;
1058 if last_run
1059 .is_some_and(|last| now.saturating_duration_since(last) < RECLAIM_TOKEN_SWEEP_INTERVAL)
1060 {
1061 return Ok(None);
1062 }
1063 *last_run = Some(now);
1064 }
1065
1066 let mut removed = 0_usize;
1067 for relative in ROOT_RECLAIM_TOKEN_PATHS {
1068 removed =
1069 removed.saturating_add(remove_stale_reclaim_token(&root.join(relative))? as usize);
1070 }
1071 for domain in RECLAIM_TOKEN_SWEEP_DOMAINS {
1072 removed = removed.saturating_add(sweep_reclaim_token_domain(&root.join(domain))?);
1073 }
1074 removed = removed.saturating_add(sweep_backup_reclaim_tokens(root)?);
1075 Ok(Some(removed))
1076}
1077
1078fn sweep_backup_reclaim_tokens(root: &Path) -> io::Result<usize> {
1079 let harnesses = match fs::read_dir(root) {
1080 Ok(entries) => entries,
1081 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(0),
1082 Err(error) => return Err(error),
1083 };
1084 let mut removed = 0_usize;
1085 for harness in harnesses {
1086 let harness = harness?;
1087 if !harness.file_type()?.is_dir() || !is_backup_harness_dir(&harness.file_name()) {
1088 continue;
1089 }
1090 let sessions = match fs::read_dir(harness.path().join("backups")) {
1091 Ok(entries) => entries,
1092 Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
1093 Err(error) => return Err(error),
1094 };
1095 for session in sessions {
1096 let session = session?;
1097 if !session.file_type()?.is_dir() {
1098 continue;
1099 }
1100 let lock_entries = match fs::read_dir(session.path().join(".locks")) {
1101 Ok(entries) => entries,
1102 Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
1103 Err(error) => return Err(error),
1104 };
1105 for lock_entry in lock_entries {
1106 let lock_entry = lock_entry?;
1107 if lock_entry.file_type()?.is_file() && is_reclaim_token_path(&lock_entry.path()) {
1108 removed = removed
1109 .saturating_add(remove_stale_reclaim_token(&lock_entry.path())? as usize);
1110 }
1111 }
1112 }
1113 }
1114 Ok(removed)
1115}
1116
1117fn is_backup_harness_dir(name: &std::ffi::OsStr) -> bool {
1118 let Some(name) = name.to_str() else {
1119 return false;
1120 };
1121 FIXED_BACKUP_HARNESS_DIRS.contains(&name)
1122 || BACKUP_HARNESS_DIR_PREFIXES
1123 .iter()
1124 .any(|prefix| name.starts_with(prefix))
1125}
1126
1127fn sweep_reclaim_token_domain(domain_root: &Path) -> io::Result<usize> {
1128 let mut directories = vec![(domain_root.to_path_buf(), 0_usize)];
1129 let mut removed = 0_usize;
1130 while let Some((directory, depth)) = directories.pop() {
1131 let entries = match fs::read_dir(&directory) {
1132 Ok(entries) => entries,
1133 Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
1134 Err(error) => return Err(error),
1135 };
1136 for entry in entries {
1137 let entry = entry?;
1138 let file_type = entry.file_type()?;
1139 let path = entry.path();
1140 if file_type.is_dir() && depth < RECLAIM_TOKEN_SWEEP_MAX_DOMAIN_DEPTH {
1141 directories.push((path, depth + 1));
1142 } else if file_type.is_file() && is_reclaim_token_path(&path) {
1143 removed = removed.saturating_add(remove_stale_reclaim_token(&path)? as usize);
1144 }
1145 }
1146 }
1147 Ok(removed)
1148}
1149
1150fn remove_stale_reclaim_token(path: &Path) -> io::Result<bool> {
1151 let removed = match inspect_reclaim_token(path)? {
1152 ExistingReclaimToken::StaleValid(owner) => remove_lock_if_owned(path, &owner)?,
1153 ExistingReclaimToken::StaleMalformed => remove_malformed_reclaim_token(path)?,
1154 ExistingReclaimToken::Held(_) | ExistingReclaimToken::Missing => false,
1155 };
1156 if removed {
1157 sync_parent(path);
1158 }
1159 Ok(removed)
1160}
1161
1162fn is_reclaim_token_path(path: &Path) -> bool {
1163 path.file_name()
1164 .and_then(|name| name.to_str())
1165 .is_some_and(|name| name.starts_with('.') && name.ends_with(".reclaim"))
1166}
1167
1168fn reclaim_token_path(lock_path: &Path) -> PathBuf {
1169 let file_name = lock_path
1170 .file_name()
1171 .and_then(|name| name.to_str())
1172 .unwrap_or("lock");
1173 lock_path.with_file_name(format!(".{file_name}.reclaim"))
1174}
1175
1176fn dead_reclaim_backoff_ms(blocked_attempt: u32, poll_interval_ms: u64) -> u64 {
1177 let base = poll_interval_ms.max(DEAD_RECLAIM_INITIAL_BACKOFF_MS);
1178 base.saturating_mul(
1179 1_u64
1180 .checked_shl(blocked_attempt.min(20))
1181 .unwrap_or(u64::MAX),
1182 )
1183 .min(DEAD_RECLAIM_MAX_BACKOFF_MS)
1184}
1185
1186fn log_reclaim_blocked(path: &Path, holder: &ReclaimTokenHeld) {
1187 let now = Instant::now();
1188 let logs = RECLAIM_BLOCK_LOGS.get_or_init(|| Mutex::new(HashMap::new()));
1189 let Ok(mut logs) = logs.lock() else {
1190 return;
1191 };
1192 let message = match logs.get_mut(path) {
1193 Some(record)
1194 if now.saturating_duration_since(record.last_emitted) < RECLAIM_BLOCK_LOG_INTERVAL =>
1195 {
1196 record.suppressed = record.suppressed.saturating_add(1);
1197 return;
1198 }
1199 Some(record) => {
1200 let suppressed = record.suppressed;
1201 record.last_emitted = now;
1202 record.suppressed = 0;
1203 format_reclaim_blocked(path, holder, suppressed)
1204 }
1205 None => {
1206 logs.insert(
1207 path.to_path_buf(),
1208 ReclaimBlockLogRecord {
1209 last_emitted: now,
1210 suppressed: 0,
1211 },
1212 );
1213 format_reclaim_blocked(path, holder, 0)
1214 }
1215 };
1216 drop(logs);
1217 emit_reclaim_warning(message);
1218}
1219
1220fn format_reclaim_blocked(path: &Path, holder: &ReclaimTokenHeld, suppressed: u64) -> String {
1221 let pid = holder
1222 .pid
1223 .map(|pid| pid.to_string())
1224 .unwrap_or_else(|| "unknown".to_string());
1225 let mut message = format!(
1226 "reclaim of {} blocked: reclaim token held by pid {} ({})",
1227 path.display(),
1228 pid,
1229 holder.state.as_str()
1230 );
1231 if suppressed > 0 {
1232 message.push_str(&format!(" (repeated {suppressed}x in 60s)"));
1233 }
1234 message
1235}
1236
1237fn emit_reclaim_warning(message: String) {
1238 #[cfg(test)]
1239 RECLAIM_TEST_LOGS.with(|logs| logs.borrow_mut().push(message.clone()));
1240 slog_warn!("{}", message);
1241}
1242
1243#[cfg(test)]
1244thread_local! {
1245 static RECLAIM_TEST_LOGS: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
1246
1247 static RETRY_SLEEP_OBSERVER: std::cell::RefCell<Option<Arc<std::sync::atomic::AtomicUsize>>> =
1250 const { std::cell::RefCell::new(None) };
1251}
1252
1253#[cfg(test)]
1254struct RetrySleepObserverGuard {
1255 previous: Option<Arc<std::sync::atomic::AtomicUsize>>,
1256}
1257
1258#[cfg(test)]
1259impl Drop for RetrySleepObserverGuard {
1260 fn drop(&mut self) {
1261 RETRY_SLEEP_OBSERVER.with(|observer| {
1262 *observer.borrow_mut() = self.previous.take();
1263 });
1264 }
1265}
1266
1267#[cfg(test)]
1268fn observe_retry_sleeps_for_test(
1269 observer: Arc<std::sync::atomic::AtomicUsize>,
1270) -> RetrySleepObserverGuard {
1271 let previous = RETRY_SLEEP_OBSERVER.with(|slot| slot.replace(Some(observer)));
1272 RetrySleepObserverGuard { previous }
1273}
1274
1275#[cfg(test)]
1276fn note_retry_sleep_for_test() {
1277 RETRY_SLEEP_OBSERVER.with(|observer| {
1278 if let Some(observer) = observer.borrow().as_ref() {
1279 observer.fetch_add(1, Ordering::SeqCst);
1280 }
1281 });
1282}
1283
1284fn sleep_until_retry(deadline: Option<Instant>, poll_interval_ms: u64) -> Result<(), AcquireError> {
1285 let poll = Duration::from_millis(poll_interval_ms);
1286 let sleep_for = match deadline {
1287 Some(deadline) => {
1288 let now = Instant::now();
1289 if now >= deadline {
1290 return Err(AcquireError::Timeout);
1291 }
1292 poll.min(deadline.saturating_duration_since(now))
1293 }
1294 None => poll,
1295 };
1296 #[cfg(test)]
1297 note_retry_sleep_for_test();
1298 thread::sleep(sleep_for);
1299 Ok(())
1300}
1301
1302pub(crate) fn sync_parent(path: &Path) {
1303 if let Some(parent) = path.parent() {
1304 if let Ok(dir) = File::open(parent) {
1305 let _ = dir.sync_all();
1306 }
1307 }
1308}
1309
1310fn now_ms() -> u64 {
1311 SystemTime::now()
1312 .duration_since(UNIX_EPOCH)
1313 .unwrap_or(Duration::ZERO)
1314 .as_millis() as u64
1315}
1316
1317fn now_nanos() -> u128 {
1318 SystemTime::now()
1319 .duration_since(UNIX_EPOCH)
1320 .unwrap_or(Duration::ZERO)
1321 .as_nanos()
1322}
1323
1324#[cfg(unix)]
1325fn current_hostname() -> String {
1326 let mut buffer = [0u8; 256];
1327 let result = unsafe { libc::gethostname(buffer.as_mut_ptr().cast(), buffer.len()) };
1328 if result == 0 {
1329 let len = buffer
1330 .iter()
1331 .position(|byte| *byte == 0)
1332 .unwrap_or(buffer.len());
1333 if len > 0 {
1334 return String::from_utf8_lossy(&buffer[..len]).into_owned();
1335 }
1336 }
1337
1338 crate::environment::non_empty_var("HOSTNAME").unwrap_or_else(|| "unknown-host".to_string())
1339}
1340
1341#[cfg(windows)]
1342fn current_hostname() -> String {
1343 crate::environment::non_empty_var("COMPUTERNAME")
1344 .or_else(|| crate::environment::non_empty_var("HOSTNAME"))
1345 .unwrap_or_else(|| "unknown-host".to_string())
1346}
1347
1348#[cfg(not(any(unix, windows)))]
1349fn current_hostname() -> String {
1350 crate::environment::non_empty_var("HOSTNAME").unwrap_or_else(|| "unknown-host".to_string())
1351}
1352
1353fn lock_owner_is_alive(metadata: &LockMetadata) -> bool {
1360 if !process_alive(metadata.pid) {
1361 return false;
1362 }
1363
1364 let Some(recorded_start_time) = metadata.process_start_time else {
1365 return true;
1366 };
1367 let Some(current_identity) = process_identity(metadata.pid) else {
1368 return true;
1369 };
1370
1371 if current_identity.start_time != recorded_start_time {
1372 return false;
1373 }
1374
1375 match &metadata.boot_id {
1376 Some(recorded_boot_id) => current_identity
1377 .boot_id
1378 .as_deref()
1379 .map_or(true, |current_boot_id| current_boot_id == recorded_boot_id),
1380 None => true,
1381 }
1382}
1383
1384#[cfg(target_os = "linux")]
1385fn process_identity(pid: u32) -> Option<ProcessIdentity> {
1386 let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
1389 let start_time = stat
1390 .rsplit_once(") ")?
1391 .1
1392 .split_ascii_whitespace()
1393 .nth(19)?
1394 .parse()
1395 .ok()?;
1396 let boot_id = fs::read_to_string("/proc/sys/kernel/random/boot_id")
1397 .ok()?
1398 .trim()
1399 .to_owned();
1400 if boot_id.is_empty() {
1401 return None;
1402 }
1403
1404 Some(ProcessIdentity {
1405 start_time,
1406 boot_id: Some(boot_id),
1407 })
1408}
1409
1410#[cfg(target_os = "macos")]
1411fn process_identity(pid: u32) -> Option<ProcessIdentity> {
1412 if pid == 0 || pid > i32::MAX as u32 {
1413 return None;
1414 }
1415
1416 const PROC_PIDTBSDINFO: libc::c_int = 3;
1419 let mut info = std::mem::MaybeUninit::<libc::proc_bsdinfo>::zeroed();
1420 let size = std::mem::size_of::<libc::proc_bsdinfo>() as libc::c_int;
1421 let written = unsafe {
1422 proc_pidinfo(
1423 pid as libc::c_int,
1424 PROC_PIDTBSDINFO,
1425 0,
1426 info.as_mut_ptr().cast(),
1427 size,
1428 )
1429 };
1430 if written != size {
1431 return None;
1432 }
1433 let info = unsafe { info.assume_init() };
1434 let start_time = info
1435 .pbi_start_tvsec
1436 .checked_mul(1_000_000)?
1437 .checked_add(info.pbi_start_tvusec)?;
1438
1439 Some(ProcessIdentity {
1440 start_time,
1441 boot_id: None,
1442 })
1443}
1444
1445#[cfg(windows)]
1446fn process_identity(_pid: u32) -> Option<ProcessIdentity> {
1447 None
1451}
1452
1453#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
1454fn process_identity(_pid: u32) -> Option<ProcessIdentity> {
1455 None
1456}
1457
1458#[cfg(target_os = "macos")]
1459unsafe extern "C" {
1460 fn proc_pidinfo(
1461 pid: libc::c_int,
1462 flavor: libc::c_int,
1463 arg: u64,
1464 buffer: *mut libc::c_void,
1465 buffersize: libc::c_int,
1466 ) -> libc::c_int;
1467}
1468
1469#[cfg(unix)]
1470pub(crate) fn process_alive(pid: u32) -> bool {
1471 if pid == 0 || pid > i32::MAX as u32 {
1472 return false;
1473 }
1474
1475 let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
1476 if result == 0 {
1477 return true;
1478 }
1479
1480 io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
1481}
1482
1483#[cfg(windows)]
1484pub(crate) fn process_alive(pid: u32) -> bool {
1485 if pid == 0 {
1486 return false;
1487 }
1488 let filter = format!("PID eq {pid}");
1489 let Ok(output) = std::process::Command::new("tasklist")
1490 .args(["/FI", &filter, "/FO", "CSV", "/NH"])
1491 .output()
1492 else {
1493 return true;
1494 };
1495
1496 if !output.status.success() {
1497 return true;
1498 }
1499
1500 let stdout = String::from_utf8_lossy(&output.stdout);
1501
1502 if stdout.contains("No tasks are running") {
1513 return false;
1514 }
1515 stdout.contains(&format!("\"{pid}\""))
1516}
1517
1518#[cfg(not(any(unix, windows)))]
1519pub(crate) fn process_alive(_pid: u32) -> bool {
1520 true
1521}
1522
1523#[cfg(test)]
1524mod tests {
1525 use super::*;
1526 use std::sync::atomic::{AtomicUsize, Ordering};
1527 use std::sync::{mpsc, Arc, Barrier};
1528
1529 fn test_config() -> LockConfig {
1530 LockConfig {
1531 heartbeat_interval_ms: 25,
1532 stale_heartbeat_ms: 2_000,
1533 live_owner_warn_ms: LIVE_OWNER_WARN_MS,
1534 poll_interval_ms: 10,
1535 }
1536 }
1537
1538 fn test_lock_path() -> (tempfile::TempDir, PathBuf) {
1539 let dir = tempfile::tempdir().expect("create temp dir");
1540 let path = dir.path().join("test.lock");
1541 (dir, path)
1542 }
1543
1544 fn write_synthetic_lock(path: &Path, metadata: &LockMetadata) {
1545 let mut file = open_new_lock_file(path).expect("create synthetic lock");
1546 write_lock_metadata_to_file(&mut file, metadata).expect("write synthetic lock");
1547 }
1548
1549 #[derive(Serialize)]
1550 struct LegacyLockMetadata<'a> {
1551 pid: u32,
1552 hostname: &'a str,
1553 created_at_ms: u64,
1554 heartbeat_at_ms: u64,
1555 writer_epoch: &'a str,
1556 }
1557
1558 fn write_legacy_lock(path: &Path, metadata: &LockMetadata) -> String {
1559 let legacy = LegacyLockMetadata {
1560 pid: metadata.pid,
1561 hostname: &metadata.hostname,
1562 created_at_ms: metadata.created_at_ms,
1563 heartbeat_at_ms: metadata.heartbeat_at_ms,
1564 writer_epoch: &metadata.writer_epoch,
1565 };
1566 let contents = format!(
1567 "{}\n",
1568 serde_json::to_string(&legacy).expect("serialize legacy lock")
1569 );
1570 fs::write(path, &contents).expect("write legacy synthetic lock");
1571 contents
1572 }
1573
1574 fn synthetic_metadata(pid: u32, hostname: String, created_at_ms: u64) -> LockMetadata {
1575 LockMetadata {
1576 pid,
1577 hostname,
1578 process_start_time: None,
1581 boot_id: None,
1582 created_at_ms,
1583 heartbeat_at_ms: created_at_ms,
1584 writer_epoch: format!("synthetic-{pid}-{created_at_ms}"),
1585 }
1586 }
1587
1588 fn current_process_metadata() -> LockMetadata {
1589 let now = now_ms();
1590 let pid = std::process::id();
1591 let process_identity = process_identity(pid);
1592 let mut metadata = synthetic_metadata(pid, current_hostname(), now);
1593 metadata.process_start_time = process_identity
1594 .as_ref()
1595 .map(|identity| identity.start_time);
1596 metadata.boot_id = process_identity.and_then(|identity| identity.boot_id);
1597 metadata
1598 }
1599
1600 fn different_start_time(start_time: u64) -> u64 {
1601 start_time.checked_add(1).unwrap_or(start_time - 1)
1602 }
1603
1604 fn write_reclaim_token(lock_path: &Path, metadata: &LockMetadata) -> PathBuf {
1605 let token_path = reclaim_token_path(lock_path);
1606 write_synthetic_lock(&token_path, metadata);
1607 token_path
1608 }
1609
1610 fn take_reclaim_test_logs() -> Vec<String> {
1611 RECLAIM_TEST_LOGS.with(|logs| std::mem::take(&mut *logs.borrow_mut()))
1612 }
1613
1614 #[test]
1615 fn lock_operation_trace_lines_are_debug_not_info() {
1616 let source = include_str!("fs_lock.rs");
1617 assert!(source.contains("slog_debug!(\"acquired filesystem lock at {}\", path.display())"));
1618 assert!(
1619 source.contains("slog_debug!(\"released filesystem lock at {}\", self.path.display())")
1620 );
1621 assert!(!source.contains("slog_info!(\"acquired filesystem lock at {}\", path.display())"));
1622 assert!(
1623 !source.contains("slog_info!(\"released filesystem lock at {}\", self.path.display())")
1624 );
1625 }
1626
1627 #[test]
1628 fn acquire_creates_lockfile_and_unlocks_on_drop() {
1629 let (_dir, path) = test_lock_path();
1630
1631 let guard = acquire_with_config(&path, None, test_config()).expect("acquire lock");
1632 let metadata = read_lock_metadata(&path).expect("read lock metadata");
1633 assert_eq!(metadata.pid, std::process::id());
1634 assert_eq!(metadata.hostname, current_hostname());
1635 assert_eq!(metadata.created_at_ms, guard.metadata.created_at_ms);
1636 assert_eq!(metadata.writer_epoch, guard.metadata.writer_epoch);
1637 #[cfg(unix)]
1638 {
1639 use std::os::unix::fs::PermissionsExt;
1640 assert_eq!(
1641 fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1642 0o600
1643 );
1644 }
1645
1646 drop(guard);
1647 assert!(!path.exists());
1648 }
1649
1650 #[test]
1651 fn permission_denied_is_treated_as_transient_create_contention() {
1652 let err = io::Error::from(io::ErrorKind::PermissionDenied);
1655 assert!(is_transient_create_contention(&err));
1656 }
1657
1658 #[test]
1659 fn unrelated_io_errors_are_not_treated_as_contention() {
1660 let err = io::Error::from(io::ErrorKind::NotFound);
1663 assert!(!is_transient_create_contention(&err));
1664 }
1665
1666 #[cfg(windows)]
1667 #[test]
1668 fn windows_sharing_violation_is_treated_as_transient_create_contention() {
1669 let err = io::Error::from_raw_os_error(32);
1672 assert!(is_transient_create_contention(&err));
1673 }
1674
1675 #[test]
1676 fn reclaim_refuses_to_delete_a_different_owners_lock() {
1677 let (_dir, path) = test_lock_path();
1678
1679 let owner_b = synthetic_metadata(4242, "host-b".to_string(), now_ms());
1681 create_lock_file_atomically(&path, &owner_b).expect("write owner B lock");
1682
1683 let judged_a = synthetic_metadata(1111, "host-a".to_string(), now_ms() - 1_000_000);
1686 let outcome = reclaim_lock_file(&path, &judged_a).expect("reclaim");
1687 assert!(
1688 matches!(outcome, ReclaimResult::Unchanged),
1689 "must not remove a different owner's lock"
1690 );
1691 assert!(path.exists(), "owner B's lock must survive");
1692 let still = read_lock_metadata(&path).expect("still readable");
1693 assert_eq!(still.pid, 4242, "owner B's lock intact");
1694 }
1695
1696 #[test]
1697 fn reclaim_deletes_when_identity_still_matches() {
1698 let (_dir, path) = test_lock_path();
1699 let owner = synthetic_metadata(1111, "host-a".to_string(), 5_000);
1700 create_lock_file_atomically(&path, &owner).expect("write lock");
1701
1702 let outcome = reclaim_lock_file(&path, &owner).expect("reclaim");
1704 assert!(
1705 matches!(outcome, ReclaimResult::Removed),
1706 "matching-identity stale lock should be removed"
1707 );
1708 assert!(!path.exists());
1709
1710 assert!(matches!(
1712 reclaim_lock_file(&path, &owner).expect("reclaim missing"),
1713 ReclaimResult::Unchanged
1714 ));
1715 }
1716
1717 #[test]
1718 fn try_acquire_once_never_waits_behind_live_owner() {
1719 const OUTER_THREAD_JOIN_TIMEOUT: Duration = Duration::from_secs(30);
1720
1721 let (_dir, path) = test_lock_path();
1722 let guard = acquire_with_config(&path, None, test_config()).expect("acquire lock");
1723 let contender_path = path.clone();
1724 let sleeper_entries = Arc::new(AtomicUsize::new(0));
1725 let contender_sleeper_entries = Arc::clone(&sleeper_entries);
1726 let (announced_tx, announced_rx) = mpsc::sync_channel(1);
1727 let (enter_tx, enter_rx) = mpsc::sync_channel::<()>(1);
1728 let (result_tx, result_rx) = mpsc::sync_channel(1);
1729 let contender = std::thread::spawn(move || {
1730 let _ = announced_tx.send(());
1731 let _ = enter_rx.recv();
1732 let _observer = observe_retry_sleeps_for_test(contender_sleeper_entries);
1733 let _ = result_tx.send(try_acquire_once(&contender_path));
1734 });
1735
1736 announced_rx
1737 .recv_timeout(OUTER_THREAD_JOIN_TIMEOUT)
1738 .expect("contender should announce before the controlled enter gate");
1739 enter_tx
1742 .send(())
1743 .expect("contender should wait at the controlled enter gate");
1744
1745 let result = match result_rx.recv_timeout(OUTER_THREAD_JOIN_TIMEOUT) {
1746 Ok(result) => result,
1747 Err(error) => {
1748 drop(guard);
1749 let _ = result_rx.recv_timeout(OUTER_THREAD_JOIN_TIMEOUT);
1750 let _ = contender.join();
1751 panic!("contender did not finish before the outer join bound: {error}");
1752 }
1753 };
1754
1755 contender.join().expect("contender should exit");
1756 assert!(matches!(result, Err(AcquireError::Timeout)));
1757 assert_eq!(
1758 sleeper_entries.load(Ordering::SeqCst),
1759 0,
1760 "zero-timeout acquisition must return Timeout without sleeping"
1761 );
1762 }
1763
1764 #[test]
1765 fn acquire_serializes_concurrent_callers() {
1766 let (_dir, path) = test_lock_path();
1767 let path = Arc::new(path);
1768 let barrier = Arc::new(Barrier::new(3));
1769 let inside = Arc::new(AtomicUsize::new(0));
1770 let entered = Arc::new(AtomicUsize::new(0));
1771 let max_inside = Arc::new(AtomicUsize::new(0));
1772
1773 let mut handles = Vec::new();
1774 for _ in 0..2 {
1775 let path = Arc::clone(&path);
1776 let barrier = Arc::clone(&barrier);
1777 let inside = Arc::clone(&inside);
1778 let entered = Arc::clone(&entered);
1779 let max_inside = Arc::clone(&max_inside);
1780 handles.push(thread::spawn(move || {
1781 barrier.wait();
1782 let guard = acquire_with_config(&path, Some(Duration::from_secs(2)), test_config())
1783 .expect("thread acquire lock");
1784 let previous = inside.fetch_add(1, Ordering::SeqCst);
1785 assert_eq!(previous, 0, "two lock holders overlapped");
1786 entered.fetch_add(1, Ordering::SeqCst);
1787 max_inside.fetch_max(previous + 1, Ordering::SeqCst);
1788 thread::sleep(Duration::from_millis(75));
1789 inside.fetch_sub(1, Ordering::SeqCst);
1790 drop(guard);
1791 }));
1792 }
1793
1794 barrier.wait();
1795 for handle in handles {
1796 handle.join().expect("join worker");
1797 }
1798
1799 assert_eq!(entered.load(Ordering::SeqCst), 2);
1800 assert_eq!(max_inside.load(Ordering::SeqCst), 1);
1801 assert!(!path.exists());
1802 }
1803
1804 #[test]
1805 fn failed_atomic_replacement_preserves_existing_destination() {
1806 let dir = tempfile::tempdir().expect("create temp dir");
1807 let source = dir.path().join("source.tmp");
1808 let destination = dir.path().join("artifact.bin");
1809 fs::write(&source, b"new artifact").expect("write source");
1810 fs::write(&destination, b"valid old artifact").expect("write destination");
1811
1812 let error = rename_over_with(&source, &destination, |_from, _to| {
1813 Err(io::Error::new(
1814 io::ErrorKind::PermissionDenied,
1815 "injected replacement failure",
1816 ))
1817 })
1818 .expect_err("replacement must fail");
1819
1820 assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
1821 assert_eq!(
1822 fs::read(&destination).expect("read preserved destination"),
1823 b"valid old artifact"
1824 );
1825 assert_eq!(
1826 fs::read(&source).expect("read retained source"),
1827 b"new artifact"
1828 );
1829 }
1830
1831 #[test]
1832 fn heartbeat_updates_lockfile_timestamp() {
1833 let (_dir, path) = test_lock_path();
1834 let guard = acquire_with_config(&path, None, test_config()).expect("acquire lock");
1835 let initial_metadata = read_lock_metadata(&path).expect("read initial metadata");
1836 let initial = initial_metadata.heartbeat_at_ms;
1837
1838 let deadline = std::time::Instant::now() + Duration::from_millis(2_000);
1848 let mut updated = initial;
1849 while std::time::Instant::now() < deadline {
1850 thread::sleep(Duration::from_millis(50));
1851 match read_lock_metadata(&path) {
1852 Ok(meta) => {
1853 updated = meta.heartbeat_at_ms;
1854 if updated > initial {
1855 break;
1856 }
1857 }
1858 Err(ReadLockError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
1859 continue;
1862 }
1863 Err(other) => panic!("read updated metadata: {other:?}"),
1864 }
1865 }
1866 assert!(
1867 updated > initial,
1868 "heartbeat timestamp did not advance within 2s"
1869 );
1870 let updated_metadata = read_lock_metadata(&path).expect("read final metadata");
1871 assert_eq!(
1872 updated_metadata.process_start_time, guard.metadata.process_start_time,
1873 "heartbeat rewrite must preserve the owner's process start time"
1874 );
1875 assert_eq!(
1876 updated_metadata.boot_id, guard.metadata.boot_id,
1877 "heartbeat rewrite must preserve the owner's boot identity"
1878 );
1879 drop(guard);
1880 }
1881
1882 #[test]
1883 fn dead_pid_lock_is_reclaimed() {
1884 let (_dir, path) = test_lock_path();
1885 let metadata = synthetic_metadata(999_999_999, current_hostname(), now_ms());
1886 write_synthetic_lock(&path, &metadata);
1887
1888 let guard = acquire_with_config(&path, Some(Duration::from_secs(1)), test_config())
1889 .expect("reclaim dead pid lock");
1890 let metadata = read_lock_metadata(&path).expect("read reclaimed lock");
1891 assert_eq!(metadata.pid, std::process::id());
1892 drop(guard);
1893 }
1894
1895 #[test]
1896 fn zero_timeout_dead_pid_reclaim_acquires_after_removing_stale_file() {
1897 let (_dir, path) = test_lock_path();
1898 let metadata = synthetic_metadata(999_999_999, current_hostname(), now_ms());
1899 write_synthetic_lock(&path, &metadata);
1900
1901 let guard = acquire_with_config(&path, Some(Duration::ZERO), test_config())
1902 .expect("zero-timeout acquire should claim the reaped stale lock");
1903 let metadata = read_lock_metadata(&path).expect("read reclaimed lock");
1904 assert_eq!(metadata.pid, std::process::id());
1905 drop(guard);
1906 }
1907
1908 #[test]
1909 fn dead_same_host_reclaim_token_is_reaped_with_stale_lock() {
1910 let (_dir, path) = test_lock_path();
1911 let stale = synthetic_metadata(999_999_999, current_hostname(), now_ms());
1912 write_synthetic_lock(&path, &stale);
1913 let token_path = write_reclaim_token(&path, &stale);
1914
1915 let guard = acquire_with_config(&path, Some(Duration::from_secs(1)), test_config())
1916 .expect("dead token must not wedge stale-lock reclamation");
1917 assert!(!token_path.exists(), "stale reclaim token must be removed");
1918 drop(guard);
1919 assert!(!path.exists(), "acquired lock must be released normally");
1920 }
1921
1922 #[test]
1923 fn live_reclaim_token_blocks_and_is_untouched() {
1924 let (_dir, path) = test_lock_path();
1925 let stale = synthetic_metadata(999_999_999, current_hostname(), now_ms());
1926 write_synthetic_lock(&path, &stale);
1927 let live = current_process_metadata();
1928 let token_path = write_reclaim_token(&path, &live);
1929
1930 let result = acquire_with_config(&path, Some(Duration::ZERO), test_config());
1931 assert!(matches!(result, Err(AcquireError::Timeout)));
1932 assert_eq!(read_lock_metadata(&token_path).expect("live token"), live);
1933 }
1934
1935 #[test]
1936 fn foreign_host_reclaim_token_is_authoritative() {
1937 let (_dir, path) = test_lock_path();
1938 let stale = synthetic_metadata(999_999_999, current_hostname(), now_ms());
1939 write_synthetic_lock(&path, &stale);
1940 let mut foreign = stale.clone();
1941 foreign.hostname = format!("{}-foreign", current_hostname());
1942 let token_path = write_reclaim_token(&path, &foreign);
1943
1944 let result = acquire_with_config(&path, Some(Duration::ZERO), test_config());
1945 assert!(matches!(result, Err(AcquireError::Timeout)));
1946 assert_eq!(
1947 read_lock_metadata(&token_path).expect("foreign token"),
1948 foreign
1949 );
1950 }
1951
1952 #[test]
1953 fn malformed_reclaim_token_is_held_until_older_than_sixty_seconds() {
1954 use filetime::{set_file_mtime, FileTime};
1955
1956 let (_dir, path) = test_lock_path();
1957 let stale = synthetic_metadata(999_999_999, current_hostname(), now_ms());
1958 write_synthetic_lock(&path, &stale);
1959 let token_path = reclaim_token_path(&path);
1960 fs::write(&token_path, b"{ malformed").expect("write malformed token");
1961
1962 let young_result = acquire_with_config(&path, Some(Duration::ZERO), test_config());
1963 assert!(matches!(young_result, Err(AcquireError::Timeout)));
1964 assert_eq!(fs::read(&token_path).expect("young token"), b"{ malformed");
1965
1966 let old = SystemTime::now()
1967 .checked_sub(RECLAIM_TOKEN_MALFORMED_STALE_AGE + Duration::from_secs(1))
1968 .expect("old timestamp");
1969 set_file_mtime(&token_path, FileTime::from_system_time(old)).expect("age token");
1970 let guard = acquire_with_config(&path, Some(Duration::from_secs(1)), test_config())
1971 .expect("old malformed token should be reclaimed");
1972 assert!(!token_path.exists());
1973 drop(guard);
1974 }
1975
1976 #[test]
1977 fn held_reclaim_token_logs_blocked_without_claiming_lock_removal() {
1978 let (_dir, path) = test_lock_path();
1979 let stale = synthetic_metadata(999_999_999, current_hostname(), now_ms());
1980 write_synthetic_lock(&path, &stale);
1981 let live = current_process_metadata();
1982 write_reclaim_token(&path, &live);
1983 take_reclaim_test_logs();
1984
1985 let result = acquire_with_config(&path, Some(Duration::ZERO), test_config());
1986 assert!(matches!(result, Err(AcquireError::Timeout)));
1987 let logs = take_reclaim_test_logs();
1988 assert!(logs.iter().any(|line| {
1989 line.contains(&format!("reclaim of {} blocked", path.display()))
1990 && line.contains(&format!("pid {} (alive)", live.pid))
1991 }));
1992 assert!(!logs
1993 .iter()
1994 .any(|line| line.contains("removing filesystem lock")));
1995 }
1996
1997 #[test]
1998 fn dead_unreclaimable_lock_retry_backoff_stays_bounded() {
1999 let mut elapsed_ms = 0_u64;
2000 let mut attempts = 0_u32;
2001 while elapsed_ms < 60_000 {
2002 elapsed_ms = elapsed_ms.saturating_add(dead_reclaim_backoff_ms(attempts, 100));
2003 attempts += 1;
2004 }
2005
2006 assert!(
2007 attempts <= 17,
2008 "{attempts} retries exceed the one-minute bound"
2009 );
2010 assert_eq!(dead_reclaim_backoff_ms(0, 100), 250);
2011 assert_eq!(dead_reclaim_backoff_ms(20, 100), 5_000);
2012 }
2013
2014 #[test]
2015 fn maintenance_sweep_is_bounded_to_known_lock_domains() {
2016 let root = tempfile::tempdir().expect("temporary storage root");
2017 let cache_dir = root.path().join("index").join("project");
2018 fs::create_dir_all(&cache_dir).expect("create nested cache");
2019 let dead = synthetic_metadata(999_999_999, current_hostname(), now_ms());
2020 let dead_token = write_reclaim_token(&cache_dir.join("cache.lock"), &dead);
2021 let live = current_process_metadata();
2022 let live_token = write_reclaim_token(&cache_dir.join("live.lock"), &live);
2023 let backup_session = root.path().join("opencode/backups/session");
2024 let backup_entry = backup_session.join("path_hash");
2025 let backup_locks = backup_session.join(".locks");
2026 fs::create_dir_all(&backup_entry).expect("create backup entry tree");
2027 fs::create_dir_all(&backup_locks).expect("create backup lock directory");
2028 let unrelated_token = write_reclaim_token(&backup_entry.join("foo.lock"), &dead);
2029 let backup_token = write_reclaim_token(&backup_locks.join("x.lock"), &dead);
2030 let cadence = Mutex::new(None);
2031
2032 let removed = sweep_stale_reclaim_tokens_at(root.path(), Instant::now(), &cadence)
2033 .expect("sweep tokens");
2034
2035 assert_eq!(removed, Some(2));
2036 assert!(!dead_token.exists());
2037 assert!(
2038 !backup_token.exists(),
2039 "BackupStore lock tokens must be swept"
2040 );
2041 assert_eq!(read_lock_metadata(&live_token).expect("live token"), live);
2042 assert!(
2043 unrelated_token.exists(),
2044 "maintenance must not descend into backup entry trees"
2045 );
2046 }
2047
2048 #[test]
2049 fn maintenance_sweep_runs_first_then_obeys_daily_cadence() {
2050 let root = tempfile::tempdir().expect("temporary storage root");
2051 let cache_dir = root.path().join("index").join("project");
2052 fs::create_dir_all(&cache_dir).expect("create index cache");
2053 let dead = synthetic_metadata(999_999_999, current_hostname(), now_ms());
2054 let first_token = write_reclaim_token(&cache_dir.join("cache.lock"), &dead);
2055 let cadence = Mutex::new(None);
2056 let start = Instant::now();
2057
2058 assert_eq!(
2059 sweep_stale_reclaim_tokens_at(root.path(), start, &cadence).expect("first sweep"),
2060 Some(1)
2061 );
2062 assert!(!first_token.exists());
2063
2064 let second_token = write_reclaim_token(&cache_dir.join("cache.lock"), &dead);
2065 assert_eq!(
2066 sweep_stale_reclaim_tokens_at(root.path(), start + Duration::from_secs(60), &cadence,)
2067 .expect("suppressed sweep"),
2068 None
2069 );
2070 assert!(second_token.exists());
2071 assert_eq!(
2072 sweep_stale_reclaim_tokens_at(
2073 root.path(),
2074 start + RECLAIM_TOKEN_SWEEP_INTERVAL + Duration::from_secs(1),
2075 &cadence,
2076 )
2077 .expect("next-day sweep"),
2078 Some(1)
2079 );
2080 assert!(!second_token.exists());
2081 }
2082
2083 #[test]
2084 fn stale_heartbeat_from_live_pid_blocks() {
2085 let (_dir, path) = test_lock_path();
2086 let mut metadata = current_process_metadata();
2087 #[cfg(any(target_os = "linux", target_os = "macos"))]
2088 {
2089 let identity = process_identity(std::process::id())
2090 .expect("current process should have a start-time identity");
2091 assert_eq!(metadata.process_start_time, Some(identity.start_time));
2092 assert_eq!(metadata.boot_id, identity.boot_id);
2093 }
2094 metadata.created_at_ms = now_ms().saturating_sub(60_000);
2095 metadata.heartbeat_at_ms = now_ms().saturating_sub(60_000);
2096 write_synthetic_lock(&path, &metadata);
2097
2098 let result = acquire_with_config(&path, Some(Duration::from_millis(80)), test_config());
2099 assert!(matches!(result, Err(AcquireError::Timeout)));
2100 assert_eq!(read_lock_metadata(&path).expect("read lock"), metadata);
2101
2102 remove_lock_file(&path).expect("cleanup synthetic lock");
2103 }
2104
2105 #[cfg(any(target_os = "linux", target_os = "macos"))]
2106 #[test]
2107 fn live_pid_with_wrong_start_time_is_reclaimed() {
2108 let (_dir, path) = test_lock_path();
2109 let mut metadata = current_process_metadata();
2110 let current_identity = process_identity(std::process::id())
2111 .expect("current process should have a start-time identity");
2112 metadata.process_start_time = Some(different_start_time(current_identity.start_time));
2113 metadata.boot_id = current_identity.boot_id;
2114 write_synthetic_lock(&path, &metadata);
2115
2116 let guard = acquire_with_config(&path, Some(Duration::ZERO), test_config())
2117 .expect("zero-timeout acquire should reclaim a reused PID");
2118 assert_eq!(guard.metadata.pid, std::process::id());
2119 assert_eq!(
2120 guard.metadata.process_start_time,
2121 Some(current_identity.start_time)
2122 );
2123 drop(guard);
2124 }
2125
2126 #[cfg(target_os = "linux")]
2127 #[test]
2128 fn live_pid_with_wrong_boot_id_is_reclaimed() {
2129 let (_dir, path) = test_lock_path();
2130 let mut metadata = current_process_metadata();
2131 let current_identity = process_identity(std::process::id())
2132 .expect("current process should have a start-time identity");
2133 metadata.boot_id = Some(format!("wrong-{}", current_identity.boot_id.unwrap()));
2134 write_synthetic_lock(&path, &metadata);
2135
2136 let guard = acquire_with_config(&path, Some(Duration::ZERO), test_config())
2137 .expect("zero-timeout acquire should reclaim a rebooted PID identity");
2138 assert_eq!(guard.metadata.pid, std::process::id());
2139 drop(guard);
2140 }
2141
2142 #[test]
2143 fn legacy_live_pid_lock_keeps_pid_only_liveness() {
2144 let (_dir, path) = test_lock_path();
2145 let stale_at = now_ms().saturating_sub(60_000);
2146 let mut metadata = synthetic_metadata(std::process::id(), current_hostname(), stale_at);
2147 metadata.heartbeat_at_ms = stale_at;
2148 let original = write_legacy_lock(&path, &metadata);
2149 assert!(!original.contains("process_start_time"));
2150 assert!(!original.contains("boot_id"));
2151
2152 let result = acquire_with_config(&path, Some(Duration::from_millis(80)), test_config());
2153 assert!(matches!(result, Err(AcquireError::Timeout)));
2154 assert_eq!(
2155 fs::read_to_string(&path).expect("read legacy lock"),
2156 original
2157 );
2158
2159 remove_lock_file(&path).expect("cleanup legacy lock");
2160 }
2161
2162 #[test]
2163 fn legacy_dead_pid_lock_is_reclaimed() {
2164 let (_dir, path) = test_lock_path();
2165 let metadata = synthetic_metadata(999_999_999, current_hostname(), now_ms());
2166 let original = write_legacy_lock(&path, &metadata);
2167 assert!(!original.contains("process_start_time"));
2168 assert!(!original.contains("boot_id"));
2169
2170 let guard = acquire_with_config(&path, Some(Duration::ZERO), test_config())
2171 .expect("zero-timeout acquire should reclaim a legacy dead PID lock");
2172 assert_eq!(guard.metadata.pid, std::process::id());
2173 drop(guard);
2174 }
2175
2176 #[test]
2177 fn healthy_live_owner_blocks() {
2178 let (_dir, path) = test_lock_path();
2179 let metadata = current_process_metadata();
2180 write_synthetic_lock(&path, &metadata);
2181
2182 let result = acquire_with_config(&path, Some(Duration::from_millis(80)), test_config());
2183 assert!(matches!(result, Err(AcquireError::Timeout)));
2184
2185 remove_lock_file(&path).expect("cleanup synthetic lock");
2186 }
2187
2188 #[test]
2189 fn malformed_lockfile_is_reclaimed() {
2190 let (_dir, path) = test_lock_path();
2191 fs::write(&path, b"not valid json").expect("write malformed lock");
2192
2193 let guard = acquire_with_config(&path, Some(Duration::from_secs(1)), test_config())
2194 .expect("reclaim malformed lock");
2195 let metadata = read_lock_metadata(&path).expect("read reclaimed lock");
2196 assert_eq!(metadata.pid, std::process::id());
2197 drop(guard);
2198 }
2199
2200 #[test]
2201 fn cross_host_lock_is_not_stolen_before_extended_stale_threshold() {
2202 let (_dir, path) = test_lock_path();
2203 let now = now_ms();
2204 let mut metadata = current_process_metadata();
2205 metadata.hostname = format!("{}-other", current_hostname());
2206 metadata.process_start_time = metadata.process_start_time.map(different_start_time);
2207 metadata.created_at_ms = now;
2208 metadata.heartbeat_at_ms = now;
2209 metadata.writer_epoch = format!("cross-host-{now}");
2210 #[cfg(any(target_os = "linux", target_os = "macos"))]
2211 assert_ne!(
2212 metadata.process_start_time,
2213 process_identity(std::process::id()).map(|identity| identity.start_time)
2214 );
2215 write_synthetic_lock(&path, &metadata);
2216
2217 let result = acquire_with_config(&path, Some(Duration::from_millis(80)), test_config());
2218 assert!(matches!(result, Err(AcquireError::Timeout)));
2219 assert_eq!(read_lock_metadata(&path).expect("read lock"), metadata);
2220
2221 remove_lock_file(&path).expect("cleanup synthetic lock");
2222 }
2223
2224 #[test]
2225 fn stale_cross_host_lock_is_reclaimed_after_extended_threshold() {
2226 let (_dir, path) = test_lock_path();
2227 let stale_at =
2228 now_ms().saturating_sub(test_config().cross_host_stale_heartbeat_ms() + 1_000);
2229 let mut metadata = current_process_metadata();
2230 metadata.hostname = format!("{}-other", current_hostname());
2231 metadata.process_start_time = metadata.process_start_time.map(different_start_time);
2232 metadata.created_at_ms = stale_at;
2233 metadata.heartbeat_at_ms = stale_at;
2234 metadata.writer_epoch = format!("cross-host-{stale_at}");
2235 write_synthetic_lock(&path, &metadata);
2236
2237 let guard = acquire_with_config(&path, Some(Duration::from_secs(1)), test_config())
2238 .expect("reclaim stale cross-host lock");
2239 let reclaimed = read_lock_metadata(&path).expect("read reclaimed lock");
2240 assert_eq!(reclaimed.hostname, current_hostname());
2241 assert_ne!(reclaimed.created_at_ms, metadata.created_at_ms);
2242 drop(guard);
2243 }
2244
2245 #[test]
2246 fn live_owner_over_10min_warns_but_blocks() {
2247 let (_dir, path) = test_lock_path();
2248 let mut metadata = current_process_metadata();
2249 metadata.created_at_ms = now_ms().saturating_sub(11 * 60 * 1_000);
2250 metadata.heartbeat_at_ms = now_ms();
2251 write_synthetic_lock(&path, &metadata);
2252
2253 let result = acquire_with_config(&path, Some(Duration::from_millis(80)), test_config());
2254 assert!(matches!(result, Err(AcquireError::Timeout)));
2255 assert_eq!(read_lock_metadata(&path).expect("read lock"), metadata);
2256
2257 remove_lock_file(&path).expect("cleanup synthetic lock");
2258 }
2259
2260 #[test]
2261 fn drop_stops_heartbeat_thread() {
2262 let (_dir, path) = test_lock_path();
2263 let guard = acquire_with_config(&path, None, test_config()).expect("acquire lock");
2264 drop(guard);
2265
2266 thread::sleep(Duration::from_millis(
2267 test_config().heartbeat_interval_ms * 3,
2268 ));
2269 assert!(
2270 !path.exists(),
2271 "heartbeat recreated or kept updating lockfile"
2272 );
2273 }
2274
2275 #[test]
2276 fn heartbeat_error_classification_terminal_vs_transient() {
2277 assert!(heartbeat_error_is_terminal(&HeartbeatError::LockGone));
2279 assert!(heartbeat_error_is_terminal(&HeartbeatError::NotOwner));
2280 assert!(!heartbeat_error_is_terminal(&HeartbeatError::Io(
2283 io::Error::other("disk blip")
2284 )));
2285 let malformed: serde_json::Error =
2286 serde_json::from_str::<LockMetadata>("not json").unwrap_err();
2287 assert!(!heartbeat_error_is_terminal(&HeartbeatError::Malformed(
2288 malformed
2289 )));
2290 }
2291
2292 #[test]
2293 fn heartbeat_survives_transient_malformed_and_recovers() {
2294 let (_dir, path) = test_lock_path();
2302 let guard = acquire_with_config(&path, None, test_config()).expect("acquire lock");
2303 let owner = guard.metadata.clone();
2304
2305 fs::write(&path, b"{ not valid json").expect("corrupt lockfile");
2310
2311 thread::sleep(Duration::from_millis(
2314 test_config().heartbeat_interval_ms * 4,
2315 ));
2316
2317 let sentinel = now_ms().saturating_sub(1_000_000);
2328 let mut restored = owner.clone();
2329 restored.heartbeat_at_ms = sentinel;
2330 atomic_write_lock_metadata(&path, &restored).expect("atomically restore lock metadata");
2331
2332 let deadline = std::time::Instant::now() + Duration::from_millis(3_000);
2335 let mut recovered = false;
2336 while std::time::Instant::now() < deadline {
2337 thread::sleep(Duration::from_millis(25));
2338 match read_lock_metadata(&path) {
2339 Ok(meta)
2340 if meta.created_at_ms == owner.created_at_ms
2341 && meta.heartbeat_at_ms > sentinel =>
2342 {
2343 recovered = true;
2344 break;
2345 }
2346 _ => continue,
2347 }
2348 }
2349 assert!(
2350 recovered,
2351 "heartbeat did not recover after a transient malformed read — thread likely died"
2352 );
2353 drop(guard);
2354 }
2355}