Skip to main content

aft/
fs_lock.rs

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
22/// Max consecutive transient OS errors tolerated while creating the lock file
23/// before giving up. On Windows, two processes/threads racing to create (or one
24/// creating while another deletes) the same path can momentarily return
25/// ERROR_ACCESS_DENIED (5) or ERROR_SHARING_VIOLATION (32) instead of a clean
26/// "already exists". Those windows close in milliseconds, so a small bounded
27/// retry rides them out while a genuinely persistent permission/IO failure still
28/// surfaces promptly.
29const 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
36// Lock files in these storage domains have the fixed layout
37// `<storage>/<domain>/<key>/<lock>`. Add a domain here when a new persistent
38// artifact lock is introduced. BackupStore's deeper `.locks` shape is handled
39// separately so maintenance never walks backup entry histories.
40const RECLAIM_TOKEN_SWEEP_DOMAINS: &[&str] = &[
41    "index",       // Search-index cache locks.
42    "callgraph",   // Callgraph root-cache writer leases.
43    "inspect",     // Inspect root-cache writer leases.
44    "semantic",    // Semantic-index cache locks.
45    "symbols",     // On-disk symbol-cache locks.
46    "checkpoints", // Checkpoint-store mutation locks.
47    ".aft",        // Storage-migration locks.
48];
49const RECLAIM_TOKEN_SWEEP_MAX_DOMAIN_DEPTH: usize = 2;
50// compress::trust owns this one lock directly under the storage root.
51const ROOT_RECLAIM_TOKEN_PATHS: &[&str] = &[".trusted-filter-projects.json.lock.reclaim"];
52// BackupStore scopes locks below each harness and session. Prefixes cover the
53// client-specific MCP and fingerprint-specific federated harness directories.
54const 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
99/// True for OS errors that mean "another actor is touching this exact lock path
100/// right now", as opposed to a real, persistent failure. On Windows a contended
101/// create/delete on the same file surfaces as ERROR_ACCESS_DENIED (5) or
102/// ERROR_SHARING_VIOLATION (32); `PermissionDenied` covers the former across
103/// platforms. These are retried as contention, never treated as fatal.
104fn is_transient_create_contention(error: &io::Error) -> bool {
105    if error.kind() == io::ErrorKind::PermissionDenied {
106        return true;
107    }
108    #[cfg(windows)]
109    {
110        // ERROR_SHARING_VIOLATION = 32. ERROR_ACCESS_DENIED = 5 maps to
111        // PermissionDenied above, but match it explicitly too in case the OS
112        // surfaces it as an Other-kind raw error.
113        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    /// Identifies this PID incarnation so a process in a restarted PID namespace
158    /// cannot be mistaken for an owner that was hard-killed in an earlier launch.
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    process_start_time: Option<u64>,
161    /// Linux start times are relative to boot. Keeping the boot ID alongside the
162    /// raw jiffies prevents a reboot from making a newly recycled PID look like
163    /// an earlier owner whose start time happened to match.
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    boot_id: Option<String>,
166    created_at_ms: u64,
167    heartbeat_at_ms: u64,
168    /// Fencing nonce for writer leases. The owner re-reads the lock immediately
169    /// before publishing/writing and aborts if a stale guard has been usurped.
170    #[serde(default)]
171    writer_epoch: String,
172}
173
174/// Acquire a filesystem lock at `path`. Blocks until the lock is held.
175///
176/// The returned guard owns a background heartbeat thread; dropping it releases
177/// the lock and removes the lock file.
178pub fn acquire(path: &Path) -> Result<LockGuard, AcquireError> {
179    acquire_with_config(path, None, LockConfig::default())
180}
181
182/// Try to acquire a filesystem lock at `path` within `timeout`.
183pub fn try_acquire(path: &Path, timeout: Duration) -> Result<LockGuard, AcquireError> {
184    acquire_with_config(path, Some(timeout), LockConfig::default())
185}
186
187/// Try one lock acquisition attempt, then check once whether an existing stale
188/// lock can be taken over.
189///
190/// Read-only cache openers use this to switch to writer mode without waiting
191/// behind another process that is still building the cache.
192pub 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    /// Re-read the lock file and confirm that this guard still owns the writer
215    /// token. Writers call this right before saving published data or starting
216    /// SQLite writes so they stop if another process has taken over the lock.
217    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        // Signal shutdown then unconditionally join the heartbeat thread
233        // BEFORE removing the lockfile. The earlier `recv_timeout(100ms)`
234        // implementation could let `remove_lock_if_owned` race with a
235        // still-alive heartbeat:
236        //
237        //   1. Drop signals shutdown, ack times out under CI load.
238        //   2. Drop calls `remove_lock_if_owned` → file removed.
239        //   3. Another caller acquires the lock → writes its metadata.
240        //   4. Our heartbeat (still alive, mid-`atomic_write_lock_metadata`
241        //      from before shutdown was checked) overwrites the new
242        //      owner's file with our stale metadata. heartbeat_once's
243        //      ownership check happens BEFORE the write, so it can race
244        //      with a concurrent acquire that flips ownership in between.
245        //   5. The new owner's heartbeat sees foreign metadata, exits
246        //      `NotOwner`. The new owner's drop sees foreign metadata,
247        //      `remove_lock_if_owned` returns `Ok(false)`, file persists.
248        //
249        // Always-joining bounds drop latency to one `park_timeout`
250        // iteration (~25ms) plus the current `heartbeat_once` IO —
251        // typically <500ms under CI load. The unused `heartbeat_done`
252        // channel is kept for backward compatibility with any external
253        // code that may still construct LockGuard manually, but Drop no
254        // longer relies on it.
255        self.shutdown.store(true, Ordering::Release);
256        if let Some(handle) = self.heartbeat.take() {
257            handle.thread().unpark();
258            let _ = handle.join();
259        }
260        // Drain any pending ack so the receiver doesn't carry stale state
261        // if this LockGuard is somehow re-used (it isn't today, but be
262        // defensive).
263        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    // A zero-timeout acquire still gets one immediate retry after it removes a
313    // stale lock; otherwise it would reap the dead owner and report Timeout.
314    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            // The lock file already exists — fall through to inspect its owner.
331            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
332            // Transient contention (chiefly Windows: a concurrent create/delete
333            // on this exact path surfaces as access-denied/sharing-violation
334            // rather than already-exists). Back off one poll interval and retry,
335            // bounded so a persistent failure still propagates instead of
336            // spinning forever.
337            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                // A just-created O_EXCL file is visible before its owner has
358                // finished writing JSON. Give that transient creation window
359                // one poll interval before treating malformed contents as stale.
360                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            // Same-host PID plus start-time identity is authoritative. A
443            // SIGSTOP'd process, suspended VM, or sleeping laptop can miss
444            // heartbeats and later resume inside the critical section. Breaking
445            // that lock would allow split-brain writers, so a paused matching
446            // owner blocks acquirers until it resumes and releases the lock or
447            // dies. PID namespaces restart numbering after hard-killed owners,
448            // which turns this safety rule into a deadlock unless the start time
449            // distinguishes the unrelated process that reused the PID.
450            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    // Number of consecutive heartbeat intervals that can be missed before the
535    // same-host stale window elapses and another process may reclaim the lock.
536    // Beyond this point a sustained failure is genuinely dangerous, so we
537    // escalate the log from warn to error — but we still keep retrying.
538    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                // Terminal states: the lock is provably gone or owned by
564                // someone else. Continuing to write would clobber a new owner's
565                // metadata (the exact race documented in LockGuard::drop), so
566                // stop heartbeating.
567                slog_error!(
568                    "{}; stopping heartbeat",
569                    terminal_heartbeat_message(&path, &error)
570                );
571                return;
572            }
573            Err(error) => {
574                // Transient states: a temporary I/O hiccup (disk/NFS blip,
575                // quota) or a read that raced a concurrent writer mid-write
576                // (momentarily unparseable file). A single such error must NOT
577                // permanently kill the heartbeat — that would silently stop
578                // refreshing heartbeat_at_ms while the guard holder keeps
579                // running its critical section, letting another process reclaim
580                // the lock after the stale window and produce concurrent
581                // writers. Log and retry on the next interval; a later success
582                // resumes heartbeating automatically.
583                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
595/// A heartbeat failure is terminal when the lock is provably no longer ours to
596/// refresh: it was removed (`LockGone`) or a different owner now holds it
597/// (`NotOwner`). I/O and malformed-read failures are treated as transient —
598/// they are typically temporary disk/NFS hiccups or a read that raced a
599/// concurrent writer — so the heartbeat retries rather than dying.
600fn 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        // Not reachable for non-terminal errors, but keep a sensible string.
614        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
635/// Log a transient heartbeat failure, escalating to error exactly once when the
636/// failures have lasted long enough that the lock is now reclaimable by another
637/// owner. Beyond that point we stay quiet to avoid log spam while still
638/// retrying — the holder has already been warned the lock is at risk.
639fn 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    // MoveFileExW with MOVEFILE_REPLACE_EXISTING is the only replacement path
769    // here that preserves the old destination on failure. A copy fallback
770    // truncates the destination before copying and can expose partial bytes or
771    // destroy the last valid artifact if the copy fails midway. Callers retain
772    // their temp file cleanup and retry policy when an open handle prevents the
773    // atomic replacement.
774    // Closure instead of the bare `fs::rename` fn item: the generic fn item
775    // instantiates with concrete reference lifetimes and fails the
776    // higher-ranked `FnOnce(&Path, &Path)` bound on some targets.
777    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
785// Per-thread counter that disambiguates temp lockfile paths for callers
786// inside the same process. `now_nanos()` alone is not unique enough on
787// Windows when two threads race to acquire the same lock (caught by the
788// `acquire_serializes_concurrent_callers` test): two threads sampling the
789// nanosecond clock within the same scheduler quantum produce identical
790// timestamps, both write to the same `.lock.tmp.<pid>.<nanos>` file, one
791// thread's `fs::remove_file(&tmp_path)` cleanup deletes the file before
792// the other thread's `fs::hard_link(&tmp_path, ...)` runs, and the loser
793// panics with `Io(Os { code: 2, NotFound })`.
794//
795// `AtomicU64` shared across threads makes every temp path unique within
796// the process regardless of clock resolution or scheduling races.
797static 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
848/// Reclaim (delete) a lock file we judged stale/dead, but ONLY if it still holds
849/// the SAME owner identity we evaluated. Between reading the metadata and
850/// deleting, the stale owner could release and a FRESH owner acquire — blindly
851/// `remove_file` would then delete the fresh owner's lock, allowing split-brain
852/// writers. Re-read immediately before the unlink and bail if the full owner
853/// identity changed or the file vanished. POSIX has no atomic compare-and-unlink,
854/// so a microscopic residual race remains, but this shrinks the window from the
855/// whole judgment/poll duration to a couple of syscalls — the standard mitigation.
856fn 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(&current, judged) {
865                remove_lock_file(path)?;
866                Ok(ReclaimResult::Removed)
867            } else {
868                // A different owner acquired it in the gap — do NOT delete.
869                Ok(ReclaimResult::Unchanged)
870            }
871        }
872        // Already gone (released/reclaimed by someone else) — nothing to do.
873        Err(ReadLockError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
874            Ok(ReclaimResult::Unchanged)
875        }
876        // Malformed now (mid-write by a new owner) — don't delete; retry next poll.
877        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    // A stale-token takeover gets exactly one O_EXCL retry. Another contender may
914    // win after the unlink; never loop and accidentally turn reclamation into a
915    // second lock-acquisition spin.
916    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
1041/// Remove abandoned reclaim tokens from the bounded set of storage domains that
1042/// own persistent artifact locks. The first maintenance pass in a process runs;
1043/// later passes are suppressed for 24 hours.
1044pub(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    // The observer is thread-local so concurrent lock tests cannot record one
1248    // another's retry decisions.
1249    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
1353/// Returns whether a same-host lock owner is still the same process instance.
1354///
1355/// Old leases have no start time, so they deliberately retain PID-only liveness
1356/// for backward compatibility. If an OS lookup cannot attest a recorded start
1357/// time, keep the PID alive: incorrectly reclaiming a paused owner is worse than
1358/// waiting for a process that may release the lock later.
1359fn 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    // Field 22 is starttime. Split after the final ')' because a process name is
1387    // allowed to contain spaces and parentheses.
1388    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    // PROC_PIDTBSDINFO supplies the kernel-recorded process birth time without
1417    // spawning a command or trusting user-controlled process metadata.
1418    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    // Windows keeps PID-only liveness for now. Querying creation time requires a
1448    // process handle and new FFI/error policy; Linux PID namespaces are the
1449    // environment where reused PIDs otherwise persistently deadlock leases.
1450    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    // `tasklist /NH /FO CSV` emits a single line per matching process with
1503    // every field quoted, e.g. `"image","7420","Console","1","12,345 K"`.
1504    // When the filter matches nothing, the literal text
1505    // `INFO: No tasks are running which match the specified criteria.`
1506    // is written to stdout. The previous matcher was too strict — it looked
1507    // for `","{pid}",` patterns mid-line, which works on most Windows builds
1508    // but missed Windows runners that emit slightly different quoting (e.g.
1509    // a trailing CRLF leaves the pid token at end-of-line as `"7420"\r\n`).
1510    // The robust check: confirm the "no tasks" sentinel is absent AND any
1511    // PID-quoted form is present.
1512    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            // Synthetic metadata defaults to the legacy shape so tests must opt
1579            // in when they need to exercise process-instance identity.
1580            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        // Windows surfaces a contended create/delete on the same lock path as
1653        // access-denied; acquire must retry these rather than fail the caller.
1654        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        // A genuinely fatal error (e.g. the parent dir is missing) must still
1661        // propagate, not spin in the transient-retry arm.
1662        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        // ERROR_SHARING_VIOLATION (32) is the other contention code Windows
1670        // returns when a concurrent actor holds the path open mid-create.
1671        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        // A lock currently owned by "owner B".
1680        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        // We judged a DIFFERENT (older) owner A as stale. Reclaiming must NOT
1684        // delete B's lock (the TOCTOU split-brain guard).
1685        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        // Same identity we judged → safe to remove.
1703        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        // Reclaiming a now-absent lock is a no-op, not an error.
1711        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        // Keep the announce-to-enter gap under channel control rather than
1740        // charging an arbitrary scheduler pause to the acquisition decision.
1741        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        // Poll for up to 2s rather than sleeping a fixed multiple of the
1839        // heartbeat interval. `park_timeout` is a *maximum* wait, not a
1840        // guaranteed periodic timer — under load (shared macOS CI runners
1841        // running other cargo-test threads concurrently) the heartbeat
1842        // thread may not fire 3 times within 75ms even though
1843        // heartbeat_interval_ms=25. The contract being asserted is "the
1844        // heartbeat advances eventually", not "it advances within N
1845        // heartbeat intervals".
1846        //
1847        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                    // Heartbeat thread is mid-rewrite (Windows
1860                    // remove-then-rename window). Retry next iteration.
1861                    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        // Terminal: the lock is provably no longer ours to refresh.
2278        assert!(heartbeat_error_is_terminal(&HeartbeatError::LockGone));
2279        assert!(heartbeat_error_is_terminal(&HeartbeatError::NotOwner));
2280        // Transient: a temporary I/O hiccup or a read that raced a concurrent
2281        // writer. These must NOT kill the heartbeat — it retries instead.
2282        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        // Regression: a single transient failure (e.g. a read that races a
2295        // concurrent writer and sees a momentarily-unparseable file) used to
2296        // permanently kill the heartbeat thread. The guard holder would then
2297        // run its critical section with a stale heartbeat_at_ms, letting
2298        // another process reclaim the lock after the stale window — concurrent
2299        // writers / split-brain. The heartbeat must instead retry and resume
2300        // refreshing once the file is readable again.
2301        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        // Corrupt the lockfile out from under the heartbeat (simulates a
2306        // concurrent-writer race producing a momentarily-unparseable read).
2307        // The heartbeat reads-then-writes, so it observes Malformed and, with
2308        // the fix, retries instead of dying.
2309        fs::write(&path, b"{ not valid json").expect("corrupt lockfile");
2310
2311        // Give the heartbeat several intervals to observe the malformed file.
2312        // Pre-fix, the thread is dead by now.
2313        thread::sleep(Duration::from_millis(
2314            test_config().heartbeat_interval_ms * 4,
2315        ));
2316
2317        // Restore valid owner metadata with a clearly-stale heartbeat sentinel.
2318        // Ownership fields must match `owner` exactly so heartbeat_once passes
2319        // its ownership check and writes a fresh timestamp.
2320        //
2321        // Use the atomic temp-write+rename path rather than remove-then-recreate:
2322        // a remove followed by a separate create leaves a window where the file
2323        // does not exist, and a heartbeat poll landing in that window reads
2324        // NotFound -> LockGone (terminal) and kills the thread, failing this test
2325        // spuriously under runner load (observed on macOS CI). The atomic replace
2326        // overwrites the corrupt file in place with no no-file window on Unix.
2327        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        // If the heartbeat thread is still alive (the fix), it will overwrite
2333        // heartbeat_at_ms with a current value. Poll for that recovery.
2334        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}