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, PartialEq, Eq)]
79struct ProcessIdentity {
80    start_time: u64,
81    boot_id: Option<String>,
82}
83
84#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
85struct LockMetadata {
86    pid: u32,
87    hostname: String,
88    /// Identifies this PID incarnation so a process in a restarted PID namespace
89    /// cannot be mistaken for an owner that was hard-killed in an earlier launch.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    process_start_time: Option<u64>,
92    /// Linux start times are relative to boot. Keeping the boot ID alongside the
93    /// raw jiffies prevents a reboot from making a newly recycled PID look like
94    /// an earlier owner whose start time happened to match.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    boot_id: Option<String>,
97    created_at_ms: u64,
98    heartbeat_at_ms: u64,
99    /// Fencing nonce for writer leases. The owner re-reads the lock immediately
100    /// before publishing/writing and aborts if a stale guard has been usurped.
101    #[serde(default)]
102    writer_epoch: String,
103}
104
105/// Acquire a filesystem lock at `path`. Blocks until the lock is held.
106///
107/// The returned guard owns a background heartbeat thread; dropping it releases
108/// the lock and removes the lock file.
109pub fn acquire(path: &Path) -> Result<LockGuard, AcquireError> {
110    acquire_with_config(path, None, LockConfig::default())
111}
112
113/// Try to acquire a filesystem lock at `path` within `timeout`.
114pub fn try_acquire(path: &Path, timeout: Duration) -> Result<LockGuard, AcquireError> {
115    acquire_with_config(path, Some(timeout), LockConfig::default())
116}
117
118/// Try one lock acquisition attempt, then check once whether an existing stale
119/// lock can be taken over.
120///
121/// Read-only cache openers use this to switch to writer mode without waiting
122/// behind another process that is still building the cache.
123pub fn try_acquire_once(path: &Path) -> Result<LockGuard, AcquireError> {
124    try_acquire(path, Duration::ZERO)
125}
126
127pub struct LockGuard {
128    path: PathBuf,
129    metadata: LockMetadata,
130    shutdown: Arc<AtomicBool>,
131    heartbeat_failed: Arc<AtomicBool>,
132    heartbeat_done: mpsc::Receiver<()>,
133    heartbeat: Option<JoinHandle<()>>,
134}
135
136impl LockGuard {
137    pub fn path(&self) -> &Path {
138        &self.path
139    }
140
141    pub fn writer_epoch(&self) -> &str {
142        &self.metadata.writer_epoch
143    }
144
145    /// Re-read the lock file and confirm that this guard still owns the writer
146    /// token. Writers call this right before saving published data or starting
147    /// SQLite writes so they stop if another process has taken over the lock.
148    pub fn verify_writer_epoch(&self) -> io::Result<bool> {
149        if self.heartbeat_failed.load(Ordering::Acquire) {
150            return Ok(false);
151        }
152        match read_lock_metadata(&self.path) {
153            Ok(metadata) => Ok(lock_identity_matches(&metadata, &self.metadata)),
154            Err(ReadLockError::Io(error)) if error.kind() == io::ErrorKind::NotFound => Ok(false),
155            Err(ReadLockError::Io(error)) => Err(error),
156            Err(ReadLockError::Malformed(_)) => Ok(false),
157        }
158    }
159}
160
161impl Drop for LockGuard {
162    fn drop(&mut self) {
163        // Signal shutdown then unconditionally join the heartbeat thread
164        // BEFORE removing the lockfile. The earlier `recv_timeout(100ms)`
165        // implementation could let `remove_lock_if_owned` race with a
166        // still-alive heartbeat:
167        //
168        //   1. Drop signals shutdown, ack times out under CI load.
169        //   2. Drop calls `remove_lock_if_owned` → file removed.
170        //   3. Another caller acquires the lock → writes its metadata.
171        //   4. Our heartbeat (still alive, mid-`atomic_write_lock_metadata`
172        //      from before shutdown was checked) overwrites the new
173        //      owner's file with our stale metadata. heartbeat_once's
174        //      ownership check happens BEFORE the write, so it can race
175        //      with a concurrent acquire that flips ownership in between.
176        //   5. The new owner's heartbeat sees foreign metadata, exits
177        //      `NotOwner`. The new owner's drop sees foreign metadata,
178        //      `remove_lock_if_owned` returns `Ok(false)`, file persists.
179        //
180        // Always-joining bounds drop latency to one `park_timeout`
181        // iteration (~25ms) plus the current `heartbeat_once` IO —
182        // typically <500ms under CI load. The unused `heartbeat_done`
183        // channel is kept for backward compatibility with any external
184        // code that may still construct LockGuard manually, but Drop no
185        // longer relies on it.
186        self.shutdown.store(true, Ordering::Release);
187        if let Some(handle) = self.heartbeat.take() {
188            handle.thread().unpark();
189            let _ = handle.join();
190        }
191        // Drain any pending ack so the receiver doesn't carry stale state
192        // if this LockGuard is somehow re-used (it isn't today, but be
193        // defensive).
194        while self.heartbeat_done.try_recv().is_ok() {}
195
196        match remove_lock_if_owned(&self.path, &self.metadata) {
197            Ok(true) => slog_debug!("released filesystem lock at {}", self.path.display()),
198            Ok(false) => {}
199            Err(error) => slog_warn!(
200                "failed to release filesystem lock at {}: {}",
201                self.path.display(),
202                error
203            ),
204        }
205    }
206}
207
208#[derive(Debug)]
209pub enum AcquireError {
210    Io(io::Error),
211    Timeout,
212}
213
214impl fmt::Display for AcquireError {
215    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
216        match self {
217            AcquireError::Io(error) => write!(f, "filesystem lock I/O error: {error}"),
218            AcquireError::Timeout => write!(f, "timed out acquiring filesystem lock"),
219        }
220    }
221}
222
223impl std::error::Error for AcquireError {}
224
225impl From<io::Error> for AcquireError {
226    fn from(error: io::Error) -> Self {
227        AcquireError::Io(error)
228    }
229}
230
231fn acquire_with_config(
232    path: &Path,
233    timeout: Option<Duration>,
234    config: LockConfig,
235) -> Result<LockGuard, AcquireError> {
236    let deadline = timeout.map(|timeout| Instant::now() + timeout);
237    let hostname = current_hostname();
238    let mut warned_live_owner = false;
239    let mut warned_stale_live_owner = false;
240    let mut transient_create_failures: u32 = 0;
241    let mut attempted_once = false;
242    // A zero-timeout acquire still gets one immediate retry after it removes a
243    // stale lock; otherwise it would reap the dead owner and report Timeout.
244    let mut immediate_retry_budget = 0_u8;
245
246    loop {
247        if attempted_once {
248            if immediate_retry_budget > 0 {
249                immediate_retry_budget -= 1;
250            } else if let Some(deadline) = deadline {
251                if Instant::now() >= deadline {
252                    return Err(AcquireError::Timeout);
253                }
254            }
255        }
256        attempted_once = true;
257
258        match create_new_lock(path, &hostname, config) {
259            Ok(guard) => return Ok(guard),
260            // The lock file already exists — fall through to inspect its owner.
261            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
262            // Transient contention (chiefly Windows: a concurrent create/delete
263            // on this exact path surfaces as access-denied/sharing-violation
264            // rather than already-exists). Back off one poll interval and retry,
265            // bounded so a persistent failure still propagates instead of
266            // spinning forever.
267            Err(error) if is_transient_create_contention(&error) => {
268                transient_create_failures += 1;
269                if transient_create_failures > MAX_TRANSIENT_CREATE_RETRIES {
270                    return Err(error.into());
271                }
272                sleep_until_retry(deadline, config.poll_interval_ms)?;
273                continue;
274            }
275            Err(error) => return Err(error.into()),
276        }
277        transient_create_failures = 0;
278
279        let metadata = match read_lock_metadata(path) {
280            Ok(metadata) => metadata,
281            Err(ReadLockError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
282                immediate_retry_budget = 1;
283                continue;
284            }
285            Err(ReadLockError::Io(error)) => return Err(error.into()),
286            Err(ReadLockError::Malformed(error)) => {
287                // A just-created O_EXCL file is visible before its owner has
288                // finished writing JSON. Give that transient creation window
289                // one poll interval before treating malformed contents as stale.
290                sleep_until_retry(deadline, config.poll_interval_ms)?;
291                match read_lock_metadata(path) {
292                    Ok(_) => continue,
293                    Err(ReadLockError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
294                        continue;
295                    }
296                    Err(ReadLockError::Io(error)) => return Err(error.into()),
297                    Err(ReadLockError::Malformed(_)) => {}
298                }
299                slog_warn!(
300                    "removing malformed filesystem lock at {}: {}",
301                    path.display(),
302                    error
303                );
304                remove_lock_file(path)?;
305                immediate_retry_budget = 1;
306                continue;
307            }
308        };
309
310        let now = now_ms();
311        let since_heartbeat = now.saturating_sub(metadata.heartbeat_at_ms);
312
313        if metadata.hostname != hostname {
314            let cross_host_stale_ms = config.cross_host_stale_heartbeat_ms();
315            if since_heartbeat > cross_host_stale_ms {
316                slog_warn!(
317                    "reclaiming cross-host filesystem lock at {} from host {} after stale heartbeat ({}ms > {}ms)",
318                    path.display(),
319                    metadata.hostname,
320                    since_heartbeat,
321                    cross_host_stale_ms
322                );
323                // Compare-and-delete: only remove if it's still the SAME stale
324                // owner (a fresh owner may have acquired it in the gap).
325                if reclaim_lock_file(path, &metadata)? {
326                    immediate_retry_budget = 1;
327                }
328                continue;
329            }
330            sleep_until_retry(deadline, config.poll_interval_ms)?;
331            continue;
332        }
333
334        if !lock_owner_is_alive(&metadata) {
335            slog_warn!(
336                "removing filesystem lock at {} from dead or recycled PID {}",
337                path.display(),
338                metadata.pid
339            );
340            // Compare-and-delete: only remove if it's still this dead owner's
341            // lock. A fresh owner could have written a new lock (with a recycled
342            // or different PID) between our liveness check and the unlink.
343            if reclaim_lock_file(path, &metadata)? {
344                immediate_retry_budget = 1;
345            }
346            continue;
347        }
348
349        if since_heartbeat > config.stale_heartbeat_ms && !warned_stale_live_owner {
350            // Same-host PID plus start-time identity is authoritative. A
351            // SIGSTOP'd process, suspended VM, or sleeping laptop can miss
352            // heartbeats and later resume inside the critical section. Breaking
353            // that lock would allow split-brain writers, so a paused matching
354            // owner blocks acquirers until it resumes and releases the lock or
355            // dies. PID namespaces restart numbering after hard-killed owners,
356            // which turns this safety rule into a deadlock unless the start time
357            // distinguishes the unrelated process that reused the PID.
358            slog_warn!(
359                "filesystem lock at {} held by live PID {} has stale heartbeat ({}ms); NOT breaking",
360                path.display(),
361                metadata.pid,
362                since_heartbeat
363            );
364            warned_stale_live_owner = true;
365        }
366
367        let held_for = now.saturating_sub(metadata.created_at_ms);
368        if held_for > config.live_owner_warn_ms && !warned_live_owner {
369            slog_warn!(
370                "filesystem lock at {} held >10min by live heartbeating PID {}; NOT breaking",
371                path.display(),
372                metadata.pid
373            );
374            warned_live_owner = true;
375        }
376
377        sleep_until_retry(deadline, config.poll_interval_ms)?;
378    }
379}
380
381fn create_new_lock(path: &Path, hostname: &str, config: LockConfig) -> io::Result<LockGuard> {
382    let now = now_ms();
383    let pid = std::process::id();
384    let process_identity = process_identity(pid);
385    let metadata = LockMetadata {
386        pid,
387        hostname: hostname.to_string(),
388        process_start_time: process_identity
389            .as_ref()
390            .map(|identity| identity.start_time),
391        boot_id: process_identity.and_then(|identity| identity.boot_id),
392        created_at_ms: now,
393        heartbeat_at_ms: now,
394        writer_epoch: format!("{pid}-{}", now_nanos()),
395    };
396
397    create_lock_file_atomically(path, &metadata)?;
398
399    let shutdown = Arc::new(AtomicBool::new(false));
400    let heartbeat_failed = Arc::new(AtomicBool::new(false));
401    let (done_tx, done_rx) = mpsc::channel();
402    let heartbeat_path = path.to_path_buf();
403    let heartbeat_metadata = metadata.clone();
404    let heartbeat_shutdown = Arc::clone(&shutdown);
405    let heartbeat_failed_for_thread = Arc::clone(&heartbeat_failed);
406    let heartbeat = thread::Builder::new()
407        .name("aft-fs-lock-heartbeat".to_string())
408        .spawn(move || {
409            let heartbeat_shutdown_for_run = Arc::clone(&heartbeat_shutdown);
410            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
411                run_heartbeat(
412                    heartbeat_path,
413                    heartbeat_metadata,
414                    heartbeat_shutdown_for_run,
415                    config,
416                );
417            }));
418            if result.is_err() || !heartbeat_shutdown.load(Ordering::Acquire) {
419                heartbeat_failed_for_thread.store(true, Ordering::Release);
420            }
421            let _ = done_tx.send(());
422        })?;
423
424    slog_debug!("acquired filesystem lock at {}", path.display());
425
426    Ok(LockGuard {
427        path: path.to_path_buf(),
428        metadata,
429        shutdown,
430        heartbeat_failed,
431        heartbeat_done: done_rx,
432        heartbeat: Some(heartbeat),
433    })
434}
435
436fn run_heartbeat(
437    path: PathBuf,
438    owner: LockMetadata,
439    shutdown: Arc<AtomicBool>,
440    config: LockConfig,
441) {
442    // Number of consecutive heartbeat intervals that can be missed before the
443    // same-host stale window elapses and another process may reclaim the lock.
444    // Beyond this point a sustained failure is genuinely dangerous, so we
445    // escalate the log from warn to error — but we still keep retrying.
446    let stale_intervals = config
447        .stale_heartbeat_ms
448        .checked_div(config.heartbeat_interval_ms.max(1))
449        .unwrap_or(3)
450        .max(1);
451    let mut consecutive_transient_failures: u64 = 0;
452
453    loop {
454        thread::park_timeout(Duration::from_millis(config.heartbeat_interval_ms));
455        if shutdown.load(Ordering::Acquire) {
456            return;
457        }
458
459        match heartbeat_once(&path, &owner) {
460            Ok(()) => {
461                if consecutive_transient_failures > 0 {
462                    slog_info!(
463                        "filesystem lock at {} heartbeat recovered after {} transient failure(s)",
464                        path.display(),
465                        consecutive_transient_failures
466                    );
467                    consecutive_transient_failures = 0;
468                }
469            }
470            Err(error) if heartbeat_error_is_terminal(&error) => {
471                // Terminal states: the lock is provably gone or owned by
472                // someone else. Continuing to write would clobber a new owner's
473                // metadata (the exact race documented in LockGuard::drop), so
474                // stop heartbeating.
475                slog_error!(
476                    "{}; stopping heartbeat",
477                    terminal_heartbeat_message(&path, &error)
478                );
479                return;
480            }
481            Err(error) => {
482                // Transient states: a temporary I/O hiccup (disk/NFS blip,
483                // quota) or a read that raced a concurrent writer mid-write
484                // (momentarily unparseable file). A single such error must NOT
485                // permanently kill the heartbeat — that would silently stop
486                // refreshing heartbeat_at_ms while the guard holder keeps
487                // running its critical section, letting another process reclaim
488                // the lock after the stale window and produce concurrent
489                // writers. Log and retry on the next interval; a later success
490                // resumes heartbeating automatically.
491                consecutive_transient_failures += 1;
492                log_transient_heartbeat_failure(
493                    &path,
494                    &transient_heartbeat_reason(&error),
495                    consecutive_transient_failures,
496                    stale_intervals,
497                );
498            }
499        }
500    }
501}
502
503/// A heartbeat failure is terminal when the lock is provably no longer ours to
504/// refresh: it was removed (`LockGone`) or a different owner now holds it
505/// (`NotOwner`). I/O and malformed-read failures are treated as transient —
506/// they are typically temporary disk/NFS hiccups or a read that raced a
507/// concurrent writer — so the heartbeat retries rather than dying.
508fn heartbeat_error_is_terminal(error: &HeartbeatError) -> bool {
509    matches!(error, HeartbeatError::LockGone | HeartbeatError::NotOwner)
510}
511
512fn terminal_heartbeat_message(path: &Path, error: &HeartbeatError) -> String {
513    match error {
514        HeartbeatError::LockGone => {
515            format!("filesystem lock at {} disappeared", path.display())
516        }
517        HeartbeatError::NotOwner => format!(
518            "filesystem lock at {} is no longer owned by this guard",
519            path.display()
520        ),
521        // Not reachable for non-terminal errors, but keep a sensible string.
522        HeartbeatError::Io(error) => {
523            format!("filesystem lock at {} I/O error: {error}", path.display())
524        }
525        HeartbeatError::Malformed(error) => {
526            format!(
527                "filesystem lock at {} became malformed: {error}",
528                path.display()
529            )
530        }
531    }
532}
533
534fn transient_heartbeat_reason(error: &HeartbeatError) -> String {
535    match error {
536        HeartbeatError::Io(error) => format!("I/O error: {error}"),
537        HeartbeatError::Malformed(error) => format!("became malformed: {error}"),
538        HeartbeatError::LockGone => "lock disappeared".to_string(),
539        HeartbeatError::NotOwner => "lock no longer owned".to_string(),
540    }
541}
542
543/// Log a transient heartbeat failure, escalating to error exactly once when the
544/// failures have lasted long enough that the lock is now reclaimable by another
545/// owner. Beyond that point we stay quiet to avoid log spam while still
546/// retrying — the holder has already been warned the lock is at risk.
547fn log_transient_heartbeat_failure(
548    path: &Path,
549    reason: &str,
550    consecutive_failures: u64,
551    stale_intervals: u64,
552) {
553    if consecutive_failures < stale_intervals {
554        slog_warn!(
555            "transient failure to heartbeat filesystem lock at {}: {}; retrying (attempt {})",
556            path.display(),
557            reason,
558            consecutive_failures
559        );
560    } else if consecutive_failures == stale_intervals {
561        slog_error!(
562            "filesystem lock at {} has failed {} consecutive heartbeats: {}; \
563             the lock may now be reclaimed by another owner — continuing to retry",
564            path.display(),
565            consecutive_failures,
566            reason
567        );
568    }
569}
570
571fn heartbeat_once(path: &Path, owner: &LockMetadata) -> Result<(), HeartbeatError> {
572    let mut metadata = match read_lock_metadata(path) {
573        Ok(metadata) => metadata,
574        Err(ReadLockError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
575            return Err(HeartbeatError::LockGone);
576        }
577        Err(ReadLockError::Io(error)) => return Err(HeartbeatError::Io(error)),
578        Err(ReadLockError::Malformed(error)) => return Err(HeartbeatError::Malformed(error)),
579    };
580
581    if !lock_identity_matches(&metadata, owner) {
582        return Err(HeartbeatError::NotOwner);
583    }
584
585    metadata.heartbeat_at_ms = now_ms();
586    atomic_write_lock_metadata(path, &metadata).map_err(HeartbeatError::Io)
587}
588
589#[derive(Debug)]
590enum HeartbeatError {
591    Io(io::Error),
592    LockGone,
593    Malformed(serde_json::Error),
594    NotOwner,
595}
596
597#[derive(Debug)]
598enum ReadLockError {
599    Io(io::Error),
600    Malformed(serde_json::Error),
601}
602
603fn read_lock_metadata(path: &Path) -> Result<LockMetadata, ReadLockError> {
604    let bytes = fs::read(path).map_err(ReadLockError::Io)?;
605    serde_json::from_slice(&bytes).map_err(ReadLockError::Malformed)
606}
607
608#[cfg(unix)]
609fn open_new_lock_file(path: &Path) -> io::Result<File> {
610    use std::os::unix::fs::OpenOptionsExt;
611
612    OpenOptions::new()
613        .write(true)
614        .create_new(true)
615        .mode(0o600)
616        .open(path)
617}
618
619#[cfg(not(unix))]
620fn open_new_lock_file(path: &Path) -> io::Result<File> {
621    OpenOptions::new().write(true).create_new(true).open(path)
622}
623
624fn write_lock_metadata_to_file(file: &mut File, metadata: &LockMetadata) -> io::Result<()> {
625    serde_json::to_writer(&mut *file, metadata).map_err(io::Error::other)?;
626    file.write_all(b"\n")?;
627    file.sync_all()
628}
629
630fn create_lock_file_atomically(path: &Path, metadata: &LockMetadata) -> io::Result<()> {
631    let tmp_path = temp_path_for_lock(path);
632    let result = (|| {
633        let mut file = open_new_lock_file(&tmp_path)?;
634        write_lock_metadata_to_file(&mut file, metadata)?;
635        drop(file);
636
637        fs::hard_link(&tmp_path, path)?;
638        sync_parent(path);
639        Ok(())
640    })();
641
642    let _ = fs::remove_file(&tmp_path);
643    result
644}
645
646fn atomic_write_lock_metadata(path: &Path, metadata: &LockMetadata) -> io::Result<()> {
647    let tmp_path = temp_path_for_lock(path);
648    let write_result = (|| {
649        let mut file = open_new_lock_file(&tmp_path)?;
650        write_lock_metadata_to_file(&mut file, metadata)?;
651        drop(file);
652
653        rename_over(&tmp_path, path)?;
654        sync_parent(path);
655        Ok(())
656    })();
657
658    if write_result.is_err() {
659        let _ = fs::remove_file(&tmp_path);
660    }
661
662    write_result
663}
664
665#[cfg(any(windows, test))]
666fn rename_over_with(
667    from: &Path,
668    to: &Path,
669    replace: impl FnOnce(&Path, &Path) -> io::Result<()>,
670) -> io::Result<()> {
671    replace(from, to)
672}
673
674#[cfg(windows)]
675pub(crate) fn rename_over(from: &Path, to: &Path) -> io::Result<()> {
676    // MoveFileExW with MOVEFILE_REPLACE_EXISTING is the only replacement path
677    // here that preserves the old destination on failure. A copy fallback
678    // truncates the destination before copying and can expose partial bytes or
679    // destroy the last valid artifact if the copy fails midway. Callers retain
680    // their temp file cleanup and retry policy when an open handle prevents the
681    // atomic replacement.
682    // Closure instead of the bare `fs::rename` fn item: the generic fn item
683    // instantiates with concrete reference lifetimes and fails the
684    // higher-ranked `FnOnce(&Path, &Path)` bound on some targets.
685    rename_over_with(from, to, |from, to| fs::rename(from, to))
686}
687
688#[cfg(not(windows))]
689pub(crate) fn rename_over(from: &Path, to: &Path) -> io::Result<()> {
690    fs::rename(from, to)
691}
692
693// Per-thread counter that disambiguates temp lockfile paths for callers
694// inside the same process. `now_nanos()` alone is not unique enough on
695// Windows when two threads race to acquire the same lock (caught by the
696// `acquire_serializes_concurrent_callers` test): two threads sampling the
697// nanosecond clock within the same scheduler quantum produce identical
698// timestamps, both write to the same `.lock.tmp.<pid>.<nanos>` file, one
699// thread's `fs::remove_file(&tmp_path)` cleanup deletes the file before
700// the other thread's `fs::hard_link(&tmp_path, ...)` runs, and the loser
701// panics with `Io(Os { code: 2, NotFound })`.
702//
703// `AtomicU64` shared across threads makes every temp path unique within
704// the process regardless of clock resolution or scheduling races.
705static TEMP_LOCK_COUNTER: AtomicU64 = AtomicU64::new(0);
706
707fn temp_path_for_lock(path: &Path) -> PathBuf {
708    let file_name = path
709        .file_name()
710        .and_then(|name| name.to_str())
711        .unwrap_or("lock");
712    let seq = TEMP_LOCK_COUNTER.fetch_add(1, Ordering::Relaxed);
713    path.with_file_name(format!(
714        ".{file_name}.tmp.{}.{}.{}",
715        std::process::id(),
716        now_nanos(),
717        seq
718    ))
719}
720
721fn lock_identity_matches(left: &LockMetadata, right: &LockMetadata) -> bool {
722    left.pid == right.pid
723        && left.hostname == right.hostname
724        && left.process_start_time == right.process_start_time
725        && left.boot_id == right.boot_id
726        && left.created_at_ms == right.created_at_ms
727        && left.writer_epoch == right.writer_epoch
728}
729
730fn remove_lock_if_owned(path: &Path, owner: &LockMetadata) -> io::Result<bool> {
731    let metadata = match read_lock_metadata(path) {
732        Ok(metadata) => metadata,
733        Err(ReadLockError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
734            return Ok(false);
735        }
736        Err(ReadLockError::Io(error)) => return Err(error),
737        Err(ReadLockError::Malformed(_)) => return Ok(false),
738    };
739
740    if lock_identity_matches(&metadata, owner) {
741        remove_lock_file(path)?;
742        Ok(true)
743    } else {
744        Ok(false)
745    }
746}
747
748fn remove_lock_file(path: &Path) -> io::Result<()> {
749    match fs::remove_file(path) {
750        Ok(()) => Ok(()),
751        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
752        Err(error) => Err(error),
753    }
754}
755
756/// Reclaim (delete) a lock file we judged stale/dead, but ONLY if it still holds
757/// the SAME owner identity we evaluated. Between reading the metadata and
758/// deleting, the stale owner could release and a FRESH owner acquire — blindly
759/// `remove_file` would then delete the fresh owner's lock, allowing split-brain
760/// writers. Re-read immediately before the unlink and bail if the full owner
761/// identity changed or the file vanished. POSIX has no atomic compare-and-unlink,
762/// so a microscopic residual race remains, but this
763/// shrinks the window from the whole judgment/poll duration to a couple of
764/// syscalls — the standard mitigation. Returns true if we removed it.
765fn reclaim_lock_file(path: &Path, judged: &LockMetadata) -> io::Result<bool> {
766    let Some(_token) = acquire_reclaim_token(path)? else {
767        return Ok(false);
768    };
769    match read_lock_metadata(path) {
770        Ok(current) => {
771            if lock_identity_matches(&current, judged) {
772                remove_lock_file(path)?;
773                Ok(true)
774            } else {
775                // A different owner acquired it in the gap — do NOT delete.
776                Ok(false)
777            }
778        }
779        // Already gone (released/reclaimed by someone else) — nothing to do.
780        Err(ReadLockError::Io(error)) if error.kind() == io::ErrorKind::NotFound => Ok(false),
781        // Malformed now (mid-write by a new owner) — don't delete; retry next poll.
782        Err(ReadLockError::Malformed(_)) => Ok(false),
783        Err(ReadLockError::Io(error)) => Err(error),
784    }
785}
786
787struct ReclaimTokenGuard {
788    path: PathBuf,
789}
790
791impl Drop for ReclaimTokenGuard {
792    fn drop(&mut self) {
793        let _ = fs::remove_file(&self.path);
794        sync_parent(&self.path);
795    }
796}
797
798fn acquire_reclaim_token(lock_path: &Path) -> io::Result<Option<ReclaimTokenGuard>> {
799    let token_path = reclaim_token_path(lock_path);
800    let pid = std::process::id();
801    let process_identity = process_identity(pid);
802    let metadata = LockMetadata {
803        pid,
804        hostname: current_hostname(),
805        process_start_time: process_identity
806            .as_ref()
807            .map(|identity| identity.start_time),
808        boot_id: process_identity.and_then(|identity| identity.boot_id),
809        created_at_ms: now_ms(),
810        heartbeat_at_ms: now_ms(),
811        writer_epoch: format!("reclaim-{pid}-{}", now_nanos()),
812    };
813    let mut file = match open_new_lock_file(&token_path) {
814        Ok(file) => file,
815        Err(error) if error.kind() == io::ErrorKind::AlreadyExists => return Ok(None),
816        Err(error) => return Err(error),
817    };
818    if let Err(error) = write_lock_metadata_to_file(&mut file, &metadata) {
819        let _ = fs::remove_file(&token_path);
820        return Err(error);
821    }
822    sync_parent(&token_path);
823    Ok(Some(ReclaimTokenGuard { path: token_path }))
824}
825
826fn reclaim_token_path(lock_path: &Path) -> PathBuf {
827    let file_name = lock_path
828        .file_name()
829        .and_then(|name| name.to_str())
830        .unwrap_or("lock");
831    lock_path.with_file_name(format!(".{file_name}.reclaim"))
832}
833
834#[cfg(test)]
835thread_local! {
836    // The observer is thread-local so concurrent lock tests cannot record one
837    // another's retry decisions.
838    static RETRY_SLEEP_OBSERVER: std::cell::RefCell<Option<Arc<std::sync::atomic::AtomicUsize>>> =
839        const { std::cell::RefCell::new(None) };
840}
841
842#[cfg(test)]
843struct RetrySleepObserverGuard {
844    previous: Option<Arc<std::sync::atomic::AtomicUsize>>,
845}
846
847#[cfg(test)]
848impl Drop for RetrySleepObserverGuard {
849    fn drop(&mut self) {
850        RETRY_SLEEP_OBSERVER.with(|observer| {
851            *observer.borrow_mut() = self.previous.take();
852        });
853    }
854}
855
856#[cfg(test)]
857fn observe_retry_sleeps_for_test(
858    observer: Arc<std::sync::atomic::AtomicUsize>,
859) -> RetrySleepObserverGuard {
860    let previous = RETRY_SLEEP_OBSERVER.with(|slot| slot.replace(Some(observer)));
861    RetrySleepObserverGuard { previous }
862}
863
864#[cfg(test)]
865fn note_retry_sleep_for_test() {
866    RETRY_SLEEP_OBSERVER.with(|observer| {
867        if let Some(observer) = observer.borrow().as_ref() {
868            observer.fetch_add(1, Ordering::SeqCst);
869        }
870    });
871}
872
873fn sleep_until_retry(deadline: Option<Instant>, poll_interval_ms: u64) -> Result<(), AcquireError> {
874    let poll = Duration::from_millis(poll_interval_ms);
875    let sleep_for = match deadline {
876        Some(deadline) => {
877            let now = Instant::now();
878            if now >= deadline {
879                return Err(AcquireError::Timeout);
880            }
881            poll.min(deadline.saturating_duration_since(now))
882        }
883        None => poll,
884    };
885    #[cfg(test)]
886    note_retry_sleep_for_test();
887    thread::sleep(sleep_for);
888    Ok(())
889}
890
891pub(crate) fn sync_parent(path: &Path) {
892    if let Some(parent) = path.parent() {
893        if let Ok(dir) = File::open(parent) {
894            let _ = dir.sync_all();
895        }
896    }
897}
898
899fn now_ms() -> u64 {
900    SystemTime::now()
901        .duration_since(UNIX_EPOCH)
902        .unwrap_or(Duration::ZERO)
903        .as_millis() as u64
904}
905
906fn now_nanos() -> u128 {
907    SystemTime::now()
908        .duration_since(UNIX_EPOCH)
909        .unwrap_or(Duration::ZERO)
910        .as_nanos()
911}
912
913#[cfg(unix)]
914fn current_hostname() -> String {
915    let mut buffer = [0u8; 256];
916    let result = unsafe { libc::gethostname(buffer.as_mut_ptr().cast(), buffer.len()) };
917    if result == 0 {
918        let len = buffer
919            .iter()
920            .position(|byte| *byte == 0)
921            .unwrap_or(buffer.len());
922        if len > 0 {
923            return String::from_utf8_lossy(&buffer[..len]).into_owned();
924        }
925    }
926
927    std::env::var("HOSTNAME").unwrap_or_else(|_| "unknown-host".to_string())
928}
929
930#[cfg(windows)]
931fn current_hostname() -> String {
932    std::env::var("COMPUTERNAME")
933        .or_else(|_| std::env::var("HOSTNAME"))
934        .unwrap_or_else(|_| "unknown-host".to_string())
935}
936
937#[cfg(not(any(unix, windows)))]
938fn current_hostname() -> String {
939    std::env::var("HOSTNAME").unwrap_or_else(|_| "unknown-host".to_string())
940}
941
942/// Returns whether a same-host lock owner is still the same process instance.
943///
944/// Old leases have no start time, so they deliberately retain PID-only liveness
945/// for backward compatibility. If an OS lookup cannot attest a recorded start
946/// time, keep the PID alive: incorrectly reclaiming a paused owner is worse than
947/// waiting for a process that may release the lock later.
948fn lock_owner_is_alive(metadata: &LockMetadata) -> bool {
949    if !process_alive(metadata.pid) {
950        return false;
951    }
952
953    let Some(recorded_start_time) = metadata.process_start_time else {
954        return true;
955    };
956    let Some(current_identity) = process_identity(metadata.pid) else {
957        return true;
958    };
959
960    if current_identity.start_time != recorded_start_time {
961        return false;
962    }
963
964    match &metadata.boot_id {
965        Some(recorded_boot_id) => current_identity
966            .boot_id
967            .as_deref()
968            .map_or(true, |current_boot_id| current_boot_id == recorded_boot_id),
969        None => true,
970    }
971}
972
973#[cfg(target_os = "linux")]
974fn process_identity(pid: u32) -> Option<ProcessIdentity> {
975    // Field 22 is starttime. Split after the final ')' because a process name is
976    // allowed to contain spaces and parentheses.
977    let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
978    let start_time = stat
979        .rsplit_once(") ")?
980        .1
981        .split_ascii_whitespace()
982        .nth(19)?
983        .parse()
984        .ok()?;
985    let boot_id = fs::read_to_string("/proc/sys/kernel/random/boot_id")
986        .ok()?
987        .trim()
988        .to_owned();
989    if boot_id.is_empty() {
990        return None;
991    }
992
993    Some(ProcessIdentity {
994        start_time,
995        boot_id: Some(boot_id),
996    })
997}
998
999#[cfg(target_os = "macos")]
1000fn process_identity(pid: u32) -> Option<ProcessIdentity> {
1001    if pid == 0 || pid > i32::MAX as u32 {
1002        return None;
1003    }
1004
1005    // PROC_PIDTBSDINFO supplies the kernel-recorded process birth time without
1006    // spawning a command or trusting user-controlled process metadata.
1007    const PROC_PIDTBSDINFO: libc::c_int = 3;
1008    let mut info = std::mem::MaybeUninit::<libc::proc_bsdinfo>::zeroed();
1009    let size = std::mem::size_of::<libc::proc_bsdinfo>() as libc::c_int;
1010    let written = unsafe {
1011        proc_pidinfo(
1012            pid as libc::c_int,
1013            PROC_PIDTBSDINFO,
1014            0,
1015            info.as_mut_ptr().cast(),
1016            size,
1017        )
1018    };
1019    if written != size {
1020        return None;
1021    }
1022    let info = unsafe { info.assume_init() };
1023    let start_time = info
1024        .pbi_start_tvsec
1025        .checked_mul(1_000_000)?
1026        .checked_add(info.pbi_start_tvusec)?;
1027
1028    Some(ProcessIdentity {
1029        start_time,
1030        boot_id: None,
1031    })
1032}
1033
1034#[cfg(windows)]
1035fn process_identity(_pid: u32) -> Option<ProcessIdentity> {
1036    // Windows keeps PID-only liveness for now. Querying creation time requires a
1037    // process handle and new FFI/error policy; Linux PID namespaces are the
1038    // environment where reused PIDs otherwise persistently deadlock leases.
1039    None
1040}
1041
1042#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
1043fn process_identity(_pid: u32) -> Option<ProcessIdentity> {
1044    None
1045}
1046
1047#[cfg(target_os = "macos")]
1048unsafe extern "C" {
1049    fn proc_pidinfo(
1050        pid: libc::c_int,
1051        flavor: libc::c_int,
1052        arg: u64,
1053        buffer: *mut libc::c_void,
1054        buffersize: libc::c_int,
1055    ) -> libc::c_int;
1056}
1057
1058#[cfg(unix)]
1059pub(crate) fn process_alive(pid: u32) -> bool {
1060    if pid == 0 || pid > i32::MAX as u32 {
1061        return false;
1062    }
1063
1064    let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
1065    if result == 0 {
1066        return true;
1067    }
1068
1069    io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
1070}
1071
1072#[cfg(windows)]
1073pub(crate) fn process_alive(pid: u32) -> bool {
1074    if pid == 0 {
1075        return false;
1076    }
1077    let filter = format!("PID eq {pid}");
1078    let Ok(output) = std::process::Command::new("tasklist")
1079        .args(["/FI", &filter, "/FO", "CSV", "/NH"])
1080        .output()
1081    else {
1082        return true;
1083    };
1084
1085    if !output.status.success() {
1086        return true;
1087    }
1088
1089    let stdout = String::from_utf8_lossy(&output.stdout);
1090
1091    // `tasklist /NH /FO CSV` emits a single line per matching process with
1092    // every field quoted, e.g. `"image","7420","Console","1","12,345 K"`.
1093    // When the filter matches nothing, the literal text
1094    // `INFO: No tasks are running which match the specified criteria.`
1095    // is written to stdout. The previous matcher was too strict — it looked
1096    // for `","{pid}",` patterns mid-line, which works on most Windows builds
1097    // but missed Windows runners that emit slightly different quoting (e.g.
1098    // a trailing CRLF leaves the pid token at end-of-line as `"7420"\r\n`).
1099    // The robust check: confirm the "no tasks" sentinel is absent AND any
1100    // PID-quoted form is present.
1101    if stdout.contains("No tasks are running") {
1102        return false;
1103    }
1104    stdout.contains(&format!("\"{pid}\""))
1105}
1106
1107#[cfg(not(any(unix, windows)))]
1108pub(crate) fn process_alive(_pid: u32) -> bool {
1109    true
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114    use super::*;
1115    use std::sync::atomic::{AtomicUsize, Ordering};
1116    use std::sync::{mpsc, Arc, Barrier};
1117
1118    fn test_config() -> LockConfig {
1119        LockConfig {
1120            heartbeat_interval_ms: 25,
1121            stale_heartbeat_ms: 2_000,
1122            live_owner_warn_ms: LIVE_OWNER_WARN_MS,
1123            poll_interval_ms: 10,
1124        }
1125    }
1126
1127    fn test_lock_path() -> (tempfile::TempDir, PathBuf) {
1128        let dir = tempfile::tempdir().expect("create temp dir");
1129        let path = dir.path().join("test.lock");
1130        (dir, path)
1131    }
1132
1133    fn write_synthetic_lock(path: &Path, metadata: &LockMetadata) {
1134        let mut file = open_new_lock_file(path).expect("create synthetic lock");
1135        write_lock_metadata_to_file(&mut file, metadata).expect("write synthetic lock");
1136    }
1137
1138    #[derive(Serialize)]
1139    struct LegacyLockMetadata<'a> {
1140        pid: u32,
1141        hostname: &'a str,
1142        created_at_ms: u64,
1143        heartbeat_at_ms: u64,
1144        writer_epoch: &'a str,
1145    }
1146
1147    fn write_legacy_lock(path: &Path, metadata: &LockMetadata) -> String {
1148        let legacy = LegacyLockMetadata {
1149            pid: metadata.pid,
1150            hostname: &metadata.hostname,
1151            created_at_ms: metadata.created_at_ms,
1152            heartbeat_at_ms: metadata.heartbeat_at_ms,
1153            writer_epoch: &metadata.writer_epoch,
1154        };
1155        let contents = format!(
1156            "{}\n",
1157            serde_json::to_string(&legacy).expect("serialize legacy lock")
1158        );
1159        fs::write(path, &contents).expect("write legacy synthetic lock");
1160        contents
1161    }
1162
1163    fn synthetic_metadata(pid: u32, hostname: String, created_at_ms: u64) -> LockMetadata {
1164        LockMetadata {
1165            pid,
1166            hostname,
1167            // Synthetic metadata defaults to the legacy shape so tests must opt
1168            // in when they need to exercise process-instance identity.
1169            process_start_time: None,
1170            boot_id: None,
1171            created_at_ms,
1172            heartbeat_at_ms: created_at_ms,
1173            writer_epoch: format!("synthetic-{pid}-{created_at_ms}"),
1174        }
1175    }
1176
1177    fn current_process_metadata() -> LockMetadata {
1178        let now = now_ms();
1179        let pid = std::process::id();
1180        let process_identity = process_identity(pid);
1181        let mut metadata = synthetic_metadata(pid, current_hostname(), now);
1182        metadata.process_start_time = process_identity
1183            .as_ref()
1184            .map(|identity| identity.start_time);
1185        metadata.boot_id = process_identity.and_then(|identity| identity.boot_id);
1186        metadata
1187    }
1188
1189    fn different_start_time(start_time: u64) -> u64 {
1190        start_time.checked_add(1).unwrap_or(start_time - 1)
1191    }
1192
1193    #[test]
1194    fn lock_operation_trace_lines_are_debug_not_info() {
1195        let source = include_str!("fs_lock.rs");
1196        assert!(source.contains("slog_debug!(\"acquired filesystem lock at {}\", path.display())"));
1197        assert!(
1198            source.contains("slog_debug!(\"released filesystem lock at {}\", self.path.display())")
1199        );
1200        assert!(!source.contains("slog_info!(\"acquired filesystem lock at {}\", path.display())"));
1201        assert!(
1202            !source.contains("slog_info!(\"released filesystem lock at {}\", self.path.display())")
1203        );
1204    }
1205
1206    #[test]
1207    fn acquire_creates_lockfile_and_unlocks_on_drop() {
1208        let (_dir, path) = test_lock_path();
1209
1210        let guard = acquire_with_config(&path, None, test_config()).expect("acquire lock");
1211        let metadata = read_lock_metadata(&path).expect("read lock metadata");
1212        assert_eq!(metadata.pid, std::process::id());
1213        assert_eq!(metadata.hostname, current_hostname());
1214        assert_eq!(metadata.created_at_ms, guard.metadata.created_at_ms);
1215        assert_eq!(metadata.writer_epoch, guard.metadata.writer_epoch);
1216        #[cfg(unix)]
1217        {
1218            use std::os::unix::fs::PermissionsExt;
1219            assert_eq!(
1220                fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1221                0o600
1222            );
1223        }
1224
1225        drop(guard);
1226        assert!(!path.exists());
1227    }
1228
1229    #[test]
1230    fn permission_denied_is_treated_as_transient_create_contention() {
1231        // Windows surfaces a contended create/delete on the same lock path as
1232        // access-denied; acquire must retry these rather than fail the caller.
1233        let err = io::Error::from(io::ErrorKind::PermissionDenied);
1234        assert!(is_transient_create_contention(&err));
1235    }
1236
1237    #[test]
1238    fn unrelated_io_errors_are_not_treated_as_contention() {
1239        // A genuinely fatal error (e.g. the parent dir is missing) must still
1240        // propagate, not spin in the transient-retry arm.
1241        let err = io::Error::from(io::ErrorKind::NotFound);
1242        assert!(!is_transient_create_contention(&err));
1243    }
1244
1245    #[cfg(windows)]
1246    #[test]
1247    fn windows_sharing_violation_is_treated_as_transient_create_contention() {
1248        // ERROR_SHARING_VIOLATION (32) is the other contention code Windows
1249        // returns when a concurrent actor holds the path open mid-create.
1250        let err = io::Error::from_raw_os_error(32);
1251        assert!(is_transient_create_contention(&err));
1252    }
1253
1254    #[test]
1255    fn reclaim_refuses_to_delete_a_different_owners_lock() {
1256        let (_dir, path) = test_lock_path();
1257
1258        // A lock currently owned by "owner B".
1259        let owner_b = synthetic_metadata(4242, "host-b".to_string(), now_ms());
1260        create_lock_file_atomically(&path, &owner_b).expect("write owner B lock");
1261
1262        // We judged a DIFFERENT (older) owner A as stale. Reclaiming must NOT
1263        // delete B's lock (the TOCTOU split-brain guard).
1264        let judged_a = synthetic_metadata(1111, "host-a".to_string(), now_ms() - 1_000_000);
1265        let removed = reclaim_lock_file(&path, &judged_a).expect("reclaim");
1266        assert!(!removed, "must not remove a different owner's lock");
1267        assert!(path.exists(), "owner B's lock must survive");
1268        let still = read_lock_metadata(&path).expect("still readable");
1269        assert_eq!(still.pid, 4242, "owner B's lock intact");
1270    }
1271
1272    #[test]
1273    fn reclaim_deletes_when_identity_still_matches() {
1274        let (_dir, path) = test_lock_path();
1275        let owner = synthetic_metadata(1111, "host-a".to_string(), 5_000);
1276        create_lock_file_atomically(&path, &owner).expect("write lock");
1277
1278        // Same identity we judged → safe to remove.
1279        let removed = reclaim_lock_file(&path, &owner).expect("reclaim");
1280        assert!(removed, "matching-identity stale lock should be removed");
1281        assert!(!path.exists());
1282
1283        // Reclaiming a now-absent lock is a no-op, not an error.
1284        assert!(!reclaim_lock_file(&path, &owner).expect("reclaim missing"));
1285    }
1286
1287    #[test]
1288    fn try_acquire_once_never_waits_behind_live_owner() {
1289        const OUTER_THREAD_JOIN_TIMEOUT: Duration = Duration::from_secs(30);
1290
1291        let (_dir, path) = test_lock_path();
1292        let guard = acquire_with_config(&path, None, test_config()).expect("acquire lock");
1293        let contender_path = path.clone();
1294        let sleeper_entries = Arc::new(AtomicUsize::new(0));
1295        let contender_sleeper_entries = Arc::clone(&sleeper_entries);
1296        let (announced_tx, announced_rx) = mpsc::sync_channel(1);
1297        let (enter_tx, enter_rx) = mpsc::sync_channel::<()>(1);
1298        let (result_tx, result_rx) = mpsc::sync_channel(1);
1299        let contender = std::thread::spawn(move || {
1300            let _ = announced_tx.send(());
1301            let _ = enter_rx.recv();
1302            let _observer = observe_retry_sleeps_for_test(contender_sleeper_entries);
1303            let _ = result_tx.send(try_acquire_once(&contender_path));
1304        });
1305
1306        announced_rx
1307            .recv_timeout(OUTER_THREAD_JOIN_TIMEOUT)
1308            .expect("contender should announce before the controlled enter gate");
1309        // Keep the announce-to-enter gap under channel control rather than
1310        // charging an arbitrary scheduler pause to the acquisition decision.
1311        enter_tx
1312            .send(())
1313            .expect("contender should wait at the controlled enter gate");
1314
1315        let result = match result_rx.recv_timeout(OUTER_THREAD_JOIN_TIMEOUT) {
1316            Ok(result) => result,
1317            Err(error) => {
1318                drop(guard);
1319                let _ = result_rx.recv_timeout(OUTER_THREAD_JOIN_TIMEOUT);
1320                let _ = contender.join();
1321                panic!("contender did not finish before the outer join bound: {error}");
1322            }
1323        };
1324
1325        contender.join().expect("contender should exit");
1326        assert!(matches!(result, Err(AcquireError::Timeout)));
1327        assert_eq!(
1328            sleeper_entries.load(Ordering::SeqCst),
1329            0,
1330            "zero-timeout acquisition must return Timeout without sleeping"
1331        );
1332    }
1333
1334    #[test]
1335    fn acquire_serializes_concurrent_callers() {
1336        let (_dir, path) = test_lock_path();
1337        let path = Arc::new(path);
1338        let barrier = Arc::new(Barrier::new(3));
1339        let inside = Arc::new(AtomicUsize::new(0));
1340        let entered = Arc::new(AtomicUsize::new(0));
1341        let max_inside = Arc::new(AtomicUsize::new(0));
1342
1343        let mut handles = Vec::new();
1344        for _ in 0..2 {
1345            let path = Arc::clone(&path);
1346            let barrier = Arc::clone(&barrier);
1347            let inside = Arc::clone(&inside);
1348            let entered = Arc::clone(&entered);
1349            let max_inside = Arc::clone(&max_inside);
1350            handles.push(thread::spawn(move || {
1351                barrier.wait();
1352                let guard = acquire_with_config(&path, Some(Duration::from_secs(2)), test_config())
1353                    .expect("thread acquire lock");
1354                let previous = inside.fetch_add(1, Ordering::SeqCst);
1355                assert_eq!(previous, 0, "two lock holders overlapped");
1356                entered.fetch_add(1, Ordering::SeqCst);
1357                max_inside.fetch_max(previous + 1, Ordering::SeqCst);
1358                thread::sleep(Duration::from_millis(75));
1359                inside.fetch_sub(1, Ordering::SeqCst);
1360                drop(guard);
1361            }));
1362        }
1363
1364        barrier.wait();
1365        for handle in handles {
1366            handle.join().expect("join worker");
1367        }
1368
1369        assert_eq!(entered.load(Ordering::SeqCst), 2);
1370        assert_eq!(max_inside.load(Ordering::SeqCst), 1);
1371        assert!(!path.exists());
1372    }
1373
1374    #[test]
1375    fn failed_atomic_replacement_preserves_existing_destination() {
1376        let dir = tempfile::tempdir().expect("create temp dir");
1377        let source = dir.path().join("source.tmp");
1378        let destination = dir.path().join("artifact.bin");
1379        fs::write(&source, b"new artifact").expect("write source");
1380        fs::write(&destination, b"valid old artifact").expect("write destination");
1381
1382        let error = rename_over_with(&source, &destination, |_from, _to| {
1383            Err(io::Error::new(
1384                io::ErrorKind::PermissionDenied,
1385                "injected replacement failure",
1386            ))
1387        })
1388        .expect_err("replacement must fail");
1389
1390        assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
1391        assert_eq!(
1392            fs::read(&destination).expect("read preserved destination"),
1393            b"valid old artifact"
1394        );
1395        assert_eq!(
1396            fs::read(&source).expect("read retained source"),
1397            b"new artifact"
1398        );
1399    }
1400
1401    #[test]
1402    fn heartbeat_updates_lockfile_timestamp() {
1403        let (_dir, path) = test_lock_path();
1404        let guard = acquire_with_config(&path, None, test_config()).expect("acquire lock");
1405        let initial_metadata = read_lock_metadata(&path).expect("read initial metadata");
1406        let initial = initial_metadata.heartbeat_at_ms;
1407
1408        // Poll for up to 2s rather than sleeping a fixed multiple of the
1409        // heartbeat interval. `park_timeout` is a *maximum* wait, not a
1410        // guaranteed periodic timer — under load (shared macOS CI runners
1411        // running other cargo-test threads concurrently) the heartbeat
1412        // thread may not fire 3 times within 75ms even though
1413        // heartbeat_interval_ms=25. The contract being asserted is "the
1414        // heartbeat advances eventually", not "it advances within N
1415        // heartbeat intervals".
1416        //
1417        let deadline = std::time::Instant::now() + Duration::from_millis(2_000);
1418        let mut updated = initial;
1419        while std::time::Instant::now() < deadline {
1420            thread::sleep(Duration::from_millis(50));
1421            match read_lock_metadata(&path) {
1422                Ok(meta) => {
1423                    updated = meta.heartbeat_at_ms;
1424                    if updated > initial {
1425                        break;
1426                    }
1427                }
1428                Err(ReadLockError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
1429                    // Heartbeat thread is mid-rewrite (Windows
1430                    // remove-then-rename window). Retry next iteration.
1431                    continue;
1432                }
1433                Err(other) => panic!("read updated metadata: {other:?}"),
1434            }
1435        }
1436        assert!(
1437            updated > initial,
1438            "heartbeat timestamp did not advance within 2s"
1439        );
1440        let updated_metadata = read_lock_metadata(&path).expect("read final metadata");
1441        assert_eq!(
1442            updated_metadata.process_start_time, guard.metadata.process_start_time,
1443            "heartbeat rewrite must preserve the owner's process start time"
1444        );
1445        assert_eq!(
1446            updated_metadata.boot_id, guard.metadata.boot_id,
1447            "heartbeat rewrite must preserve the owner's boot identity"
1448        );
1449        drop(guard);
1450    }
1451
1452    #[test]
1453    fn dead_pid_lock_is_reclaimed() {
1454        let (_dir, path) = test_lock_path();
1455        let metadata = synthetic_metadata(999_999_999, current_hostname(), now_ms());
1456        write_synthetic_lock(&path, &metadata);
1457
1458        let guard = acquire_with_config(&path, Some(Duration::from_secs(1)), test_config())
1459            .expect("reclaim dead pid lock");
1460        let metadata = read_lock_metadata(&path).expect("read reclaimed lock");
1461        assert_eq!(metadata.pid, std::process::id());
1462        drop(guard);
1463    }
1464
1465    #[test]
1466    fn zero_timeout_dead_pid_reclaim_acquires_after_removing_stale_file() {
1467        let (_dir, path) = test_lock_path();
1468        let metadata = synthetic_metadata(999_999_999, current_hostname(), now_ms());
1469        write_synthetic_lock(&path, &metadata);
1470
1471        let guard = acquire_with_config(&path, Some(Duration::ZERO), test_config())
1472            .expect("zero-timeout acquire should claim the reaped stale lock");
1473        let metadata = read_lock_metadata(&path).expect("read reclaimed lock");
1474        assert_eq!(metadata.pid, std::process::id());
1475        drop(guard);
1476    }
1477
1478    #[test]
1479    fn stale_heartbeat_from_live_pid_blocks() {
1480        let (_dir, path) = test_lock_path();
1481        let mut metadata = current_process_metadata();
1482        #[cfg(any(target_os = "linux", target_os = "macos"))]
1483        {
1484            let identity = process_identity(std::process::id())
1485                .expect("current process should have a start-time identity");
1486            assert_eq!(metadata.process_start_time, Some(identity.start_time));
1487            assert_eq!(metadata.boot_id, identity.boot_id);
1488        }
1489        metadata.created_at_ms = now_ms().saturating_sub(60_000);
1490        metadata.heartbeat_at_ms = now_ms().saturating_sub(60_000);
1491        write_synthetic_lock(&path, &metadata);
1492
1493        let result = acquire_with_config(&path, Some(Duration::from_millis(80)), test_config());
1494        assert!(matches!(result, Err(AcquireError::Timeout)));
1495        assert_eq!(read_lock_metadata(&path).expect("read lock"), metadata);
1496
1497        remove_lock_file(&path).expect("cleanup synthetic lock");
1498    }
1499
1500    #[cfg(any(target_os = "linux", target_os = "macos"))]
1501    #[test]
1502    fn live_pid_with_wrong_start_time_is_reclaimed() {
1503        let (_dir, path) = test_lock_path();
1504        let mut metadata = current_process_metadata();
1505        let current_identity = process_identity(std::process::id())
1506            .expect("current process should have a start-time identity");
1507        metadata.process_start_time = Some(different_start_time(current_identity.start_time));
1508        metadata.boot_id = current_identity.boot_id;
1509        write_synthetic_lock(&path, &metadata);
1510
1511        let guard = acquire_with_config(&path, Some(Duration::ZERO), test_config())
1512            .expect("zero-timeout acquire should reclaim a reused PID");
1513        assert_eq!(guard.metadata.pid, std::process::id());
1514        assert_eq!(
1515            guard.metadata.process_start_time,
1516            Some(current_identity.start_time)
1517        );
1518        drop(guard);
1519    }
1520
1521    #[cfg(target_os = "linux")]
1522    #[test]
1523    fn live_pid_with_wrong_boot_id_is_reclaimed() {
1524        let (_dir, path) = test_lock_path();
1525        let mut metadata = current_process_metadata();
1526        let current_identity = process_identity(std::process::id())
1527            .expect("current process should have a start-time identity");
1528        metadata.boot_id = Some(format!("wrong-{}", current_identity.boot_id.unwrap()));
1529        write_synthetic_lock(&path, &metadata);
1530
1531        let guard = acquire_with_config(&path, Some(Duration::ZERO), test_config())
1532            .expect("zero-timeout acquire should reclaim a rebooted PID identity");
1533        assert_eq!(guard.metadata.pid, std::process::id());
1534        drop(guard);
1535    }
1536
1537    #[test]
1538    fn legacy_live_pid_lock_keeps_pid_only_liveness() {
1539        let (_dir, path) = test_lock_path();
1540        let stale_at = now_ms().saturating_sub(60_000);
1541        let mut metadata = synthetic_metadata(std::process::id(), current_hostname(), stale_at);
1542        metadata.heartbeat_at_ms = stale_at;
1543        let original = write_legacy_lock(&path, &metadata);
1544        assert!(!original.contains("process_start_time"));
1545        assert!(!original.contains("boot_id"));
1546
1547        let result = acquire_with_config(&path, Some(Duration::from_millis(80)), test_config());
1548        assert!(matches!(result, Err(AcquireError::Timeout)));
1549        assert_eq!(
1550            fs::read_to_string(&path).expect("read legacy lock"),
1551            original
1552        );
1553
1554        remove_lock_file(&path).expect("cleanup legacy lock");
1555    }
1556
1557    #[test]
1558    fn legacy_dead_pid_lock_is_reclaimed() {
1559        let (_dir, path) = test_lock_path();
1560        let metadata = synthetic_metadata(999_999_999, current_hostname(), now_ms());
1561        let original = write_legacy_lock(&path, &metadata);
1562        assert!(!original.contains("process_start_time"));
1563        assert!(!original.contains("boot_id"));
1564
1565        let guard = acquire_with_config(&path, Some(Duration::ZERO), test_config())
1566            .expect("zero-timeout acquire should reclaim a legacy dead PID lock");
1567        assert_eq!(guard.metadata.pid, std::process::id());
1568        drop(guard);
1569    }
1570
1571    #[test]
1572    fn healthy_live_owner_blocks() {
1573        let (_dir, path) = test_lock_path();
1574        let metadata = current_process_metadata();
1575        write_synthetic_lock(&path, &metadata);
1576
1577        let result = acquire_with_config(&path, Some(Duration::from_millis(80)), test_config());
1578        assert!(matches!(result, Err(AcquireError::Timeout)));
1579
1580        remove_lock_file(&path).expect("cleanup synthetic lock");
1581    }
1582
1583    #[test]
1584    fn malformed_lockfile_is_reclaimed() {
1585        let (_dir, path) = test_lock_path();
1586        fs::write(&path, b"not valid json").expect("write malformed lock");
1587
1588        let guard = acquire_with_config(&path, Some(Duration::from_secs(1)), test_config())
1589            .expect("reclaim malformed lock");
1590        let metadata = read_lock_metadata(&path).expect("read reclaimed lock");
1591        assert_eq!(metadata.pid, std::process::id());
1592        drop(guard);
1593    }
1594
1595    #[test]
1596    fn cross_host_lock_is_not_stolen_before_extended_stale_threshold() {
1597        let (_dir, path) = test_lock_path();
1598        let now = now_ms();
1599        let mut metadata = current_process_metadata();
1600        metadata.hostname = format!("{}-other", current_hostname());
1601        metadata.process_start_time = metadata.process_start_time.map(different_start_time);
1602        metadata.created_at_ms = now;
1603        metadata.heartbeat_at_ms = now;
1604        metadata.writer_epoch = format!("cross-host-{now}");
1605        #[cfg(any(target_os = "linux", target_os = "macos"))]
1606        assert_ne!(
1607            metadata.process_start_time,
1608            process_identity(std::process::id()).map(|identity| identity.start_time)
1609        );
1610        write_synthetic_lock(&path, &metadata);
1611
1612        let result = acquire_with_config(&path, Some(Duration::from_millis(80)), test_config());
1613        assert!(matches!(result, Err(AcquireError::Timeout)));
1614        assert_eq!(read_lock_metadata(&path).expect("read lock"), metadata);
1615
1616        remove_lock_file(&path).expect("cleanup synthetic lock");
1617    }
1618
1619    #[test]
1620    fn stale_cross_host_lock_is_reclaimed_after_extended_threshold() {
1621        let (_dir, path) = test_lock_path();
1622        let stale_at =
1623            now_ms().saturating_sub(test_config().cross_host_stale_heartbeat_ms() + 1_000);
1624        let mut metadata = current_process_metadata();
1625        metadata.hostname = format!("{}-other", current_hostname());
1626        metadata.process_start_time = metadata.process_start_time.map(different_start_time);
1627        metadata.created_at_ms = stale_at;
1628        metadata.heartbeat_at_ms = stale_at;
1629        metadata.writer_epoch = format!("cross-host-{stale_at}");
1630        write_synthetic_lock(&path, &metadata);
1631
1632        let guard = acquire_with_config(&path, Some(Duration::from_secs(1)), test_config())
1633            .expect("reclaim stale cross-host lock");
1634        let reclaimed = read_lock_metadata(&path).expect("read reclaimed lock");
1635        assert_eq!(reclaimed.hostname, current_hostname());
1636        assert_ne!(reclaimed.created_at_ms, metadata.created_at_ms);
1637        drop(guard);
1638    }
1639
1640    #[test]
1641    fn live_owner_over_10min_warns_but_blocks() {
1642        let (_dir, path) = test_lock_path();
1643        let mut metadata = current_process_metadata();
1644        metadata.created_at_ms = now_ms().saturating_sub(11 * 60 * 1_000);
1645        metadata.heartbeat_at_ms = now_ms();
1646        write_synthetic_lock(&path, &metadata);
1647
1648        let result = acquire_with_config(&path, Some(Duration::from_millis(80)), test_config());
1649        assert!(matches!(result, Err(AcquireError::Timeout)));
1650        assert_eq!(read_lock_metadata(&path).expect("read lock"), metadata);
1651
1652        remove_lock_file(&path).expect("cleanup synthetic lock");
1653    }
1654
1655    #[test]
1656    fn drop_stops_heartbeat_thread() {
1657        let (_dir, path) = test_lock_path();
1658        let guard = acquire_with_config(&path, None, test_config()).expect("acquire lock");
1659        drop(guard);
1660
1661        thread::sleep(Duration::from_millis(
1662            test_config().heartbeat_interval_ms * 3,
1663        ));
1664        assert!(
1665            !path.exists(),
1666            "heartbeat recreated or kept updating lockfile"
1667        );
1668    }
1669
1670    #[test]
1671    fn heartbeat_error_classification_terminal_vs_transient() {
1672        // Terminal: the lock is provably no longer ours to refresh.
1673        assert!(heartbeat_error_is_terminal(&HeartbeatError::LockGone));
1674        assert!(heartbeat_error_is_terminal(&HeartbeatError::NotOwner));
1675        // Transient: a temporary I/O hiccup or a read that raced a concurrent
1676        // writer. These must NOT kill the heartbeat — it retries instead.
1677        assert!(!heartbeat_error_is_terminal(&HeartbeatError::Io(
1678            io::Error::other("disk blip")
1679        )));
1680        let malformed: serde_json::Error =
1681            serde_json::from_str::<LockMetadata>("not json").unwrap_err();
1682        assert!(!heartbeat_error_is_terminal(&HeartbeatError::Malformed(
1683            malformed
1684        )));
1685    }
1686
1687    #[test]
1688    fn heartbeat_survives_transient_malformed_and_recovers() {
1689        // Regression: a single transient failure (e.g. a read that races a
1690        // concurrent writer and sees a momentarily-unparseable file) used to
1691        // permanently kill the heartbeat thread. The guard holder would then
1692        // run its critical section with a stale heartbeat_at_ms, letting
1693        // another process reclaim the lock after the stale window — concurrent
1694        // writers / split-brain. The heartbeat must instead retry and resume
1695        // refreshing once the file is readable again.
1696        let (_dir, path) = test_lock_path();
1697        let guard = acquire_with_config(&path, None, test_config()).expect("acquire lock");
1698        let owner = guard.metadata.clone();
1699
1700        // Corrupt the lockfile out from under the heartbeat (simulates a
1701        // concurrent-writer race producing a momentarily-unparseable read).
1702        // The heartbeat reads-then-writes, so it observes Malformed and, with
1703        // the fix, retries instead of dying.
1704        fs::write(&path, b"{ not valid json").expect("corrupt lockfile");
1705
1706        // Give the heartbeat several intervals to observe the malformed file.
1707        // Pre-fix, the thread is dead by now.
1708        thread::sleep(Duration::from_millis(
1709            test_config().heartbeat_interval_ms * 4,
1710        ));
1711
1712        // Restore valid owner metadata with a clearly-stale heartbeat sentinel.
1713        // Ownership fields must match `owner` exactly so heartbeat_once passes
1714        // its ownership check and writes a fresh timestamp.
1715        //
1716        // Use the atomic temp-write+rename path rather than remove-then-recreate:
1717        // a remove followed by a separate create leaves a window where the file
1718        // does not exist, and a heartbeat poll landing in that window reads
1719        // NotFound -> LockGone (terminal) and kills the thread, failing this test
1720        // spuriously under runner load (observed on macOS CI). The atomic replace
1721        // overwrites the corrupt file in place with no no-file window on Unix.
1722        let sentinel = now_ms().saturating_sub(1_000_000);
1723        let mut restored = owner.clone();
1724        restored.heartbeat_at_ms = sentinel;
1725        atomic_write_lock_metadata(&path, &restored).expect("atomically restore lock metadata");
1726
1727        // If the heartbeat thread is still alive (the fix), it will overwrite
1728        // heartbeat_at_ms with a current value. Poll for that recovery.
1729        let deadline = std::time::Instant::now() + Duration::from_millis(3_000);
1730        let mut recovered = false;
1731        while std::time::Instant::now() < deadline {
1732            thread::sleep(Duration::from_millis(25));
1733            match read_lock_metadata(&path) {
1734                Ok(meta)
1735                    if meta.created_at_ms == owner.created_at_ms
1736                        && meta.heartbeat_at_ms > sentinel =>
1737                {
1738                    recovered = true;
1739                    break;
1740                }
1741                _ => continue,
1742            }
1743        }
1744        assert!(
1745            recovered,
1746            "heartbeat did not recover after a transient malformed read — thread likely died"
1747        );
1748        drop(guard);
1749    }
1750}