Skip to main content

aft/
fs_lock.rs

1use std::fmt;
2use std::fs::{self, File, OpenOptions};
3use std::io::{self, Write};
4use std::path::{Path, PathBuf};
5use std::sync::{
6    atomic::{AtomicBool, AtomicU64, Ordering},
7    mpsc, Arc,
8};
9use std::thread::{self, JoinHandle};
10use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
11
12use serde::{Deserialize, Serialize};
13
14use crate::{slog_debug, slog_error, slog_info, slog_warn};
15
16pub const HEARTBEAT_INTERVAL_MS: u64 = 5_000;
17pub const STALE_HEARTBEAT_MS: u64 = 15_000;
18pub const LIVE_OWNER_WARN_MS: u64 = 600_000;
19pub const POLL_INTERVAL_MS: u64 = 100;
20
21/// Max consecutive transient OS errors tolerated while creating the lock file
22/// before giving up. On Windows, two processes/threads racing to create (or one
23/// creating while another deletes) the same path can momentarily return
24/// ERROR_ACCESS_DENIED (5) or ERROR_SHARING_VIOLATION (32) instead of a clean
25/// "already exists". Those windows close in milliseconds, so a small bounded
26/// retry rides them out while a genuinely persistent permission/IO failure still
27/// surfaces promptly.
28const MAX_TRANSIENT_CREATE_RETRIES: u32 = 50;
29
30/// True for OS errors that mean "another actor is touching this exact lock path
31/// right now", as opposed to a real, persistent failure. On Windows a contended
32/// create/delete on the same file surfaces as ERROR_ACCESS_DENIED (5) or
33/// ERROR_SHARING_VIOLATION (32); `PermissionDenied` covers the former across
34/// platforms. These are retried as contention, never treated as fatal.
35fn is_transient_create_contention(error: &io::Error) -> bool {
36    if error.kind() == io::ErrorKind::PermissionDenied {
37        return true;
38    }
39    #[cfg(windows)]
40    {
41        // ERROR_SHARING_VIOLATION = 32. ERROR_ACCESS_DENIED = 5 maps to
42        // PermissionDenied above, but match it explicitly too in case the OS
43        // surfaces it as an Other-kind raw error.
44        if let Some(code) = error.raw_os_error() {
45            if code == 32 || code == 5 {
46                return true;
47            }
48        }
49    }
50    false
51}
52
53#[derive(Clone, Copy, Debug)]
54struct LockConfig {
55    heartbeat_interval_ms: u64,
56    stale_heartbeat_ms: u64,
57    live_owner_warn_ms: u64,
58    poll_interval_ms: u64,
59}
60
61impl LockConfig {
62    fn cross_host_stale_heartbeat_ms(self) -> u64 {
63        self.stale_heartbeat_ms.saturating_mul(5)
64    }
65}
66
67impl Default for LockConfig {
68    fn default() -> Self {
69        Self {
70            heartbeat_interval_ms: HEARTBEAT_INTERVAL_MS,
71            stale_heartbeat_ms: STALE_HEARTBEAT_MS,
72            live_owner_warn_ms: LIVE_OWNER_WARN_MS,
73            poll_interval_ms: POLL_INTERVAL_MS,
74        }
75    }
76}
77
78#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
79struct LockMetadata {
80    pid: u32,
81    hostname: String,
82    created_at_ms: u64,
83    heartbeat_at_ms: u64,
84    /// Fencing nonce for writer leases. The owner re-reads the lock immediately
85    /// before publishing/writing and aborts if a stale guard has been usurped.
86    #[serde(default)]
87    writer_epoch: String,
88}
89
90/// Acquire a filesystem lock at `path`. Blocks until the lock is held.
91///
92/// The returned guard owns a background heartbeat thread; dropping it releases
93/// the lock and removes the lock file.
94pub fn acquire(path: &Path) -> Result<LockGuard, AcquireError> {
95    acquire_with_config(path, None, LockConfig::default())
96}
97
98/// Try to acquire a filesystem lock at `path` within `timeout`.
99pub fn try_acquire(path: &Path, timeout: Duration) -> Result<LockGuard, AcquireError> {
100    acquire_with_config(path, Some(timeout), LockConfig::default())
101}
102
103/// Try one lock acquisition attempt, then check once whether an existing stale
104/// lock can be taken over.
105///
106/// Read-only cache openers use this to switch to writer mode without waiting
107/// behind another process that is still building the cache.
108pub fn try_acquire_once(path: &Path) -> Result<LockGuard, AcquireError> {
109    try_acquire(path, Duration::ZERO)
110}
111
112pub struct LockGuard {
113    path: PathBuf,
114    metadata: LockMetadata,
115    shutdown: Arc<AtomicBool>,
116    heartbeat_failed: Arc<AtomicBool>,
117    heartbeat_done: mpsc::Receiver<()>,
118    heartbeat: Option<JoinHandle<()>>,
119}
120
121impl LockGuard {
122    pub fn path(&self) -> &Path {
123        &self.path
124    }
125
126    pub fn writer_epoch(&self) -> &str {
127        &self.metadata.writer_epoch
128    }
129
130    /// Re-read the lock file and confirm that this guard still owns the writer
131    /// token. Writers call this right before saving published data or starting
132    /// SQLite writes so they stop if another process has taken over the lock.
133    pub fn verify_writer_epoch(&self) -> io::Result<bool> {
134        if self.heartbeat_failed.load(Ordering::Acquire) {
135            return Ok(false);
136        }
137        match read_lock_metadata(&self.path) {
138            Ok(metadata) => Ok(lock_identity_matches(&metadata, &self.metadata)),
139            Err(ReadLockError::Io(error)) if error.kind() == io::ErrorKind::NotFound => Ok(false),
140            Err(ReadLockError::Io(error)) => Err(error),
141            Err(ReadLockError::Malformed(_)) => Ok(false),
142        }
143    }
144}
145
146impl Drop for LockGuard {
147    fn drop(&mut self) {
148        // Signal shutdown then unconditionally join the heartbeat thread
149        // BEFORE removing the lockfile. The earlier `recv_timeout(100ms)`
150        // implementation could let `remove_lock_if_owned` race with a
151        // still-alive heartbeat:
152        //
153        //   1. Drop signals shutdown, ack times out under CI load.
154        //   2. Drop calls `remove_lock_if_owned` → file removed.
155        //   3. Another caller acquires the lock → writes its metadata.
156        //   4. Our heartbeat (still alive, mid-`atomic_write_lock_metadata`
157        //      from before shutdown was checked) overwrites the new
158        //      owner's file with our stale metadata. heartbeat_once's
159        //      ownership check happens BEFORE the write, so it can race
160        //      with a concurrent acquire that flips ownership in between.
161        //   5. The new owner's heartbeat sees foreign metadata, exits
162        //      `NotOwner`. The new owner's drop sees foreign metadata,
163        //      `remove_lock_if_owned` returns `Ok(false)`, file persists.
164        //
165        // Always-joining bounds drop latency to one `park_timeout`
166        // iteration (~25ms) plus the current `heartbeat_once` IO —
167        // typically <500ms under CI load. The unused `heartbeat_done`
168        // channel is kept for backward compatibility with any external
169        // code that may still construct LockGuard manually, but Drop no
170        // longer relies on it.
171        self.shutdown.store(true, Ordering::Release);
172        if let Some(handle) = self.heartbeat.take() {
173            handle.thread().unpark();
174            let _ = handle.join();
175        }
176        // Drain any pending ack so the receiver doesn't carry stale state
177        // if this LockGuard is somehow re-used (it isn't today, but be
178        // defensive).
179        while self.heartbeat_done.try_recv().is_ok() {}
180
181        match remove_lock_if_owned(&self.path, &self.metadata) {
182            Ok(true) => slog_debug!("released filesystem lock at {}", self.path.display()),
183            Ok(false) => {}
184            Err(error) => slog_warn!(
185                "failed to release filesystem lock at {}: {}",
186                self.path.display(),
187                error
188            ),
189        }
190    }
191}
192
193#[derive(Debug)]
194pub enum AcquireError {
195    Io(io::Error),
196    Timeout,
197}
198
199impl fmt::Display for AcquireError {
200    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
201        match self {
202            AcquireError::Io(error) => write!(f, "filesystem lock I/O error: {error}"),
203            AcquireError::Timeout => write!(f, "timed out acquiring filesystem lock"),
204        }
205    }
206}
207
208impl std::error::Error for AcquireError {}
209
210impl From<io::Error> for AcquireError {
211    fn from(error: io::Error) -> Self {
212        AcquireError::Io(error)
213    }
214}
215
216fn acquire_with_config(
217    path: &Path,
218    timeout: Option<Duration>,
219    config: LockConfig,
220) -> Result<LockGuard, AcquireError> {
221    let deadline = timeout.map(|timeout| Instant::now() + timeout);
222    let hostname = current_hostname();
223    let mut warned_live_owner = false;
224    let mut warned_stale_live_owner = false;
225    let mut transient_create_failures: u32 = 0;
226    let mut attempted_once = false;
227    // A zero-timeout acquire still gets one immediate retry after it removes a
228    // stale lock; otherwise it would reap the dead owner and report Timeout.
229    let mut immediate_retry_budget = 0_u8;
230
231    loop {
232        if attempted_once {
233            if immediate_retry_budget > 0 {
234                immediate_retry_budget -= 1;
235            } else if let Some(deadline) = deadline {
236                if Instant::now() >= deadline {
237                    return Err(AcquireError::Timeout);
238                }
239            }
240        }
241        attempted_once = true;
242
243        match create_new_lock(path, &hostname, config) {
244            Ok(guard) => return Ok(guard),
245            // The lock file already exists — fall through to inspect its owner.
246            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
247            // Transient contention (chiefly Windows: a concurrent create/delete
248            // on this exact path surfaces as access-denied/sharing-violation
249            // rather than already-exists). Back off one poll interval and retry,
250            // bounded so a persistent failure still propagates instead of
251            // spinning forever.
252            Err(error) if is_transient_create_contention(&error) => {
253                transient_create_failures += 1;
254                if transient_create_failures > MAX_TRANSIENT_CREATE_RETRIES {
255                    return Err(error.into());
256                }
257                sleep_until_retry(deadline, config.poll_interval_ms)?;
258                continue;
259            }
260            Err(error) => return Err(error.into()),
261        }
262        transient_create_failures = 0;
263
264        let metadata = match read_lock_metadata(path) {
265            Ok(metadata) => metadata,
266            Err(ReadLockError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
267                immediate_retry_budget = 1;
268                continue;
269            }
270            Err(ReadLockError::Io(error)) => return Err(error.into()),
271            Err(ReadLockError::Malformed(error)) => {
272                // A just-created O_EXCL file is visible before its owner has
273                // finished writing JSON. Give that transient creation window
274                // one poll interval before treating malformed contents as stale.
275                sleep_until_retry(deadline, config.poll_interval_ms)?;
276                match read_lock_metadata(path) {
277                    Ok(_) => continue,
278                    Err(ReadLockError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
279                        continue;
280                    }
281                    Err(ReadLockError::Io(error)) => return Err(error.into()),
282                    Err(ReadLockError::Malformed(_)) => {}
283                }
284                slog_warn!(
285                    "removing malformed filesystem lock at {}: {}",
286                    path.display(),
287                    error
288                );
289                remove_lock_file(path)?;
290                immediate_retry_budget = 1;
291                continue;
292            }
293        };
294
295        let now = now_ms();
296        let since_heartbeat = now.saturating_sub(metadata.heartbeat_at_ms);
297
298        if metadata.hostname != hostname {
299            let cross_host_stale_ms = config.cross_host_stale_heartbeat_ms();
300            if since_heartbeat > cross_host_stale_ms {
301                slog_warn!(
302                    "reclaiming cross-host filesystem lock at {} from host {} after stale heartbeat ({}ms > {}ms)",
303                    path.display(),
304                    metadata.hostname,
305                    since_heartbeat,
306                    cross_host_stale_ms
307                );
308                // Compare-and-delete: only remove if it's still the SAME stale
309                // owner (a fresh owner may have acquired it in the gap).
310                if reclaim_lock_file(path, &metadata)? {
311                    immediate_retry_budget = 1;
312                }
313                continue;
314            }
315            sleep_until_retry(deadline, config.poll_interval_ms)?;
316            continue;
317        }
318
319        if !process_alive(metadata.pid) {
320            slog_warn!(
321                "removing filesystem lock at {} from dead PID {}",
322                path.display(),
323                metadata.pid
324            );
325            // Compare-and-delete: only remove if it's still this dead owner's
326            // lock. A fresh owner could have written a new lock (with a recycled
327            // or different PID) between our liveness check and the unlink.
328            if reclaim_lock_file(path, &metadata)? {
329                immediate_retry_budget = 1;
330            }
331            continue;
332        }
333
334        if since_heartbeat > config.stale_heartbeat_ms && !warned_stale_live_owner {
335            // Same-host PID liveness is authoritative. A SIGSTOP'd process,
336            // suspended VM, or sleeping laptop can miss heartbeats and later
337            // resume inside the critical section. Breaking that lock would allow
338            // split-brain writers, so a paused live owner blocks acquirers until
339            // it resumes and releases the lock or the PID dies.
340            slog_warn!(
341                "filesystem lock at {} held by live PID {} has stale heartbeat ({}ms); NOT breaking",
342                path.display(),
343                metadata.pid,
344                since_heartbeat
345            );
346            warned_stale_live_owner = true;
347        }
348
349        let held_for = now.saturating_sub(metadata.created_at_ms);
350        if held_for > config.live_owner_warn_ms && !warned_live_owner {
351            slog_warn!(
352                "filesystem lock at {} held >10min by live heartbeating PID {}; NOT breaking",
353                path.display(),
354                metadata.pid
355            );
356            warned_live_owner = true;
357        }
358
359        sleep_until_retry(deadline, config.poll_interval_ms)?;
360    }
361}
362
363fn create_new_lock(path: &Path, hostname: &str, config: LockConfig) -> io::Result<LockGuard> {
364    let now = now_ms();
365    let metadata = LockMetadata {
366        pid: std::process::id(),
367        hostname: hostname.to_string(),
368        created_at_ms: now,
369        heartbeat_at_ms: now,
370        writer_epoch: format!("{}-{}", std::process::id(), now_nanos()),
371    };
372
373    create_lock_file_atomically(path, &metadata)?;
374
375    let shutdown = Arc::new(AtomicBool::new(false));
376    let heartbeat_failed = Arc::new(AtomicBool::new(false));
377    let (done_tx, done_rx) = mpsc::channel();
378    let heartbeat_path = path.to_path_buf();
379    let heartbeat_metadata = metadata.clone();
380    let heartbeat_shutdown = Arc::clone(&shutdown);
381    let heartbeat_failed_for_thread = Arc::clone(&heartbeat_failed);
382    let heartbeat = thread::Builder::new()
383        .name("aft-fs-lock-heartbeat".to_string())
384        .spawn(move || {
385            let heartbeat_shutdown_for_run = Arc::clone(&heartbeat_shutdown);
386            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
387                run_heartbeat(
388                    heartbeat_path,
389                    heartbeat_metadata,
390                    heartbeat_shutdown_for_run,
391                    config,
392                );
393            }));
394            if result.is_err() || !heartbeat_shutdown.load(Ordering::Acquire) {
395                heartbeat_failed_for_thread.store(true, Ordering::Release);
396            }
397            let _ = done_tx.send(());
398        })?;
399
400    slog_debug!("acquired filesystem lock at {}", path.display());
401
402    Ok(LockGuard {
403        path: path.to_path_buf(),
404        metadata,
405        shutdown,
406        heartbeat_failed,
407        heartbeat_done: done_rx,
408        heartbeat: Some(heartbeat),
409    })
410}
411
412fn run_heartbeat(
413    path: PathBuf,
414    owner: LockMetadata,
415    shutdown: Arc<AtomicBool>,
416    config: LockConfig,
417) {
418    // Number of consecutive heartbeat intervals that can be missed before the
419    // same-host stale window elapses and another process may reclaim the lock.
420    // Beyond this point a sustained failure is genuinely dangerous, so we
421    // escalate the log from warn to error — but we still keep retrying.
422    let stale_intervals = config
423        .stale_heartbeat_ms
424        .checked_div(config.heartbeat_interval_ms.max(1))
425        .unwrap_or(3)
426        .max(1);
427    let mut consecutive_transient_failures: u64 = 0;
428
429    loop {
430        thread::park_timeout(Duration::from_millis(config.heartbeat_interval_ms));
431        if shutdown.load(Ordering::Acquire) {
432            return;
433        }
434
435        match heartbeat_once(&path, &owner) {
436            Ok(()) => {
437                if consecutive_transient_failures > 0 {
438                    slog_info!(
439                        "filesystem lock at {} heartbeat recovered after {} transient failure(s)",
440                        path.display(),
441                        consecutive_transient_failures
442                    );
443                    consecutive_transient_failures = 0;
444                }
445            }
446            Err(error) if heartbeat_error_is_terminal(&error) => {
447                // Terminal states: the lock is provably gone or owned by
448                // someone else. Continuing to write would clobber a new owner's
449                // metadata (the exact race documented in LockGuard::drop), so
450                // stop heartbeating.
451                slog_error!(
452                    "{}; stopping heartbeat",
453                    terminal_heartbeat_message(&path, &error)
454                );
455                return;
456            }
457            Err(error) => {
458                // Transient states: a temporary I/O hiccup (disk/NFS blip,
459                // quota) or a read that raced a concurrent writer mid-write
460                // (momentarily unparseable file). A single such error must NOT
461                // permanently kill the heartbeat — that would silently stop
462                // refreshing heartbeat_at_ms while the guard holder keeps
463                // running its critical section, letting another process reclaim
464                // the lock after the stale window and produce concurrent
465                // writers. Log and retry on the next interval; a later success
466                // resumes heartbeating automatically.
467                consecutive_transient_failures += 1;
468                log_transient_heartbeat_failure(
469                    &path,
470                    &transient_heartbeat_reason(&error),
471                    consecutive_transient_failures,
472                    stale_intervals,
473                );
474            }
475        }
476    }
477}
478
479/// A heartbeat failure is terminal when the lock is provably no longer ours to
480/// refresh: it was removed (`LockGone`) or a different owner now holds it
481/// (`NotOwner`). I/O and malformed-read failures are treated as transient —
482/// they are typically temporary disk/NFS hiccups or a read that raced a
483/// concurrent writer — so the heartbeat retries rather than dying.
484fn heartbeat_error_is_terminal(error: &HeartbeatError) -> bool {
485    matches!(error, HeartbeatError::LockGone | HeartbeatError::NotOwner)
486}
487
488fn terminal_heartbeat_message(path: &Path, error: &HeartbeatError) -> String {
489    match error {
490        HeartbeatError::LockGone => {
491            format!("filesystem lock at {} disappeared", path.display())
492        }
493        HeartbeatError::NotOwner => format!(
494            "filesystem lock at {} is no longer owned by this guard",
495            path.display()
496        ),
497        // Not reachable for non-terminal errors, but keep a sensible string.
498        HeartbeatError::Io(error) => {
499            format!("filesystem lock at {} I/O error: {error}", path.display())
500        }
501        HeartbeatError::Malformed(error) => {
502            format!(
503                "filesystem lock at {} became malformed: {error}",
504                path.display()
505            )
506        }
507    }
508}
509
510fn transient_heartbeat_reason(error: &HeartbeatError) -> String {
511    match error {
512        HeartbeatError::Io(error) => format!("I/O error: {error}"),
513        HeartbeatError::Malformed(error) => format!("became malformed: {error}"),
514        HeartbeatError::LockGone => "lock disappeared".to_string(),
515        HeartbeatError::NotOwner => "lock no longer owned".to_string(),
516    }
517}
518
519/// Log a transient heartbeat failure, escalating to error exactly once when the
520/// failures have lasted long enough that the lock is now reclaimable by another
521/// owner. Beyond that point we stay quiet to avoid log spam while still
522/// retrying — the holder has already been warned the lock is at risk.
523fn log_transient_heartbeat_failure(
524    path: &Path,
525    reason: &str,
526    consecutive_failures: u64,
527    stale_intervals: u64,
528) {
529    if consecutive_failures < stale_intervals {
530        slog_warn!(
531            "transient failure to heartbeat filesystem lock at {}: {}; retrying (attempt {})",
532            path.display(),
533            reason,
534            consecutive_failures
535        );
536    } else if consecutive_failures == stale_intervals {
537        slog_error!(
538            "filesystem lock at {} has failed {} consecutive heartbeats: {}; \
539             the lock may now be reclaimed by another owner — continuing to retry",
540            path.display(),
541            consecutive_failures,
542            reason
543        );
544    }
545}
546
547fn heartbeat_once(path: &Path, owner: &LockMetadata) -> Result<(), HeartbeatError> {
548    let mut metadata = match read_lock_metadata(path) {
549        Ok(metadata) => metadata,
550        Err(ReadLockError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
551            return Err(HeartbeatError::LockGone);
552        }
553        Err(ReadLockError::Io(error)) => return Err(HeartbeatError::Io(error)),
554        Err(ReadLockError::Malformed(error)) => return Err(HeartbeatError::Malformed(error)),
555    };
556
557    if !lock_identity_matches(&metadata, owner) {
558        return Err(HeartbeatError::NotOwner);
559    }
560
561    metadata.heartbeat_at_ms = now_ms();
562    atomic_write_lock_metadata(path, &metadata).map_err(HeartbeatError::Io)
563}
564
565#[derive(Debug)]
566enum HeartbeatError {
567    Io(io::Error),
568    LockGone,
569    Malformed(serde_json::Error),
570    NotOwner,
571}
572
573#[derive(Debug)]
574enum ReadLockError {
575    Io(io::Error),
576    Malformed(serde_json::Error),
577}
578
579fn read_lock_metadata(path: &Path) -> Result<LockMetadata, ReadLockError> {
580    let bytes = fs::read(path).map_err(ReadLockError::Io)?;
581    serde_json::from_slice(&bytes).map_err(ReadLockError::Malformed)
582}
583
584#[cfg(unix)]
585fn open_new_lock_file(path: &Path) -> io::Result<File> {
586    use std::os::unix::fs::OpenOptionsExt;
587
588    OpenOptions::new()
589        .write(true)
590        .create_new(true)
591        .mode(0o600)
592        .open(path)
593}
594
595#[cfg(not(unix))]
596fn open_new_lock_file(path: &Path) -> io::Result<File> {
597    OpenOptions::new().write(true).create_new(true).open(path)
598}
599
600fn write_lock_metadata_to_file(file: &mut File, metadata: &LockMetadata) -> io::Result<()> {
601    serde_json::to_writer(&mut *file, metadata).map_err(io::Error::other)?;
602    file.write_all(b"\n")?;
603    file.sync_all()
604}
605
606fn create_lock_file_atomically(path: &Path, metadata: &LockMetadata) -> io::Result<()> {
607    let tmp_path = temp_path_for_lock(path);
608    let result = (|| {
609        let mut file = open_new_lock_file(&tmp_path)?;
610        write_lock_metadata_to_file(&mut file, metadata)?;
611        drop(file);
612
613        fs::hard_link(&tmp_path, path)?;
614        sync_parent(path);
615        Ok(())
616    })();
617
618    let _ = fs::remove_file(&tmp_path);
619    result
620}
621
622fn atomic_write_lock_metadata(path: &Path, metadata: &LockMetadata) -> io::Result<()> {
623    let tmp_path = temp_path_for_lock(path);
624    let write_result = (|| {
625        let mut file = open_new_lock_file(&tmp_path)?;
626        write_lock_metadata_to_file(&mut file, metadata)?;
627        drop(file);
628
629        rename_over(&tmp_path, path)?;
630        sync_parent(path);
631        Ok(())
632    })();
633
634    if write_result.is_err() {
635        let _ = fs::remove_file(&tmp_path);
636    }
637
638    write_result
639}
640
641#[cfg(any(windows, test))]
642fn rename_over_with(
643    from: &Path,
644    to: &Path,
645    replace: impl FnOnce(&Path, &Path) -> io::Result<()>,
646) -> io::Result<()> {
647    replace(from, to)
648}
649
650#[cfg(windows)]
651pub(crate) fn rename_over(from: &Path, to: &Path) -> io::Result<()> {
652    // MoveFileExW with MOVEFILE_REPLACE_EXISTING is the only replacement path
653    // here that preserves the old destination on failure. A copy fallback
654    // truncates the destination before copying and can expose partial bytes or
655    // destroy the last valid artifact if the copy fails midway. Callers retain
656    // their temp file cleanup and retry policy when an open handle prevents the
657    // atomic replacement.
658    // Closure instead of the bare `fs::rename` fn item: the generic fn item
659    // instantiates with concrete reference lifetimes and fails the
660    // higher-ranked `FnOnce(&Path, &Path)` bound on some targets.
661    rename_over_with(from, to, |from, to| fs::rename(from, to))
662}
663
664#[cfg(not(windows))]
665pub(crate) fn rename_over(from: &Path, to: &Path) -> io::Result<()> {
666    fs::rename(from, to)
667}
668
669// Per-thread counter that disambiguates temp lockfile paths for callers
670// inside the same process. `now_nanos()` alone is not unique enough on
671// Windows when two threads race to acquire the same lock (caught by the
672// `acquire_serializes_concurrent_callers` test): two threads sampling the
673// nanosecond clock within the same scheduler quantum produce identical
674// timestamps, both write to the same `.lock.tmp.<pid>.<nanos>` file, one
675// thread's `fs::remove_file(&tmp_path)` cleanup deletes the file before
676// the other thread's `fs::hard_link(&tmp_path, ...)` runs, and the loser
677// panics with `Io(Os { code: 2, NotFound })`.
678//
679// `AtomicU64` shared across threads makes every temp path unique within
680// the process regardless of clock resolution or scheduling races.
681static TEMP_LOCK_COUNTER: AtomicU64 = AtomicU64::new(0);
682
683fn temp_path_for_lock(path: &Path) -> PathBuf {
684    let file_name = path
685        .file_name()
686        .and_then(|name| name.to_str())
687        .unwrap_or("lock");
688    let seq = TEMP_LOCK_COUNTER.fetch_add(1, Ordering::Relaxed);
689    path.with_file_name(format!(
690        ".{file_name}.tmp.{}.{}.{}",
691        std::process::id(),
692        now_nanos(),
693        seq
694    ))
695}
696
697fn lock_identity_matches(left: &LockMetadata, right: &LockMetadata) -> bool {
698    left.pid == right.pid
699        && left.hostname == right.hostname
700        && left.created_at_ms == right.created_at_ms
701        && left.writer_epoch == right.writer_epoch
702}
703
704fn remove_lock_if_owned(path: &Path, owner: &LockMetadata) -> io::Result<bool> {
705    let metadata = match read_lock_metadata(path) {
706        Ok(metadata) => metadata,
707        Err(ReadLockError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
708            return Ok(false);
709        }
710        Err(ReadLockError::Io(error)) => return Err(error),
711        Err(ReadLockError::Malformed(_)) => return Ok(false),
712    };
713
714    if lock_identity_matches(&metadata, owner) {
715        remove_lock_file(path)?;
716        Ok(true)
717    } else {
718        Ok(false)
719    }
720}
721
722fn remove_lock_file(path: &Path) -> io::Result<()> {
723    match fs::remove_file(path) {
724        Ok(()) => Ok(()),
725        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
726        Err(error) => Err(error),
727    }
728}
729
730/// Reclaim (delete) a lock file we judged stale/dead, but ONLY if it still holds
731/// the SAME owner identity we evaluated. Between reading the metadata and
732/// deleting, the stale owner could release and a FRESH owner acquire — blindly
733/// `remove_file` would then delete the fresh owner's lock, allowing split-brain
734/// writers. Re-read immediately before the unlink and bail if the identity
735/// (pid, hostname, created_at_ms) changed or the file vanished. POSIX has no
736/// atomic compare-and-unlink, so a microscopic residual race remains, but this
737/// shrinks the window from the whole judgment/poll duration to a couple of
738/// syscalls — the standard mitigation. Returns true if we removed it.
739fn reclaim_lock_file(path: &Path, judged: &LockMetadata) -> io::Result<bool> {
740    let Some(_token) = acquire_reclaim_token(path)? else {
741        return Ok(false);
742    };
743    match read_lock_metadata(path) {
744        Ok(current) => {
745            if lock_identity_matches(&current, judged) {
746                remove_lock_file(path)?;
747                Ok(true)
748            } else {
749                // A different owner acquired it in the gap — do NOT delete.
750                Ok(false)
751            }
752        }
753        // Already gone (released/reclaimed by someone else) — nothing to do.
754        Err(ReadLockError::Io(error)) if error.kind() == io::ErrorKind::NotFound => Ok(false),
755        // Malformed now (mid-write by a new owner) — don't delete; retry next poll.
756        Err(ReadLockError::Malformed(_)) => Ok(false),
757        Err(ReadLockError::Io(error)) => Err(error),
758    }
759}
760
761struct ReclaimTokenGuard {
762    path: PathBuf,
763}
764
765impl Drop for ReclaimTokenGuard {
766    fn drop(&mut self) {
767        let _ = fs::remove_file(&self.path);
768        sync_parent(&self.path);
769    }
770}
771
772fn acquire_reclaim_token(lock_path: &Path) -> io::Result<Option<ReclaimTokenGuard>> {
773    let token_path = reclaim_token_path(lock_path);
774    let metadata = LockMetadata {
775        pid: std::process::id(),
776        hostname: current_hostname(),
777        created_at_ms: now_ms(),
778        heartbeat_at_ms: now_ms(),
779        writer_epoch: format!("reclaim-{}-{}", std::process::id(), now_nanos()),
780    };
781    let mut file = match open_new_lock_file(&token_path) {
782        Ok(file) => file,
783        Err(error) if error.kind() == io::ErrorKind::AlreadyExists => return Ok(None),
784        Err(error) => return Err(error),
785    };
786    if let Err(error) = write_lock_metadata_to_file(&mut file, &metadata) {
787        let _ = fs::remove_file(&token_path);
788        return Err(error);
789    }
790    sync_parent(&token_path);
791    Ok(Some(ReclaimTokenGuard { path: token_path }))
792}
793
794fn reclaim_token_path(lock_path: &Path) -> PathBuf {
795    let file_name = lock_path
796        .file_name()
797        .and_then(|name| name.to_str())
798        .unwrap_or("lock");
799    lock_path.with_file_name(format!(".{file_name}.reclaim"))
800}
801
802fn sleep_until_retry(deadline: Option<Instant>, poll_interval_ms: u64) -> Result<(), AcquireError> {
803    let poll = Duration::from_millis(poll_interval_ms);
804    let sleep_for = match deadline {
805        Some(deadline) => {
806            let now = Instant::now();
807            if now >= deadline {
808                return Err(AcquireError::Timeout);
809            }
810            poll.min(deadline.saturating_duration_since(now))
811        }
812        None => poll,
813    };
814    thread::sleep(sleep_for);
815    Ok(())
816}
817
818pub(crate) fn sync_parent(path: &Path) {
819    if let Some(parent) = path.parent() {
820        if let Ok(dir) = File::open(parent) {
821            let _ = dir.sync_all();
822        }
823    }
824}
825
826fn now_ms() -> u64 {
827    SystemTime::now()
828        .duration_since(UNIX_EPOCH)
829        .unwrap_or(Duration::ZERO)
830        .as_millis() as u64
831}
832
833fn now_nanos() -> u128 {
834    SystemTime::now()
835        .duration_since(UNIX_EPOCH)
836        .unwrap_or(Duration::ZERO)
837        .as_nanos()
838}
839
840#[cfg(unix)]
841fn current_hostname() -> String {
842    let mut buffer = [0u8; 256];
843    let result = unsafe { libc::gethostname(buffer.as_mut_ptr().cast(), buffer.len()) };
844    if result == 0 {
845        let len = buffer
846            .iter()
847            .position(|byte| *byte == 0)
848            .unwrap_or(buffer.len());
849        if len > 0 {
850            return String::from_utf8_lossy(&buffer[..len]).into_owned();
851        }
852    }
853
854    std::env::var("HOSTNAME").unwrap_or_else(|_| "unknown-host".to_string())
855}
856
857#[cfg(windows)]
858fn current_hostname() -> String {
859    std::env::var("COMPUTERNAME")
860        .or_else(|_| std::env::var("HOSTNAME"))
861        .unwrap_or_else(|_| "unknown-host".to_string())
862}
863
864#[cfg(not(any(unix, windows)))]
865fn current_hostname() -> String {
866    std::env::var("HOSTNAME").unwrap_or_else(|_| "unknown-host".to_string())
867}
868
869#[cfg(unix)]
870pub(crate) fn process_alive(pid: u32) -> bool {
871    if pid == 0 || pid > i32::MAX as u32 {
872        return false;
873    }
874
875    let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
876    if result == 0 {
877        return true;
878    }
879
880    io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
881}
882
883#[cfg(windows)]
884pub(crate) fn process_alive(pid: u32) -> bool {
885    if pid == 0 {
886        return false;
887    }
888    let filter = format!("PID eq {pid}");
889    let Ok(output) = std::process::Command::new("tasklist")
890        .args(["/FI", &filter, "/FO", "CSV", "/NH"])
891        .output()
892    else {
893        return true;
894    };
895
896    if !output.status.success() {
897        return true;
898    }
899
900    let stdout = String::from_utf8_lossy(&output.stdout);
901
902    // `tasklist /NH /FO CSV` emits a single line per matching process with
903    // every field quoted, e.g. `"image","7420","Console","1","12,345 K"`.
904    // When the filter matches nothing, the literal text
905    // `INFO: No tasks are running which match the specified criteria.`
906    // is written to stdout. The previous matcher was too strict — it looked
907    // for `","{pid}",` patterns mid-line, which works on most Windows builds
908    // but missed Windows runners that emit slightly different quoting (e.g.
909    // a trailing CRLF leaves the pid token at end-of-line as `"7420"\r\n`).
910    // The robust check: confirm the "no tasks" sentinel is absent AND any
911    // PID-quoted form is present.
912    if stdout.contains("No tasks are running") {
913        return false;
914    }
915    stdout.contains(&format!("\"{pid}\""))
916}
917
918#[cfg(not(any(unix, windows)))]
919pub(crate) fn process_alive(_pid: u32) -> bool {
920    true
921}
922
923#[cfg(test)]
924mod tests {
925    use super::*;
926    use std::sync::atomic::{AtomicUsize, Ordering};
927    use std::sync::{mpsc, Arc, Barrier};
928
929    fn test_config() -> LockConfig {
930        LockConfig {
931            heartbeat_interval_ms: 25,
932            stale_heartbeat_ms: 2_000,
933            live_owner_warn_ms: LIVE_OWNER_WARN_MS,
934            poll_interval_ms: 10,
935        }
936    }
937
938    fn test_lock_path() -> (tempfile::TempDir, PathBuf) {
939        let dir = tempfile::tempdir().expect("create temp dir");
940        let path = dir.path().join("test.lock");
941        (dir, path)
942    }
943
944    fn write_synthetic_lock(path: &Path, metadata: &LockMetadata) {
945        let mut file = open_new_lock_file(path).expect("create synthetic lock");
946        write_lock_metadata_to_file(&mut file, metadata).expect("write synthetic lock");
947    }
948
949    fn synthetic_metadata(pid: u32, hostname: String, created_at_ms: u64) -> LockMetadata {
950        LockMetadata {
951            pid,
952            hostname,
953            created_at_ms,
954            heartbeat_at_ms: created_at_ms,
955            writer_epoch: format!("synthetic-{pid}-{created_at_ms}"),
956        }
957    }
958
959    fn current_process_metadata() -> LockMetadata {
960        let now = now_ms();
961        synthetic_metadata(std::process::id(), current_hostname(), now)
962    }
963
964    #[test]
965    fn lock_operation_trace_lines_are_debug_not_info() {
966        let source = include_str!("fs_lock.rs");
967        assert!(source.contains("slog_debug!(\"acquired filesystem lock at {}\", path.display())"));
968        assert!(
969            source.contains("slog_debug!(\"released filesystem lock at {}\", self.path.display())")
970        );
971        assert!(!source.contains("slog_info!(\"acquired filesystem lock at {}\", path.display())"));
972        assert!(
973            !source.contains("slog_info!(\"released filesystem lock at {}\", self.path.display())")
974        );
975    }
976
977    #[test]
978    fn acquire_creates_lockfile_and_unlocks_on_drop() {
979        let (_dir, path) = test_lock_path();
980
981        let guard = acquire_with_config(&path, None, test_config()).expect("acquire lock");
982        let metadata = read_lock_metadata(&path).expect("read lock metadata");
983        assert_eq!(metadata.pid, std::process::id());
984        assert_eq!(metadata.hostname, current_hostname());
985        assert_eq!(metadata.created_at_ms, guard.metadata.created_at_ms);
986        assert_eq!(metadata.writer_epoch, guard.metadata.writer_epoch);
987        #[cfg(unix)]
988        {
989            use std::os::unix::fs::PermissionsExt;
990            assert_eq!(
991                fs::metadata(&path).unwrap().permissions().mode() & 0o777,
992                0o600
993            );
994        }
995
996        drop(guard);
997        assert!(!path.exists());
998    }
999
1000    #[test]
1001    fn permission_denied_is_treated_as_transient_create_contention() {
1002        // Windows surfaces a contended create/delete on the same lock path as
1003        // access-denied; acquire must retry these rather than fail the caller.
1004        let err = io::Error::from(io::ErrorKind::PermissionDenied);
1005        assert!(is_transient_create_contention(&err));
1006    }
1007
1008    #[test]
1009    fn unrelated_io_errors_are_not_treated_as_contention() {
1010        // A genuinely fatal error (e.g. the parent dir is missing) must still
1011        // propagate, not spin in the transient-retry arm.
1012        let err = io::Error::from(io::ErrorKind::NotFound);
1013        assert!(!is_transient_create_contention(&err));
1014    }
1015
1016    #[cfg(windows)]
1017    #[test]
1018    fn windows_sharing_violation_is_treated_as_transient_create_contention() {
1019        // ERROR_SHARING_VIOLATION (32) is the other contention code Windows
1020        // returns when a concurrent actor holds the path open mid-create.
1021        let err = io::Error::from_raw_os_error(32);
1022        assert!(is_transient_create_contention(&err));
1023    }
1024
1025    #[test]
1026    fn reclaim_refuses_to_delete_a_different_owners_lock() {
1027        let (_dir, path) = test_lock_path();
1028
1029        // A lock currently owned by "owner B".
1030        let owner_b = synthetic_metadata(4242, "host-b".to_string(), now_ms());
1031        create_lock_file_atomically(&path, &owner_b).expect("write owner B lock");
1032
1033        // We judged a DIFFERENT (older) owner A as stale. Reclaiming must NOT
1034        // delete B's lock (the TOCTOU split-brain guard).
1035        let judged_a = synthetic_metadata(1111, "host-a".to_string(), now_ms() - 1_000_000);
1036        let removed = reclaim_lock_file(&path, &judged_a).expect("reclaim");
1037        assert!(!removed, "must not remove a different owner's lock");
1038        assert!(path.exists(), "owner B's lock must survive");
1039        let still = read_lock_metadata(&path).expect("still readable");
1040        assert_eq!(still.pid, 4242, "owner B's lock intact");
1041    }
1042
1043    #[test]
1044    fn reclaim_deletes_when_identity_still_matches() {
1045        let (_dir, path) = test_lock_path();
1046        let owner = synthetic_metadata(1111, "host-a".to_string(), 5_000);
1047        create_lock_file_atomically(&path, &owner).expect("write lock");
1048
1049        // Same identity we judged → safe to remove.
1050        let removed = reclaim_lock_file(&path, &owner).expect("reclaim");
1051        assert!(removed, "matching-identity stale lock should be removed");
1052        assert!(!path.exists());
1053
1054        // Reclaiming a now-absent lock is a no-op, not an error.
1055        assert!(!reclaim_lock_file(&path, &owner).expect("reclaim missing"));
1056    }
1057
1058    #[test]
1059    fn try_acquire_once_never_waits_behind_live_owner() {
1060        let (_dir, path) = test_lock_path();
1061        let guard = acquire_with_config(&path, None, test_config()).expect("acquire lock");
1062        let contender_path = path.clone();
1063        let (started_tx, started_rx) = mpsc::sync_channel(1);
1064        let (result_tx, result_rx) = mpsc::sync_channel(1);
1065        let contender = std::thread::spawn(move || {
1066            let _ = started_tx.send(());
1067            let _ = result_tx.send(try_acquire_once(&contender_path));
1068        });
1069
1070        started_rx
1071            .recv_timeout(Duration::from_secs(5))
1072            .expect("contender should start");
1073        let result = match result_rx.recv_timeout(Duration::from_secs(2)) {
1074            Ok(result) => result,
1075            Err(mpsc::RecvTimeoutError::Disconnected) => {
1076                contender.join().expect("contender should not panic");
1077                panic!("contender exited without reporting a result");
1078            }
1079            Err(mpsc::RecvTimeoutError::Timeout) => {
1080                drop(guard);
1081                match result_rx.recv_timeout(Duration::from_secs(1)) {
1082                    Ok(_) | Err(mpsc::RecvTimeoutError::Disconnected) => {
1083                        let _ = contender.join();
1084                    }
1085                    Err(mpsc::RecvTimeoutError::Timeout) => {}
1086                }
1087                panic!("try-acquire blocked behind the live owner");
1088            }
1089        };
1090
1091        contender.join().expect("contender should exit");
1092        assert!(matches!(result, Err(AcquireError::Timeout)));
1093    }
1094
1095    #[test]
1096    fn acquire_serializes_concurrent_callers() {
1097        let (_dir, path) = test_lock_path();
1098        let path = Arc::new(path);
1099        let barrier = Arc::new(Barrier::new(3));
1100        let inside = Arc::new(AtomicUsize::new(0));
1101        let entered = Arc::new(AtomicUsize::new(0));
1102        let max_inside = Arc::new(AtomicUsize::new(0));
1103
1104        let mut handles = Vec::new();
1105        for _ in 0..2 {
1106            let path = Arc::clone(&path);
1107            let barrier = Arc::clone(&barrier);
1108            let inside = Arc::clone(&inside);
1109            let entered = Arc::clone(&entered);
1110            let max_inside = Arc::clone(&max_inside);
1111            handles.push(thread::spawn(move || {
1112                barrier.wait();
1113                let guard = acquire_with_config(&path, Some(Duration::from_secs(2)), test_config())
1114                    .expect("thread acquire lock");
1115                let previous = inside.fetch_add(1, Ordering::SeqCst);
1116                assert_eq!(previous, 0, "two lock holders overlapped");
1117                entered.fetch_add(1, Ordering::SeqCst);
1118                max_inside.fetch_max(previous + 1, Ordering::SeqCst);
1119                thread::sleep(Duration::from_millis(75));
1120                inside.fetch_sub(1, Ordering::SeqCst);
1121                drop(guard);
1122            }));
1123        }
1124
1125        barrier.wait();
1126        for handle in handles {
1127            handle.join().expect("join worker");
1128        }
1129
1130        assert_eq!(entered.load(Ordering::SeqCst), 2);
1131        assert_eq!(max_inside.load(Ordering::SeqCst), 1);
1132        assert!(!path.exists());
1133    }
1134
1135    #[test]
1136    fn failed_atomic_replacement_preserves_existing_destination() {
1137        let dir = tempfile::tempdir().expect("create temp dir");
1138        let source = dir.path().join("source.tmp");
1139        let destination = dir.path().join("artifact.bin");
1140        fs::write(&source, b"new artifact").expect("write source");
1141        fs::write(&destination, b"valid old artifact").expect("write destination");
1142
1143        let error = rename_over_with(&source, &destination, |_from, _to| {
1144            Err(io::Error::new(
1145                io::ErrorKind::PermissionDenied,
1146                "injected replacement failure",
1147            ))
1148        })
1149        .expect_err("replacement must fail");
1150
1151        assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
1152        assert_eq!(
1153            fs::read(&destination).expect("read preserved destination"),
1154            b"valid old artifact"
1155        );
1156        assert_eq!(
1157            fs::read(&source).expect("read retained source"),
1158            b"new artifact"
1159        );
1160    }
1161
1162    #[test]
1163    fn heartbeat_updates_lockfile_timestamp() {
1164        let (_dir, path) = test_lock_path();
1165        let guard = acquire_with_config(&path, None, test_config()).expect("acquire lock");
1166        let initial = read_lock_metadata(&path)
1167            .expect("read initial metadata")
1168            .heartbeat_at_ms;
1169
1170        // Poll for up to 2s rather than sleeping a fixed multiple of the
1171        // heartbeat interval. `park_timeout` is a *maximum* wait, not a
1172        // guaranteed periodic timer — under load (shared macOS CI runners
1173        // running other cargo-test threads concurrently) the heartbeat
1174        // thread may not fire 3 times within 75ms even though
1175        // heartbeat_interval_ms=25. The contract being asserted is "the
1176        // heartbeat advances eventually", not "it advances within N
1177        // heartbeat intervals".
1178        //
1179        let deadline = std::time::Instant::now() + Duration::from_millis(2_000);
1180        let mut updated = initial;
1181        while std::time::Instant::now() < deadline {
1182            thread::sleep(Duration::from_millis(50));
1183            match read_lock_metadata(&path) {
1184                Ok(meta) => {
1185                    updated = meta.heartbeat_at_ms;
1186                    if updated > initial {
1187                        break;
1188                    }
1189                }
1190                Err(ReadLockError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
1191                    // Heartbeat thread is mid-rewrite (Windows
1192                    // remove-then-rename window). Retry next iteration.
1193                    continue;
1194                }
1195                Err(other) => panic!("read updated metadata: {other:?}"),
1196            }
1197        }
1198        assert!(
1199            updated > initial,
1200            "heartbeat timestamp did not advance within 2s"
1201        );
1202        drop(guard);
1203    }
1204
1205    #[test]
1206    fn dead_pid_lock_is_reclaimed() {
1207        let (_dir, path) = test_lock_path();
1208        let metadata = synthetic_metadata(999_999_999, current_hostname(), now_ms());
1209        write_synthetic_lock(&path, &metadata);
1210
1211        let guard = acquire_with_config(&path, Some(Duration::from_secs(1)), test_config())
1212            .expect("reclaim dead pid lock");
1213        let metadata = read_lock_metadata(&path).expect("read reclaimed lock");
1214        assert_eq!(metadata.pid, std::process::id());
1215        drop(guard);
1216    }
1217
1218    #[test]
1219    fn zero_timeout_dead_pid_reclaim_acquires_after_removing_stale_file() {
1220        let (_dir, path) = test_lock_path();
1221        let metadata = synthetic_metadata(999_999_999, current_hostname(), now_ms());
1222        write_synthetic_lock(&path, &metadata);
1223
1224        let guard = acquire_with_config(&path, Some(Duration::ZERO), test_config())
1225            .expect("zero-timeout acquire should claim the reaped stale lock");
1226        let metadata = read_lock_metadata(&path).expect("read reclaimed lock");
1227        assert_eq!(metadata.pid, std::process::id());
1228        drop(guard);
1229    }
1230
1231    #[test]
1232    fn stale_heartbeat_from_live_pid_blocks() {
1233        let (_dir, path) = test_lock_path();
1234        let mut metadata = current_process_metadata();
1235        metadata.created_at_ms = now_ms().saturating_sub(60_000);
1236        metadata.heartbeat_at_ms = now_ms().saturating_sub(60_000);
1237        write_synthetic_lock(&path, &metadata);
1238
1239        let result = acquire_with_config(&path, Some(Duration::from_millis(80)), test_config());
1240        assert!(matches!(result, Err(AcquireError::Timeout)));
1241        assert_eq!(read_lock_metadata(&path).expect("read lock"), metadata);
1242
1243        remove_lock_file(&path).expect("cleanup synthetic lock");
1244    }
1245
1246    #[test]
1247    fn healthy_live_owner_blocks() {
1248        let (_dir, path) = test_lock_path();
1249        let metadata = current_process_metadata();
1250        write_synthetic_lock(&path, &metadata);
1251
1252        let result = acquire_with_config(&path, Some(Duration::from_millis(80)), test_config());
1253        assert!(matches!(result, Err(AcquireError::Timeout)));
1254
1255        remove_lock_file(&path).expect("cleanup synthetic lock");
1256    }
1257
1258    #[test]
1259    fn malformed_lockfile_is_reclaimed() {
1260        let (_dir, path) = test_lock_path();
1261        fs::write(&path, b"not valid json").expect("write malformed lock");
1262
1263        let guard = acquire_with_config(&path, Some(Duration::from_secs(1)), test_config())
1264            .expect("reclaim malformed lock");
1265        let metadata = read_lock_metadata(&path).expect("read reclaimed lock");
1266        assert_eq!(metadata.pid, std::process::id());
1267        drop(guard);
1268    }
1269
1270    #[test]
1271    fn cross_host_lock_is_not_stolen_before_extended_stale_threshold() {
1272        let (_dir, path) = test_lock_path();
1273        let now = now_ms();
1274        let metadata = LockMetadata {
1275            pid: std::process::id(),
1276            hostname: format!("{}-other", current_hostname()),
1277            created_at_ms: now,
1278            heartbeat_at_ms: now,
1279            writer_epoch: format!("cross-host-{now}"),
1280        };
1281        write_synthetic_lock(&path, &metadata);
1282
1283        let result = acquire_with_config(&path, Some(Duration::from_millis(80)), test_config());
1284        assert!(matches!(result, Err(AcquireError::Timeout)));
1285        assert_eq!(read_lock_metadata(&path).expect("read lock"), metadata);
1286
1287        remove_lock_file(&path).expect("cleanup synthetic lock");
1288    }
1289
1290    #[test]
1291    fn stale_cross_host_lock_is_reclaimed_after_extended_threshold() {
1292        let (_dir, path) = test_lock_path();
1293        let stale_at =
1294            now_ms().saturating_sub(test_config().cross_host_stale_heartbeat_ms() + 1_000);
1295        let metadata = LockMetadata {
1296            pid: std::process::id(),
1297            hostname: format!("{}-other", current_hostname()),
1298            created_at_ms: stale_at,
1299            heartbeat_at_ms: stale_at,
1300            writer_epoch: format!("cross-host-{stale_at}"),
1301        };
1302        write_synthetic_lock(&path, &metadata);
1303
1304        let guard = acquire_with_config(&path, Some(Duration::from_secs(1)), test_config())
1305            .expect("reclaim stale cross-host lock");
1306        let reclaimed = read_lock_metadata(&path).expect("read reclaimed lock");
1307        assert_eq!(reclaimed.hostname, current_hostname());
1308        assert_ne!(reclaimed.created_at_ms, metadata.created_at_ms);
1309        drop(guard);
1310    }
1311
1312    #[test]
1313    fn live_owner_over_10min_warns_but_blocks() {
1314        let (_dir, path) = test_lock_path();
1315        let mut metadata = current_process_metadata();
1316        metadata.created_at_ms = now_ms().saturating_sub(11 * 60 * 1_000);
1317        metadata.heartbeat_at_ms = now_ms();
1318        write_synthetic_lock(&path, &metadata);
1319
1320        let result = acquire_with_config(&path, Some(Duration::from_millis(80)), test_config());
1321        assert!(matches!(result, Err(AcquireError::Timeout)));
1322        assert_eq!(read_lock_metadata(&path).expect("read lock"), metadata);
1323
1324        remove_lock_file(&path).expect("cleanup synthetic lock");
1325    }
1326
1327    #[test]
1328    fn drop_stops_heartbeat_thread() {
1329        let (_dir, path) = test_lock_path();
1330        let guard = acquire_with_config(&path, None, test_config()).expect("acquire lock");
1331        drop(guard);
1332
1333        thread::sleep(Duration::from_millis(
1334            test_config().heartbeat_interval_ms * 3,
1335        ));
1336        assert!(
1337            !path.exists(),
1338            "heartbeat recreated or kept updating lockfile"
1339        );
1340    }
1341
1342    #[test]
1343    fn heartbeat_error_classification_terminal_vs_transient() {
1344        // Terminal: the lock is provably no longer ours to refresh.
1345        assert!(heartbeat_error_is_terminal(&HeartbeatError::LockGone));
1346        assert!(heartbeat_error_is_terminal(&HeartbeatError::NotOwner));
1347        // Transient: a temporary I/O hiccup or a read that raced a concurrent
1348        // writer. These must NOT kill the heartbeat — it retries instead.
1349        assert!(!heartbeat_error_is_terminal(&HeartbeatError::Io(
1350            io::Error::other("disk blip")
1351        )));
1352        let malformed: serde_json::Error =
1353            serde_json::from_str::<LockMetadata>("not json").unwrap_err();
1354        assert!(!heartbeat_error_is_terminal(&HeartbeatError::Malformed(
1355            malformed
1356        )));
1357    }
1358
1359    #[test]
1360    fn heartbeat_survives_transient_malformed_and_recovers() {
1361        // Regression: a single transient failure (e.g. a read that races a
1362        // concurrent writer and sees a momentarily-unparseable file) used to
1363        // permanently kill the heartbeat thread. The guard holder would then
1364        // run its critical section with a stale heartbeat_at_ms, letting
1365        // another process reclaim the lock after the stale window — concurrent
1366        // writers / split-brain. The heartbeat must instead retry and resume
1367        // refreshing once the file is readable again.
1368        let (_dir, path) = test_lock_path();
1369        let guard = acquire_with_config(&path, None, test_config()).expect("acquire lock");
1370        let owner = guard.metadata.clone();
1371
1372        // Corrupt the lockfile out from under the heartbeat (simulates a
1373        // concurrent-writer race producing a momentarily-unparseable read).
1374        // The heartbeat reads-then-writes, so it observes Malformed and, with
1375        // the fix, retries instead of dying.
1376        fs::write(&path, b"{ not valid json").expect("corrupt lockfile");
1377
1378        // Give the heartbeat several intervals to observe the malformed file.
1379        // Pre-fix, the thread is dead by now.
1380        thread::sleep(Duration::from_millis(
1381            test_config().heartbeat_interval_ms * 4,
1382        ));
1383
1384        // Restore valid owner metadata with a clearly-stale heartbeat sentinel.
1385        // Ownership fields must match `owner` exactly so heartbeat_once passes
1386        // its ownership check and writes a fresh timestamp.
1387        //
1388        // Use the atomic temp-write+rename path rather than remove-then-recreate:
1389        // a remove followed by a separate create leaves a window where the file
1390        // does not exist, and a heartbeat poll landing in that window reads
1391        // NotFound -> LockGone (terminal) and kills the thread, failing this test
1392        // spuriously under runner load (observed on macOS CI). The atomic replace
1393        // overwrites the corrupt file in place with no no-file window on Unix.
1394        let sentinel = now_ms().saturating_sub(1_000_000);
1395        let mut restored = owner.clone();
1396        restored.heartbeat_at_ms = sentinel;
1397        atomic_write_lock_metadata(&path, &restored).expect("atomically restore lock metadata");
1398
1399        // If the heartbeat thread is still alive (the fix), it will overwrite
1400        // heartbeat_at_ms with a current value. Poll for that recovery.
1401        let deadline = std::time::Instant::now() + Duration::from_millis(3_000);
1402        let mut recovered = false;
1403        while std::time::Instant::now() < deadline {
1404            thread::sleep(Duration::from_millis(25));
1405            match read_lock_metadata(&path) {
1406                Ok(meta)
1407                    if meta.created_at_ms == owner.created_at_ms
1408                        && meta.heartbeat_at_ms > sentinel =>
1409                {
1410                    recovered = true;
1411                    break;
1412                }
1413                _ => continue,
1414            }
1415        }
1416        assert!(
1417            recovered,
1418            "heartbeat did not recover after a transient malformed read — thread likely died"
1419        );
1420        drop(guard);
1421    }
1422}