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, 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_debug!("released filesystem lock at {}", self.path.display()),
183 Ok(false) => {}
184 Err(error) => slog_warn!(
185 "failed to release filesystem lock at {}: {}",
186 self.path.display(),
187 error
188 ),
189 }
190 }
191}
192
193#[derive(Debug)]
194pub enum AcquireError {
195 Io(io::Error),
196 Timeout,
197}
198
199impl fmt::Display for AcquireError {
200 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
201 match self {
202 AcquireError::Io(error) => write!(f, "filesystem lock I/O error: {error}"),
203 AcquireError::Timeout => write!(f, "timed out acquiring filesystem lock"),
204 }
205 }
206}
207
208impl std::error::Error for AcquireError {}
209
210impl From<io::Error> for AcquireError {
211 fn from(error: io::Error) -> Self {
212 AcquireError::Io(error)
213 }
214}
215
216fn acquire_with_config(
217 path: &Path,
218 timeout: Option<Duration>,
219 config: LockConfig,
220) -> Result<LockGuard, AcquireError> {
221 let deadline = timeout.map(|timeout| Instant::now() + timeout);
222 let hostname = current_hostname();
223 let mut warned_live_owner = false;
224 let mut warned_stale_live_owner = false;
225 let mut transient_create_failures: u32 = 0;
226 let mut attempted_once = false;
227 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_debug!("acquired filesystem lock at {}", path.display());
401
402 Ok(LockGuard {
403 path: path.to_path_buf(),
404 metadata,
405 shutdown,
406 heartbeat_failed,
407 heartbeat_done: done_rx,
408 heartbeat: Some(heartbeat),
409 })
410}
411
412fn run_heartbeat(
413 path: PathBuf,
414 owner: LockMetadata,
415 shutdown: Arc<AtomicBool>,
416 config: LockConfig,
417) {
418 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 lock_operation_trace_lines_are_debug_not_info() {
966 let source = include_str!("fs_lock.rs");
967 assert!(source.contains("slog_debug!(\"acquired filesystem lock at {}\", path.display())"));
968 assert!(
969 source.contains("slog_debug!(\"released filesystem lock at {}\", self.path.display())")
970 );
971 assert!(!source.contains("slog_info!(\"acquired filesystem lock at {}\", path.display())"));
972 assert!(
973 !source.contains("slog_info!(\"released filesystem lock at {}\", self.path.display())")
974 );
975 }
976
977 #[test]
978 fn acquire_creates_lockfile_and_unlocks_on_drop() {
979 let (_dir, path) = test_lock_path();
980
981 let guard = acquire_with_config(&path, None, test_config()).expect("acquire lock");
982 let metadata = read_lock_metadata(&path).expect("read lock metadata");
983 assert_eq!(metadata.pid, std::process::id());
984 assert_eq!(metadata.hostname, current_hostname());
985 assert_eq!(metadata.created_at_ms, guard.metadata.created_at_ms);
986 assert_eq!(metadata.writer_epoch, guard.metadata.writer_epoch);
987 #[cfg(unix)]
988 {
989 use std::os::unix::fs::PermissionsExt;
990 assert_eq!(
991 fs::metadata(&path).unwrap().permissions().mode() & 0o777,
992 0o600
993 );
994 }
995
996 drop(guard);
997 assert!(!path.exists());
998 }
999
1000 #[test]
1001 fn permission_denied_is_treated_as_transient_create_contention() {
1002 let err = io::Error::from(io::ErrorKind::PermissionDenied);
1005 assert!(is_transient_create_contention(&err));
1006 }
1007
1008 #[test]
1009 fn unrelated_io_errors_are_not_treated_as_contention() {
1010 let err = io::Error::from(io::ErrorKind::NotFound);
1013 assert!(!is_transient_create_contention(&err));
1014 }
1015
1016 #[cfg(windows)]
1017 #[test]
1018 fn windows_sharing_violation_is_treated_as_transient_create_contention() {
1019 let err = io::Error::from_raw_os_error(32);
1022 assert!(is_transient_create_contention(&err));
1023 }
1024
1025 #[test]
1026 fn reclaim_refuses_to_delete_a_different_owners_lock() {
1027 let (_dir, path) = test_lock_path();
1028
1029 let owner_b = synthetic_metadata(4242, "host-b".to_string(), now_ms());
1031 create_lock_file_atomically(&path, &owner_b).expect("write owner B lock");
1032
1033 let judged_a = synthetic_metadata(1111, "host-a".to_string(), now_ms() - 1_000_000);
1036 let removed = reclaim_lock_file(&path, &judged_a).expect("reclaim");
1037 assert!(!removed, "must not remove a different owner's lock");
1038 assert!(path.exists(), "owner B's lock must survive");
1039 let still = read_lock_metadata(&path).expect("still readable");
1040 assert_eq!(still.pid, 4242, "owner B's lock intact");
1041 }
1042
1043 #[test]
1044 fn reclaim_deletes_when_identity_still_matches() {
1045 let (_dir, path) = test_lock_path();
1046 let owner = synthetic_metadata(1111, "host-a".to_string(), 5_000);
1047 create_lock_file_atomically(&path, &owner).expect("write lock");
1048
1049 let removed = reclaim_lock_file(&path, &owner).expect("reclaim");
1051 assert!(removed, "matching-identity stale lock should be removed");
1052 assert!(!path.exists());
1053
1054 assert!(!reclaim_lock_file(&path, &owner).expect("reclaim missing"));
1056 }
1057
1058 #[test]
1059 fn try_acquire_once_never_waits_behind_live_owner() {
1060 let (_dir, path) = test_lock_path();
1061 let _guard = acquire_with_config(&path, None, test_config()).expect("acquire lock");
1062 let started = Instant::now();
1063
1064 let result = try_acquire_once(&path);
1065
1066 assert!(matches!(result, Err(AcquireError::Timeout)));
1067 assert!(started.elapsed() < Duration::from_millis(250));
1068 }
1069
1070 #[test]
1071 fn acquire_serializes_concurrent_callers() {
1072 let (_dir, path) = test_lock_path();
1073 let path = Arc::new(path);
1074 let barrier = Arc::new(Barrier::new(3));
1075 let inside = Arc::new(AtomicUsize::new(0));
1076 let entered = Arc::new(AtomicUsize::new(0));
1077 let max_inside = Arc::new(AtomicUsize::new(0));
1078
1079 let mut handles = Vec::new();
1080 for _ in 0..2 {
1081 let path = Arc::clone(&path);
1082 let barrier = Arc::clone(&barrier);
1083 let inside = Arc::clone(&inside);
1084 let entered = Arc::clone(&entered);
1085 let max_inside = Arc::clone(&max_inside);
1086 handles.push(thread::spawn(move || {
1087 barrier.wait();
1088 let guard = acquire_with_config(&path, Some(Duration::from_secs(2)), test_config())
1089 .expect("thread acquire lock");
1090 let previous = inside.fetch_add(1, Ordering::SeqCst);
1091 assert_eq!(previous, 0, "two lock holders overlapped");
1092 entered.fetch_add(1, Ordering::SeqCst);
1093 max_inside.fetch_max(previous + 1, Ordering::SeqCst);
1094 thread::sleep(Duration::from_millis(75));
1095 inside.fetch_sub(1, Ordering::SeqCst);
1096 drop(guard);
1097 }));
1098 }
1099
1100 barrier.wait();
1101 for handle in handles {
1102 handle.join().expect("join worker");
1103 }
1104
1105 assert_eq!(entered.load(Ordering::SeqCst), 2);
1106 assert_eq!(max_inside.load(Ordering::SeqCst), 1);
1107 assert!(!path.exists());
1108 }
1109
1110 #[test]
1111 fn failed_atomic_replacement_preserves_existing_destination() {
1112 let dir = tempfile::tempdir().expect("create temp dir");
1113 let source = dir.path().join("source.tmp");
1114 let destination = dir.path().join("artifact.bin");
1115 fs::write(&source, b"new artifact").expect("write source");
1116 fs::write(&destination, b"valid old artifact").expect("write destination");
1117
1118 let error = rename_over_with(&source, &destination, |_from, _to| {
1119 Err(io::Error::new(
1120 io::ErrorKind::PermissionDenied,
1121 "injected replacement failure",
1122 ))
1123 })
1124 .expect_err("replacement must fail");
1125
1126 assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
1127 assert_eq!(
1128 fs::read(&destination).expect("read preserved destination"),
1129 b"valid old artifact"
1130 );
1131 assert_eq!(
1132 fs::read(&source).expect("read retained source"),
1133 b"new artifact"
1134 );
1135 }
1136
1137 #[test]
1138 fn heartbeat_updates_lockfile_timestamp() {
1139 let (_dir, path) = test_lock_path();
1140 let guard = acquire_with_config(&path, None, test_config()).expect("acquire lock");
1141 let initial = read_lock_metadata(&path)
1142 .expect("read initial metadata")
1143 .heartbeat_at_ms;
1144
1145 let deadline = std::time::Instant::now() + Duration::from_millis(2_000);
1155 let mut updated = initial;
1156 while std::time::Instant::now() < deadline {
1157 thread::sleep(Duration::from_millis(50));
1158 match read_lock_metadata(&path) {
1159 Ok(meta) => {
1160 updated = meta.heartbeat_at_ms;
1161 if updated > initial {
1162 break;
1163 }
1164 }
1165 Err(ReadLockError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
1166 continue;
1169 }
1170 Err(other) => panic!("read updated metadata: {other:?}"),
1171 }
1172 }
1173 assert!(
1174 updated > initial,
1175 "heartbeat timestamp did not advance within 2s"
1176 );
1177 drop(guard);
1178 }
1179
1180 #[test]
1181 fn dead_pid_lock_is_reclaimed() {
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::from_secs(1)), test_config())
1187 .expect("reclaim dead pid 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 zero_timeout_dead_pid_reclaim_acquires_after_removing_stale_file() {
1195 let (_dir, path) = test_lock_path();
1196 let metadata = synthetic_metadata(999_999_999, current_hostname(), now_ms());
1197 write_synthetic_lock(&path, &metadata);
1198
1199 let guard = acquire_with_config(&path, Some(Duration::ZERO), test_config())
1200 .expect("zero-timeout acquire should claim the reaped stale lock");
1201 let metadata = read_lock_metadata(&path).expect("read reclaimed lock");
1202 assert_eq!(metadata.pid, std::process::id());
1203 drop(guard);
1204 }
1205
1206 #[test]
1207 fn stale_heartbeat_from_live_pid_blocks() {
1208 let (_dir, path) = test_lock_path();
1209 let mut metadata = current_process_metadata();
1210 metadata.created_at_ms = now_ms().saturating_sub(60_000);
1211 metadata.heartbeat_at_ms = now_ms().saturating_sub(60_000);
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 assert_eq!(read_lock_metadata(&path).expect("read lock"), metadata);
1217
1218 remove_lock_file(&path).expect("cleanup synthetic lock");
1219 }
1220
1221 #[test]
1222 fn healthy_live_owner_blocks() {
1223 let (_dir, path) = test_lock_path();
1224 let metadata = current_process_metadata();
1225 write_synthetic_lock(&path, &metadata);
1226
1227 let result = acquire_with_config(&path, Some(Duration::from_millis(80)), test_config());
1228 assert!(matches!(result, Err(AcquireError::Timeout)));
1229
1230 remove_lock_file(&path).expect("cleanup synthetic lock");
1231 }
1232
1233 #[test]
1234 fn malformed_lockfile_is_reclaimed() {
1235 let (_dir, path) = test_lock_path();
1236 fs::write(&path, b"not valid json").expect("write malformed lock");
1237
1238 let guard = acquire_with_config(&path, Some(Duration::from_secs(1)), test_config())
1239 .expect("reclaim malformed lock");
1240 let metadata = read_lock_metadata(&path).expect("read reclaimed lock");
1241 assert_eq!(metadata.pid, std::process::id());
1242 drop(guard);
1243 }
1244
1245 #[test]
1246 fn cross_host_lock_is_not_stolen_before_extended_stale_threshold() {
1247 let (_dir, path) = test_lock_path();
1248 let now = now_ms();
1249 let metadata = LockMetadata {
1250 pid: std::process::id(),
1251 hostname: format!("{}-other", current_hostname()),
1252 created_at_ms: now,
1253 heartbeat_at_ms: now,
1254 writer_epoch: format!("cross-host-{now}"),
1255 };
1256 write_synthetic_lock(&path, &metadata);
1257
1258 let result = acquire_with_config(&path, Some(Duration::from_millis(80)), test_config());
1259 assert!(matches!(result, Err(AcquireError::Timeout)));
1260 assert_eq!(read_lock_metadata(&path).expect("read lock"), metadata);
1261
1262 remove_lock_file(&path).expect("cleanup synthetic lock");
1263 }
1264
1265 #[test]
1266 fn stale_cross_host_lock_is_reclaimed_after_extended_threshold() {
1267 let (_dir, path) = test_lock_path();
1268 let stale_at =
1269 now_ms().saturating_sub(test_config().cross_host_stale_heartbeat_ms() + 1_000);
1270 let metadata = LockMetadata {
1271 pid: std::process::id(),
1272 hostname: format!("{}-other", current_hostname()),
1273 created_at_ms: stale_at,
1274 heartbeat_at_ms: stale_at,
1275 writer_epoch: format!("cross-host-{stale_at}"),
1276 };
1277 write_synthetic_lock(&path, &metadata);
1278
1279 let guard = acquire_with_config(&path, Some(Duration::from_secs(1)), test_config())
1280 .expect("reclaim stale cross-host lock");
1281 let reclaimed = read_lock_metadata(&path).expect("read reclaimed lock");
1282 assert_eq!(reclaimed.hostname, current_hostname());
1283 assert_ne!(reclaimed.created_at_ms, metadata.created_at_ms);
1284 drop(guard);
1285 }
1286
1287 #[test]
1288 fn live_owner_over_10min_warns_but_blocks() {
1289 let (_dir, path) = test_lock_path();
1290 let mut metadata = current_process_metadata();
1291 metadata.created_at_ms = now_ms().saturating_sub(11 * 60 * 1_000);
1292 metadata.heartbeat_at_ms = now_ms();
1293 write_synthetic_lock(&path, &metadata);
1294
1295 let result = acquire_with_config(&path, Some(Duration::from_millis(80)), test_config());
1296 assert!(matches!(result, Err(AcquireError::Timeout)));
1297 assert_eq!(read_lock_metadata(&path).expect("read lock"), metadata);
1298
1299 remove_lock_file(&path).expect("cleanup synthetic lock");
1300 }
1301
1302 #[test]
1303 fn drop_stops_heartbeat_thread() {
1304 let (_dir, path) = test_lock_path();
1305 let guard = acquire_with_config(&path, None, test_config()).expect("acquire lock");
1306 drop(guard);
1307
1308 thread::sleep(Duration::from_millis(
1309 test_config().heartbeat_interval_ms * 3,
1310 ));
1311 assert!(
1312 !path.exists(),
1313 "heartbeat recreated or kept updating lockfile"
1314 );
1315 }
1316
1317 #[test]
1318 fn heartbeat_error_classification_terminal_vs_transient() {
1319 assert!(heartbeat_error_is_terminal(&HeartbeatError::LockGone));
1321 assert!(heartbeat_error_is_terminal(&HeartbeatError::NotOwner));
1322 assert!(!heartbeat_error_is_terminal(&HeartbeatError::Io(
1325 io::Error::other("disk blip")
1326 )));
1327 let malformed: serde_json::Error =
1328 serde_json::from_str::<LockMetadata>("not json").unwrap_err();
1329 assert!(!heartbeat_error_is_terminal(&HeartbeatError::Malformed(
1330 malformed
1331 )));
1332 }
1333
1334 #[test]
1335 fn heartbeat_survives_transient_malformed_and_recovers() {
1336 let (_dir, path) = test_lock_path();
1344 let guard = acquire_with_config(&path, None, test_config()).expect("acquire lock");
1345 let owner = guard.metadata.clone();
1346
1347 fs::write(&path, b"{ not valid json").expect("corrupt lockfile");
1352
1353 thread::sleep(Duration::from_millis(
1356 test_config().heartbeat_interval_ms * 4,
1357 ));
1358
1359 let sentinel = now_ms().saturating_sub(1_000_000);
1370 let mut restored = owner.clone();
1371 restored.heartbeat_at_ms = sentinel;
1372 atomic_write_lock_metadata(&path, &restored).expect("atomically restore lock metadata");
1373
1374 let deadline = std::time::Instant::now() + Duration::from_millis(3_000);
1377 let mut recovered = false;
1378 while std::time::Instant::now() < deadline {
1379 thread::sleep(Duration::from_millis(25));
1380 match read_lock_metadata(&path) {
1381 Ok(meta)
1382 if meta.created_at_ms == owner.created_at_ms
1383 && meta.heartbeat_at_ms > sentinel =>
1384 {
1385 recovered = true;
1386 break;
1387 }
1388 _ => continue,
1389 }
1390 }
1391 assert!(
1392 recovered,
1393 "heartbeat did not recover after a transient malformed read — thread likely died"
1394 );
1395 drop(guard);
1396 }
1397}