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_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, Deserialize, PartialEq, Eq, Serialize)]
79struct LockMetadata {
80 pid: u32,
81 hostname: String,
82 created_at_ms: u64,
83 heartbeat_at_ms: u64,
84 #[serde(default)]
87 writer_epoch: String,
88}
89
90pub fn acquire(path: &Path) -> Result<LockGuard, AcquireError> {
95 acquire_with_config(path, None, LockConfig::default())
96}
97
98pub fn try_acquire(path: &Path, timeout: Duration) -> Result<LockGuard, AcquireError> {
100 acquire_with_config(path, Some(timeout), LockConfig::default())
101}
102
103pub fn try_acquire_once(path: &Path) -> Result<LockGuard, AcquireError> {
109 try_acquire(path, Duration::ZERO)
110}
111
112pub struct LockGuard {
113 path: PathBuf,
114 metadata: LockMetadata,
115 shutdown: Arc<AtomicBool>,
116 heartbeat_failed: Arc<AtomicBool>,
117 heartbeat_done: mpsc::Receiver<()>,
118 heartbeat: Option<JoinHandle<()>>,
119}
120
121impl LockGuard {
122 pub fn path(&self) -> &Path {
123 &self.path
124 }
125
126 pub fn writer_epoch(&self) -> &str {
127 &self.metadata.writer_epoch
128 }
129
130 pub fn verify_writer_epoch(&self) -> io::Result<bool> {
134 if self.heartbeat_failed.load(Ordering::Acquire) {
135 return Ok(false);
136 }
137 match read_lock_metadata(&self.path) {
138 Ok(metadata) => Ok(lock_identity_matches(&metadata, &self.metadata)),
139 Err(ReadLockError::Io(error)) if error.kind() == io::ErrorKind::NotFound => Ok(false),
140 Err(ReadLockError::Io(error)) => Err(error),
141 Err(ReadLockError::Malformed(_)) => Ok(false),
142 }
143 }
144}
145
146impl Drop for LockGuard {
147 fn drop(&mut self) {
148 self.shutdown.store(true, Ordering::Release);
172 if let Some(handle) = self.heartbeat.take() {
173 handle.thread().unpark();
174 let _ = handle.join();
175 }
176 while self.heartbeat_done.try_recv().is_ok() {}
180
181 match remove_lock_if_owned(&self.path, &self.metadata) {
182 Ok(true) => slog_info!("released filesystem lock at {}", self.path.display()),
183 Ok(false) => {}
184 Err(error) => slog_warn!(
185 "failed to release filesystem lock at {}: {}",
186 self.path.display(),
187 error
188 ),
189 }
190 }
191}
192
193#[derive(Debug)]
194pub enum AcquireError {
195 Io(io::Error),
196 Timeout,
197}
198
199impl fmt::Display for AcquireError {
200 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
201 match self {
202 AcquireError::Io(error) => write!(f, "filesystem lock I/O error: {error}"),
203 AcquireError::Timeout => write!(f, "timed out acquiring filesystem lock"),
204 }
205 }
206}
207
208impl std::error::Error for AcquireError {}
209
210impl From<io::Error> for AcquireError {
211 fn from(error: io::Error) -> Self {
212 AcquireError::Io(error)
213 }
214}
215
216fn acquire_with_config(
217 path: &Path,
218 timeout: Option<Duration>,
219 config: LockConfig,
220) -> Result<LockGuard, AcquireError> {
221 let deadline = timeout.map(|timeout| Instant::now() + timeout);
222 let hostname = current_hostname();
223 let mut warned_live_owner = false;
224 let mut warned_stale_live_owner = false;
225 let mut transient_create_failures: u32 = 0;
226 let mut attempted_once = false;
227 let mut immediate_retry_budget = 0_u8;
230
231 loop {
232 if attempted_once {
233 if immediate_retry_budget > 0 {
234 immediate_retry_budget -= 1;
235 } else if let Some(deadline) = deadline {
236 if Instant::now() >= deadline {
237 return Err(AcquireError::Timeout);
238 }
239 }
240 }
241 attempted_once = true;
242
243 match create_new_lock(path, &hostname, config) {
244 Ok(guard) => return Ok(guard),
245 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
247 Err(error) if is_transient_create_contention(&error) => {
253 transient_create_failures += 1;
254 if transient_create_failures > MAX_TRANSIENT_CREATE_RETRIES {
255 return Err(error.into());
256 }
257 sleep_until_retry(deadline, config.poll_interval_ms)?;
258 continue;
259 }
260 Err(error) => return Err(error.into()),
261 }
262 transient_create_failures = 0;
263
264 let metadata = match read_lock_metadata(path) {
265 Ok(metadata) => metadata,
266 Err(ReadLockError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
267 immediate_retry_budget = 1;
268 continue;
269 }
270 Err(ReadLockError::Io(error)) => return Err(error.into()),
271 Err(ReadLockError::Malformed(error)) => {
272 sleep_until_retry(deadline, config.poll_interval_ms)?;
276 match read_lock_metadata(path) {
277 Ok(_) => continue,
278 Err(ReadLockError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
279 continue;
280 }
281 Err(ReadLockError::Io(error)) => return Err(error.into()),
282 Err(ReadLockError::Malformed(_)) => {}
283 }
284 slog_warn!(
285 "removing malformed filesystem lock at {}: {}",
286 path.display(),
287 error
288 );
289 remove_lock_file(path)?;
290 immediate_retry_budget = 1;
291 continue;
292 }
293 };
294
295 let now = now_ms();
296 let since_heartbeat = now.saturating_sub(metadata.heartbeat_at_ms);
297
298 if metadata.hostname != hostname {
299 let cross_host_stale_ms = config.cross_host_stale_heartbeat_ms();
300 if since_heartbeat > cross_host_stale_ms {
301 slog_warn!(
302 "reclaiming cross-host filesystem lock at {} from host {} after stale heartbeat ({}ms > {}ms)",
303 path.display(),
304 metadata.hostname,
305 since_heartbeat,
306 cross_host_stale_ms
307 );
308 if reclaim_lock_file(path, &metadata)? {
311 immediate_retry_budget = 1;
312 }
313 continue;
314 }
315 sleep_until_retry(deadline, config.poll_interval_ms)?;
316 continue;
317 }
318
319 if !process_alive(metadata.pid) {
320 slog_warn!(
321 "removing filesystem lock at {} from dead PID {}",
322 path.display(),
323 metadata.pid
324 );
325 if reclaim_lock_file(path, &metadata)? {
329 immediate_retry_budget = 1;
330 }
331 continue;
332 }
333
334 if since_heartbeat > config.stale_heartbeat_ms && !warned_stale_live_owner {
335 slog_warn!(
341 "filesystem lock at {} held by live PID {} has stale heartbeat ({}ms); NOT breaking",
342 path.display(),
343 metadata.pid,
344 since_heartbeat
345 );
346 warned_stale_live_owner = true;
347 }
348
349 let held_for = now.saturating_sub(metadata.created_at_ms);
350 if held_for > config.live_owner_warn_ms && !warned_live_owner {
351 slog_warn!(
352 "filesystem lock at {} held >10min by live heartbeating PID {}; NOT breaking",
353 path.display(),
354 metadata.pid
355 );
356 warned_live_owner = true;
357 }
358
359 sleep_until_retry(deadline, config.poll_interval_ms)?;
360 }
361}
362
363fn create_new_lock(path: &Path, hostname: &str, config: LockConfig) -> io::Result<LockGuard> {
364 let now = now_ms();
365 let metadata = LockMetadata {
366 pid: std::process::id(),
367 hostname: hostname.to_string(),
368 created_at_ms: now,
369 heartbeat_at_ms: now,
370 writer_epoch: format!("{}-{}", std::process::id(), now_nanos()),
371 };
372
373 create_lock_file_atomically(path, &metadata)?;
374
375 let shutdown = Arc::new(AtomicBool::new(false));
376 let heartbeat_failed = Arc::new(AtomicBool::new(false));
377 let (done_tx, done_rx) = mpsc::channel();
378 let heartbeat_path = path.to_path_buf();
379 let heartbeat_metadata = metadata.clone();
380 let heartbeat_shutdown = Arc::clone(&shutdown);
381 let heartbeat_failed_for_thread = Arc::clone(&heartbeat_failed);
382 let heartbeat = thread::Builder::new()
383 .name("aft-fs-lock-heartbeat".to_string())
384 .spawn(move || {
385 let heartbeat_shutdown_for_run = Arc::clone(&heartbeat_shutdown);
386 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
387 run_heartbeat(
388 heartbeat_path,
389 heartbeat_metadata,
390 heartbeat_shutdown_for_run,
391 config,
392 );
393 }));
394 if result.is_err() || !heartbeat_shutdown.load(Ordering::Acquire) {
395 heartbeat_failed_for_thread.store(true, Ordering::Release);
396 }
397 let _ = done_tx.send(());
398 })?;
399
400 slog_info!("acquired filesystem lock at {}", path.display());
401
402 Ok(LockGuard {
403 path: path.to_path_buf(),
404 metadata,
405 shutdown,
406 heartbeat_failed,
407 heartbeat_done: done_rx,
408 heartbeat: Some(heartbeat),
409 })
410}
411
412fn run_heartbeat(
413 path: PathBuf,
414 owner: LockMetadata,
415 shutdown: Arc<AtomicBool>,
416 config: LockConfig,
417) {
418 let stale_intervals = config
423 .stale_heartbeat_ms
424 .checked_div(config.heartbeat_interval_ms.max(1))
425 .unwrap_or(3)
426 .max(1);
427 let mut consecutive_transient_failures: u64 = 0;
428
429 loop {
430 thread::park_timeout(Duration::from_millis(config.heartbeat_interval_ms));
431 if shutdown.load(Ordering::Acquire) {
432 return;
433 }
434
435 match heartbeat_once(&path, &owner) {
436 Ok(()) => {
437 if consecutive_transient_failures > 0 {
438 slog_info!(
439 "filesystem lock at {} heartbeat recovered after {} transient failure(s)",
440 path.display(),
441 consecutive_transient_failures
442 );
443 consecutive_transient_failures = 0;
444 }
445 }
446 Err(error) if heartbeat_error_is_terminal(&error) => {
447 slog_error!(
452 "{}; stopping heartbeat",
453 terminal_heartbeat_message(&path, &error)
454 );
455 return;
456 }
457 Err(error) => {
458 consecutive_transient_failures += 1;
468 log_transient_heartbeat_failure(
469 &path,
470 &transient_heartbeat_reason(&error),
471 consecutive_transient_failures,
472 stale_intervals,
473 );
474 }
475 }
476 }
477}
478
479fn heartbeat_error_is_terminal(error: &HeartbeatError) -> bool {
485 matches!(error, HeartbeatError::LockGone | HeartbeatError::NotOwner)
486}
487
488fn terminal_heartbeat_message(path: &Path, error: &HeartbeatError) -> String {
489 match error {
490 HeartbeatError::LockGone => {
491 format!("filesystem lock at {} disappeared", path.display())
492 }
493 HeartbeatError::NotOwner => format!(
494 "filesystem lock at {} is no longer owned by this guard",
495 path.display()
496 ),
497 HeartbeatError::Io(error) => {
499 format!("filesystem lock at {} I/O error: {error}", path.display())
500 }
501 HeartbeatError::Malformed(error) => {
502 format!(
503 "filesystem lock at {} became malformed: {error}",
504 path.display()
505 )
506 }
507 }
508}
509
510fn transient_heartbeat_reason(error: &HeartbeatError) -> String {
511 match error {
512 HeartbeatError::Io(error) => format!("I/O error: {error}"),
513 HeartbeatError::Malformed(error) => format!("became malformed: {error}"),
514 HeartbeatError::LockGone => "lock disappeared".to_string(),
515 HeartbeatError::NotOwner => "lock no longer owned".to_string(),
516 }
517}
518
519fn log_transient_heartbeat_failure(
524 path: &Path,
525 reason: &str,
526 consecutive_failures: u64,
527 stale_intervals: u64,
528) {
529 if consecutive_failures < stale_intervals {
530 slog_warn!(
531 "transient failure to heartbeat filesystem lock at {}: {}; retrying (attempt {})",
532 path.display(),
533 reason,
534 consecutive_failures
535 );
536 } else if consecutive_failures == stale_intervals {
537 slog_error!(
538 "filesystem lock at {} has failed {} consecutive heartbeats: {}; \
539 the lock may now be reclaimed by another owner — continuing to retry",
540 path.display(),
541 consecutive_failures,
542 reason
543 );
544 }
545}
546
547fn heartbeat_once(path: &Path, owner: &LockMetadata) -> Result<(), HeartbeatError> {
548 let mut metadata = match read_lock_metadata(path) {
549 Ok(metadata) => metadata,
550 Err(ReadLockError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
551 return Err(HeartbeatError::LockGone);
552 }
553 Err(ReadLockError::Io(error)) => return Err(HeartbeatError::Io(error)),
554 Err(ReadLockError::Malformed(error)) => return Err(HeartbeatError::Malformed(error)),
555 };
556
557 if !lock_identity_matches(&metadata, owner) {
558 return Err(HeartbeatError::NotOwner);
559 }
560
561 metadata.heartbeat_at_ms = now_ms();
562 atomic_write_lock_metadata(path, &metadata).map_err(HeartbeatError::Io)
563}
564
565#[derive(Debug)]
566enum HeartbeatError {
567 Io(io::Error),
568 LockGone,
569 Malformed(serde_json::Error),
570 NotOwner,
571}
572
573#[derive(Debug)]
574enum ReadLockError {
575 Io(io::Error),
576 Malformed(serde_json::Error),
577}
578
579fn read_lock_metadata(path: &Path) -> Result<LockMetadata, ReadLockError> {
580 let bytes = fs::read(path).map_err(ReadLockError::Io)?;
581 serde_json::from_slice(&bytes).map_err(ReadLockError::Malformed)
582}
583
584#[cfg(unix)]
585fn open_new_lock_file(path: &Path) -> io::Result<File> {
586 use std::os::unix::fs::OpenOptionsExt;
587
588 OpenOptions::new()
589 .write(true)
590 .create_new(true)
591 .mode(0o600)
592 .open(path)
593}
594
595#[cfg(not(unix))]
596fn open_new_lock_file(path: &Path) -> io::Result<File> {
597 OpenOptions::new().write(true).create_new(true).open(path)
598}
599
600fn write_lock_metadata_to_file(file: &mut File, metadata: &LockMetadata) -> io::Result<()> {
601 serde_json::to_writer(&mut *file, metadata).map_err(io::Error::other)?;
602 file.write_all(b"\n")?;
603 file.sync_all()
604}
605
606fn create_lock_file_atomically(path: &Path, metadata: &LockMetadata) -> io::Result<()> {
607 let tmp_path = temp_path_for_lock(path);
608 let result = (|| {
609 let mut file = open_new_lock_file(&tmp_path)?;
610 write_lock_metadata_to_file(&mut file, metadata)?;
611 drop(file);
612
613 fs::hard_link(&tmp_path, path)?;
614 sync_parent(path);
615 Ok(())
616 })();
617
618 let _ = fs::remove_file(&tmp_path);
619 result
620}
621
622fn atomic_write_lock_metadata(path: &Path, metadata: &LockMetadata) -> io::Result<()> {
623 let tmp_path = temp_path_for_lock(path);
624 let write_result = (|| {
625 let mut file = open_new_lock_file(&tmp_path)?;
626 write_lock_metadata_to_file(&mut file, metadata)?;
627 drop(file);
628
629 rename_over(&tmp_path, path)?;
630 sync_parent(path);
631 Ok(())
632 })();
633
634 if write_result.is_err() {
635 let _ = fs::remove_file(&tmp_path);
636 }
637
638 write_result
639}
640
641#[cfg(any(windows, test))]
642fn rename_over_with(
643 from: &Path,
644 to: &Path,
645 replace: impl FnOnce(&Path, &Path) -> io::Result<()>,
646) -> io::Result<()> {
647 replace(from, to)
648}
649
650#[cfg(windows)]
651pub(crate) fn rename_over(from: &Path, to: &Path) -> io::Result<()> {
652 rename_over_with(from, to, |from, to| fs::rename(from, to))
662}
663
664#[cfg(not(windows))]
665pub(crate) fn rename_over(from: &Path, to: &Path) -> io::Result<()> {
666 fs::rename(from, to)
667}
668
669static TEMP_LOCK_COUNTER: AtomicU64 = AtomicU64::new(0);
682
683fn temp_path_for_lock(path: &Path) -> PathBuf {
684 let file_name = path
685 .file_name()
686 .and_then(|name| name.to_str())
687 .unwrap_or("lock");
688 let seq = TEMP_LOCK_COUNTER.fetch_add(1, Ordering::Relaxed);
689 path.with_file_name(format!(
690 ".{file_name}.tmp.{}.{}.{}",
691 std::process::id(),
692 now_nanos(),
693 seq
694 ))
695}
696
697fn lock_identity_matches(left: &LockMetadata, right: &LockMetadata) -> bool {
698 left.pid == right.pid
699 && left.hostname == right.hostname
700 && left.created_at_ms == right.created_at_ms
701 && left.writer_epoch == right.writer_epoch
702}
703
704fn remove_lock_if_owned(path: &Path, owner: &LockMetadata) -> io::Result<bool> {
705 let metadata = match read_lock_metadata(path) {
706 Ok(metadata) => metadata,
707 Err(ReadLockError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
708 return Ok(false);
709 }
710 Err(ReadLockError::Io(error)) => return Err(error),
711 Err(ReadLockError::Malformed(_)) => return Ok(false),
712 };
713
714 if lock_identity_matches(&metadata, owner) {
715 remove_lock_file(path)?;
716 Ok(true)
717 } else {
718 Ok(false)
719 }
720}
721
722fn remove_lock_file(path: &Path) -> io::Result<()> {
723 match fs::remove_file(path) {
724 Ok(()) => Ok(()),
725 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
726 Err(error) => Err(error),
727 }
728}
729
730fn reclaim_lock_file(path: &Path, judged: &LockMetadata) -> io::Result<bool> {
740 let Some(_token) = acquire_reclaim_token(path)? else {
741 return Ok(false);
742 };
743 match read_lock_metadata(path) {
744 Ok(current) => {
745 if lock_identity_matches(¤t, judged) {
746 remove_lock_file(path)?;
747 Ok(true)
748 } else {
749 Ok(false)
751 }
752 }
753 Err(ReadLockError::Io(error)) if error.kind() == io::ErrorKind::NotFound => Ok(false),
755 Err(ReadLockError::Malformed(_)) => Ok(false),
757 Err(ReadLockError::Io(error)) => Err(error),
758 }
759}
760
761struct ReclaimTokenGuard {
762 path: PathBuf,
763}
764
765impl Drop for ReclaimTokenGuard {
766 fn drop(&mut self) {
767 let _ = fs::remove_file(&self.path);
768 sync_parent(&self.path);
769 }
770}
771
772fn acquire_reclaim_token(lock_path: &Path) -> io::Result<Option<ReclaimTokenGuard>> {
773 let token_path = reclaim_token_path(lock_path);
774 let metadata = LockMetadata {
775 pid: std::process::id(),
776 hostname: current_hostname(),
777 created_at_ms: now_ms(),
778 heartbeat_at_ms: now_ms(),
779 writer_epoch: format!("reclaim-{}-{}", std::process::id(), now_nanos()),
780 };
781 let mut file = match open_new_lock_file(&token_path) {
782 Ok(file) => file,
783 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => return Ok(None),
784 Err(error) => return Err(error),
785 };
786 if let Err(error) = write_lock_metadata_to_file(&mut file, &metadata) {
787 let _ = fs::remove_file(&token_path);
788 return Err(error);
789 }
790 sync_parent(&token_path);
791 Ok(Some(ReclaimTokenGuard { path: token_path }))
792}
793
794fn reclaim_token_path(lock_path: &Path) -> PathBuf {
795 let file_name = lock_path
796 .file_name()
797 .and_then(|name| name.to_str())
798 .unwrap_or("lock");
799 lock_path.with_file_name(format!(".{file_name}.reclaim"))
800}
801
802fn sleep_until_retry(deadline: Option<Instant>, poll_interval_ms: u64) -> Result<(), AcquireError> {
803 let poll = Duration::from_millis(poll_interval_ms);
804 let sleep_for = match deadline {
805 Some(deadline) => {
806 let now = Instant::now();
807 if now >= deadline {
808 return Err(AcquireError::Timeout);
809 }
810 poll.min(deadline.saturating_duration_since(now))
811 }
812 None => poll,
813 };
814 thread::sleep(sleep_for);
815 Ok(())
816}
817
818pub(crate) fn sync_parent(path: &Path) {
819 if let Some(parent) = path.parent() {
820 if let Ok(dir) = File::open(parent) {
821 let _ = dir.sync_all();
822 }
823 }
824}
825
826fn now_ms() -> u64 {
827 SystemTime::now()
828 .duration_since(UNIX_EPOCH)
829 .unwrap_or(Duration::ZERO)
830 .as_millis() as u64
831}
832
833fn now_nanos() -> u128 {
834 SystemTime::now()
835 .duration_since(UNIX_EPOCH)
836 .unwrap_or(Duration::ZERO)
837 .as_nanos()
838}
839
840#[cfg(unix)]
841fn current_hostname() -> String {
842 let mut buffer = [0u8; 256];
843 let result = unsafe { libc::gethostname(buffer.as_mut_ptr().cast(), buffer.len()) };
844 if result == 0 {
845 let len = buffer
846 .iter()
847 .position(|byte| *byte == 0)
848 .unwrap_or(buffer.len());
849 if len > 0 {
850 return String::from_utf8_lossy(&buffer[..len]).into_owned();
851 }
852 }
853
854 std::env::var("HOSTNAME").unwrap_or_else(|_| "unknown-host".to_string())
855}
856
857#[cfg(windows)]
858fn current_hostname() -> String {
859 std::env::var("COMPUTERNAME")
860 .or_else(|_| std::env::var("HOSTNAME"))
861 .unwrap_or_else(|_| "unknown-host".to_string())
862}
863
864#[cfg(not(any(unix, windows)))]
865fn current_hostname() -> String {
866 std::env::var("HOSTNAME").unwrap_or_else(|_| "unknown-host".to_string())
867}
868
869#[cfg(unix)]
870pub(crate) fn process_alive(pid: u32) -> bool {
871 if pid == 0 || pid > i32::MAX as u32 {
872 return false;
873 }
874
875 let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
876 if result == 0 {
877 return true;
878 }
879
880 io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
881}
882
883#[cfg(windows)]
884pub(crate) fn process_alive(pid: u32) -> bool {
885 if pid == 0 {
886 return false;
887 }
888 let filter = format!("PID eq {pid}");
889 let Ok(output) = std::process::Command::new("tasklist")
890 .args(["/FI", &filter, "/FO", "CSV", "/NH"])
891 .output()
892 else {
893 return true;
894 };
895
896 if !output.status.success() {
897 return true;
898 }
899
900 let stdout = String::from_utf8_lossy(&output.stdout);
901
902 if stdout.contains("No tasks are running") {
913 return false;
914 }
915 stdout.contains(&format!("\"{pid}\""))
916}
917
918#[cfg(not(any(unix, windows)))]
919pub(crate) fn process_alive(_pid: u32) -> bool {
920 true
921}
922
923#[cfg(test)]
924mod tests {
925 use super::*;
926 use std::sync::atomic::{AtomicUsize, Ordering};
927 use std::sync::{Arc, Barrier};
928
929 fn test_config() -> LockConfig {
930 LockConfig {
931 heartbeat_interval_ms: 25,
932 stale_heartbeat_ms: 2_000,
933 live_owner_warn_ms: LIVE_OWNER_WARN_MS,
934 poll_interval_ms: 10,
935 }
936 }
937
938 fn test_lock_path() -> (tempfile::TempDir, PathBuf) {
939 let dir = tempfile::tempdir().expect("create temp dir");
940 let path = dir.path().join("test.lock");
941 (dir, path)
942 }
943
944 fn write_synthetic_lock(path: &Path, metadata: &LockMetadata) {
945 let mut file = open_new_lock_file(path).expect("create synthetic lock");
946 write_lock_metadata_to_file(&mut file, metadata).expect("write synthetic lock");
947 }
948
949 fn synthetic_metadata(pid: u32, hostname: String, created_at_ms: u64) -> LockMetadata {
950 LockMetadata {
951 pid,
952 hostname,
953 created_at_ms,
954 heartbeat_at_ms: created_at_ms,
955 writer_epoch: format!("synthetic-{pid}-{created_at_ms}"),
956 }
957 }
958
959 fn current_process_metadata() -> LockMetadata {
960 let now = now_ms();
961 synthetic_metadata(std::process::id(), current_hostname(), now)
962 }
963
964 #[test]
965 fn acquire_creates_lockfile_and_unlocks_on_drop() {
966 let (_dir, path) = test_lock_path();
967
968 let guard = acquire_with_config(&path, None, test_config()).expect("acquire lock");
969 let metadata = read_lock_metadata(&path).expect("read lock metadata");
970 assert_eq!(metadata.pid, std::process::id());
971 assert_eq!(metadata.hostname, current_hostname());
972 assert_eq!(metadata.created_at_ms, guard.metadata.created_at_ms);
973 assert_eq!(metadata.writer_epoch, guard.metadata.writer_epoch);
974 #[cfg(unix)]
975 {
976 use std::os::unix::fs::PermissionsExt;
977 assert_eq!(
978 fs::metadata(&path).unwrap().permissions().mode() & 0o777,
979 0o600
980 );
981 }
982
983 drop(guard);
984 assert!(!path.exists());
985 }
986
987 #[test]
988 fn permission_denied_is_treated_as_transient_create_contention() {
989 let err = io::Error::from(io::ErrorKind::PermissionDenied);
992 assert!(is_transient_create_contention(&err));
993 }
994
995 #[test]
996 fn unrelated_io_errors_are_not_treated_as_contention() {
997 let err = io::Error::from(io::ErrorKind::NotFound);
1000 assert!(!is_transient_create_contention(&err));
1001 }
1002
1003 #[cfg(windows)]
1004 #[test]
1005 fn windows_sharing_violation_is_treated_as_transient_create_contention() {
1006 let err = io::Error::from_raw_os_error(32);
1009 assert!(is_transient_create_contention(&err));
1010 }
1011
1012 #[test]
1013 fn reclaim_refuses_to_delete_a_different_owners_lock() {
1014 let (_dir, path) = test_lock_path();
1015
1016 let owner_b = synthetic_metadata(4242, "host-b".to_string(), now_ms());
1018 create_lock_file_atomically(&path, &owner_b).expect("write owner B lock");
1019
1020 let judged_a = synthetic_metadata(1111, "host-a".to_string(), now_ms() - 1_000_000);
1023 let removed = reclaim_lock_file(&path, &judged_a).expect("reclaim");
1024 assert!(!removed, "must not remove a different owner's lock");
1025 assert!(path.exists(), "owner B's lock must survive");
1026 let still = read_lock_metadata(&path).expect("still readable");
1027 assert_eq!(still.pid, 4242, "owner B's lock intact");
1028 }
1029
1030 #[test]
1031 fn reclaim_deletes_when_identity_still_matches() {
1032 let (_dir, path) = test_lock_path();
1033 let owner = synthetic_metadata(1111, "host-a".to_string(), 5_000);
1034 create_lock_file_atomically(&path, &owner).expect("write lock");
1035
1036 let removed = reclaim_lock_file(&path, &owner).expect("reclaim");
1038 assert!(removed, "matching-identity stale lock should be removed");
1039 assert!(!path.exists());
1040
1041 assert!(!reclaim_lock_file(&path, &owner).expect("reclaim missing"));
1043 }
1044
1045 #[test]
1046 fn try_acquire_once_never_waits_behind_live_owner() {
1047 let (_dir, path) = test_lock_path();
1048 let _guard = acquire_with_config(&path, None, test_config()).expect("acquire lock");
1049 let started = Instant::now();
1050
1051 let result = try_acquire_once(&path);
1052
1053 assert!(matches!(result, Err(AcquireError::Timeout)));
1054 assert!(started.elapsed() < Duration::from_millis(250));
1055 }
1056
1057 #[test]
1058 fn acquire_serializes_concurrent_callers() {
1059 let (_dir, path) = test_lock_path();
1060 let path = Arc::new(path);
1061 let barrier = Arc::new(Barrier::new(3));
1062 let inside = Arc::new(AtomicUsize::new(0));
1063 let entered = Arc::new(AtomicUsize::new(0));
1064 let max_inside = Arc::new(AtomicUsize::new(0));
1065
1066 let mut handles = Vec::new();
1067 for _ in 0..2 {
1068 let path = Arc::clone(&path);
1069 let barrier = Arc::clone(&barrier);
1070 let inside = Arc::clone(&inside);
1071 let entered = Arc::clone(&entered);
1072 let max_inside = Arc::clone(&max_inside);
1073 handles.push(thread::spawn(move || {
1074 barrier.wait();
1075 let guard = acquire_with_config(&path, Some(Duration::from_secs(2)), test_config())
1076 .expect("thread acquire lock");
1077 let previous = inside.fetch_add(1, Ordering::SeqCst);
1078 assert_eq!(previous, 0, "two lock holders overlapped");
1079 entered.fetch_add(1, Ordering::SeqCst);
1080 max_inside.fetch_max(previous + 1, Ordering::SeqCst);
1081 thread::sleep(Duration::from_millis(75));
1082 inside.fetch_sub(1, Ordering::SeqCst);
1083 drop(guard);
1084 }));
1085 }
1086
1087 barrier.wait();
1088 for handle in handles {
1089 handle.join().expect("join worker");
1090 }
1091
1092 assert_eq!(entered.load(Ordering::SeqCst), 2);
1093 assert_eq!(max_inside.load(Ordering::SeqCst), 1);
1094 assert!(!path.exists());
1095 }
1096
1097 #[test]
1098 fn failed_atomic_replacement_preserves_existing_destination() {
1099 let dir = tempfile::tempdir().expect("create temp dir");
1100 let source = dir.path().join("source.tmp");
1101 let destination = dir.path().join("artifact.bin");
1102 fs::write(&source, b"new artifact").expect("write source");
1103 fs::write(&destination, b"valid old artifact").expect("write destination");
1104
1105 let error = rename_over_with(&source, &destination, |_from, _to| {
1106 Err(io::Error::new(
1107 io::ErrorKind::PermissionDenied,
1108 "injected replacement failure",
1109 ))
1110 })
1111 .expect_err("replacement must fail");
1112
1113 assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
1114 assert_eq!(
1115 fs::read(&destination).expect("read preserved destination"),
1116 b"valid old artifact"
1117 );
1118 assert_eq!(
1119 fs::read(&source).expect("read retained source"),
1120 b"new artifact"
1121 );
1122 }
1123
1124 #[test]
1125 fn heartbeat_updates_lockfile_timestamp() {
1126 let (_dir, path) = test_lock_path();
1127 let guard = acquire_with_config(&path, None, test_config()).expect("acquire lock");
1128 let initial = read_lock_metadata(&path)
1129 .expect("read initial metadata")
1130 .heartbeat_at_ms;
1131
1132 let deadline = std::time::Instant::now() + Duration::from_millis(2_000);
1142 let mut updated = initial;
1143 while std::time::Instant::now() < deadline {
1144 thread::sleep(Duration::from_millis(50));
1145 match read_lock_metadata(&path) {
1146 Ok(meta) => {
1147 updated = meta.heartbeat_at_ms;
1148 if updated > initial {
1149 break;
1150 }
1151 }
1152 Err(ReadLockError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
1153 continue;
1156 }
1157 Err(other) => panic!("read updated metadata: {other:?}"),
1158 }
1159 }
1160 assert!(
1161 updated > initial,
1162 "heartbeat timestamp did not advance within 2s"
1163 );
1164 drop(guard);
1165 }
1166
1167 #[test]
1168 fn dead_pid_lock_is_reclaimed() {
1169 let (_dir, path) = test_lock_path();
1170 let metadata = synthetic_metadata(999_999_999, current_hostname(), now_ms());
1171 write_synthetic_lock(&path, &metadata);
1172
1173 let guard = acquire_with_config(&path, Some(Duration::from_secs(1)), test_config())
1174 .expect("reclaim dead pid lock");
1175 let metadata = read_lock_metadata(&path).expect("read reclaimed lock");
1176 assert_eq!(metadata.pid, std::process::id());
1177 drop(guard);
1178 }
1179
1180 #[test]
1181 fn zero_timeout_dead_pid_reclaim_acquires_after_removing_stale_file() {
1182 let (_dir, path) = test_lock_path();
1183 let metadata = synthetic_metadata(999_999_999, current_hostname(), now_ms());
1184 write_synthetic_lock(&path, &metadata);
1185
1186 let guard = acquire_with_config(&path, Some(Duration::ZERO), test_config())
1187 .expect("zero-timeout acquire should claim the reaped stale lock");
1188 let metadata = read_lock_metadata(&path).expect("read reclaimed lock");
1189 assert_eq!(metadata.pid, std::process::id());
1190 drop(guard);
1191 }
1192
1193 #[test]
1194 fn stale_heartbeat_from_live_pid_blocks() {
1195 let (_dir, path) = test_lock_path();
1196 let mut metadata = current_process_metadata();
1197 metadata.created_at_ms = now_ms().saturating_sub(60_000);
1198 metadata.heartbeat_at_ms = now_ms().saturating_sub(60_000);
1199 write_synthetic_lock(&path, &metadata);
1200
1201 let result = acquire_with_config(&path, Some(Duration::from_millis(80)), test_config());
1202 assert!(matches!(result, Err(AcquireError::Timeout)));
1203 assert_eq!(read_lock_metadata(&path).expect("read lock"), metadata);
1204
1205 remove_lock_file(&path).expect("cleanup synthetic lock");
1206 }
1207
1208 #[test]
1209 fn healthy_live_owner_blocks() {
1210 let (_dir, path) = test_lock_path();
1211 let metadata = current_process_metadata();
1212 write_synthetic_lock(&path, &metadata);
1213
1214 let result = acquire_with_config(&path, Some(Duration::from_millis(80)), test_config());
1215 assert!(matches!(result, Err(AcquireError::Timeout)));
1216
1217 remove_lock_file(&path).expect("cleanup synthetic lock");
1218 }
1219
1220 #[test]
1221 fn malformed_lockfile_is_reclaimed() {
1222 let (_dir, path) = test_lock_path();
1223 fs::write(&path, b"not valid json").expect("write malformed lock");
1224
1225 let guard = acquire_with_config(&path, Some(Duration::from_secs(1)), test_config())
1226 .expect("reclaim malformed lock");
1227 let metadata = read_lock_metadata(&path).expect("read reclaimed lock");
1228 assert_eq!(metadata.pid, std::process::id());
1229 drop(guard);
1230 }
1231
1232 #[test]
1233 fn cross_host_lock_is_not_stolen_before_extended_stale_threshold() {
1234 let (_dir, path) = test_lock_path();
1235 let now = now_ms();
1236 let metadata = LockMetadata {
1237 pid: std::process::id(),
1238 hostname: format!("{}-other", current_hostname()),
1239 created_at_ms: now,
1240 heartbeat_at_ms: now,
1241 writer_epoch: format!("cross-host-{now}"),
1242 };
1243 write_synthetic_lock(&path, &metadata);
1244
1245 let result = acquire_with_config(&path, Some(Duration::from_millis(80)), test_config());
1246 assert!(matches!(result, Err(AcquireError::Timeout)));
1247 assert_eq!(read_lock_metadata(&path).expect("read lock"), metadata);
1248
1249 remove_lock_file(&path).expect("cleanup synthetic lock");
1250 }
1251
1252 #[test]
1253 fn stale_cross_host_lock_is_reclaimed_after_extended_threshold() {
1254 let (_dir, path) = test_lock_path();
1255 let stale_at =
1256 now_ms().saturating_sub(test_config().cross_host_stale_heartbeat_ms() + 1_000);
1257 let metadata = LockMetadata {
1258 pid: std::process::id(),
1259 hostname: format!("{}-other", current_hostname()),
1260 created_at_ms: stale_at,
1261 heartbeat_at_ms: stale_at,
1262 writer_epoch: format!("cross-host-{stale_at}"),
1263 };
1264 write_synthetic_lock(&path, &metadata);
1265
1266 let guard = acquire_with_config(&path, Some(Duration::from_secs(1)), test_config())
1267 .expect("reclaim stale cross-host lock");
1268 let reclaimed = read_lock_metadata(&path).expect("read reclaimed lock");
1269 assert_eq!(reclaimed.hostname, current_hostname());
1270 assert_ne!(reclaimed.created_at_ms, metadata.created_at_ms);
1271 drop(guard);
1272 }
1273
1274 #[test]
1275 fn live_owner_over_10min_warns_but_blocks() {
1276 let (_dir, path) = test_lock_path();
1277 let mut metadata = current_process_metadata();
1278 metadata.created_at_ms = now_ms().saturating_sub(11 * 60 * 1_000);
1279 metadata.heartbeat_at_ms = now_ms();
1280 write_synthetic_lock(&path, &metadata);
1281
1282 let result = acquire_with_config(&path, Some(Duration::from_millis(80)), test_config());
1283 assert!(matches!(result, Err(AcquireError::Timeout)));
1284 assert_eq!(read_lock_metadata(&path).expect("read lock"), metadata);
1285
1286 remove_lock_file(&path).expect("cleanup synthetic lock");
1287 }
1288
1289 #[test]
1290 fn drop_stops_heartbeat_thread() {
1291 let (_dir, path) = test_lock_path();
1292 let guard = acquire_with_config(&path, None, test_config()).expect("acquire lock");
1293 drop(guard);
1294
1295 thread::sleep(Duration::from_millis(
1296 test_config().heartbeat_interval_ms * 3,
1297 ));
1298 assert!(
1299 !path.exists(),
1300 "heartbeat recreated or kept updating lockfile"
1301 );
1302 }
1303
1304 #[test]
1305 fn heartbeat_error_classification_terminal_vs_transient() {
1306 assert!(heartbeat_error_is_terminal(&HeartbeatError::LockGone));
1308 assert!(heartbeat_error_is_terminal(&HeartbeatError::NotOwner));
1309 assert!(!heartbeat_error_is_terminal(&HeartbeatError::Io(
1312 io::Error::other("disk blip")
1313 )));
1314 let malformed: serde_json::Error =
1315 serde_json::from_str::<LockMetadata>("not json").unwrap_err();
1316 assert!(!heartbeat_error_is_terminal(&HeartbeatError::Malformed(
1317 malformed
1318 )));
1319 }
1320
1321 #[test]
1322 fn heartbeat_survives_transient_malformed_and_recovers() {
1323 let (_dir, path) = test_lock_path();
1331 let guard = acquire_with_config(&path, None, test_config()).expect("acquire lock");
1332 let owner = guard.metadata.clone();
1333
1334 fs::write(&path, b"{ not valid json").expect("corrupt lockfile");
1339
1340 thread::sleep(Duration::from_millis(
1343 test_config().heartbeat_interval_ms * 4,
1344 ));
1345
1346 let sentinel = now_ms().saturating_sub(1_000_000);
1357 let mut restored = owner.clone();
1358 restored.heartbeat_at_ms = sentinel;
1359 atomic_write_lock_metadata(&path, &restored).expect("atomically restore lock metadata");
1360
1361 let deadline = std::time::Instant::now() + Duration::from_millis(3_000);
1364 let mut recovered = false;
1365 while std::time::Instant::now() < deadline {
1366 thread::sleep(Duration::from_millis(25));
1367 match read_lock_metadata(&path) {
1368 Ok(meta)
1369 if meta.created_at_ms == owner.created_at_ms
1370 && meta.heartbeat_at_ms > sentinel =>
1371 {
1372 recovered = true;
1373 break;
1374 }
1375 _ => continue,
1376 }
1377 }
1378 assert!(
1379 recovered,
1380 "heartbeat did not recover after a transient malformed read — thread likely died"
1381 );
1382 drop(guard);
1383 }
1384}