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
21const MAX_TRANSIENT_CREATE_RETRIES: u32 = 50;
29
30fn is_transient_create_contention(error: &io::Error) -> bool {
36 if error.kind() == io::ErrorKind::PermissionDenied {
37 return true;
38 }
39 #[cfg(windows)]
40 {
41 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 #[serde(default, skip_serializing_if = "Option::is_none")]
91 process_start_time: Option<u64>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
96 boot_id: Option<String>,
97 created_at_ms: u64,
98 heartbeat_at_ms: u64,
99 #[serde(default)]
102 writer_epoch: String,
103}
104
105pub fn acquire(path: &Path) -> Result<LockGuard, AcquireError> {
110 acquire_with_config(path, None, LockConfig::default())
111}
112
113pub fn try_acquire(path: &Path, timeout: Duration) -> Result<LockGuard, AcquireError> {
115 acquire_with_config(path, Some(timeout), LockConfig::default())
116}
117
118pub 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 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 self.shutdown.store(true, Ordering::Release);
187 if let Some(handle) = self.heartbeat.take() {
188 handle.thread().unpark();
189 let _ = handle.join();
190 }
191 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 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 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
262 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 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 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 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 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 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 slog_error!(
476 "{}; stopping heartbeat",
477 terminal_heartbeat_message(&path, &error)
478 );
479 return;
480 }
481 Err(error) => {
482 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
503fn 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 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
543fn 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 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
693static 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
756fn 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(¤t, judged) {
772 remove_lock_file(path)?;
773 Ok(true)
774 } else {
775 Ok(false)
777 }
778 }
779 Err(ReadLockError::Io(error)) if error.kind() == io::ErrorKind::NotFound => Ok(false),
781 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 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
942fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(heartbeat_error_is_terminal(&HeartbeatError::LockGone));
1674 assert!(heartbeat_error_is_terminal(&HeartbeatError::NotOwner));
1675 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 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 fs::write(&path, b"{ not valid json").expect("corrupt lockfile");
1705
1706 thread::sleep(Duration::from_millis(
1709 test_config().heartbeat_interval_ms * 4,
1710 ));
1711
1712 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 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}