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::{mpsc, Arc, Barrier};
928
929 fn test_config() -> LockConfig {
930 LockConfig {
931 heartbeat_interval_ms: 25,
932 stale_heartbeat_ms: 2_000,
933 live_owner_warn_ms: LIVE_OWNER_WARN_MS,
934 poll_interval_ms: 10,
935 }
936 }
937
938 fn test_lock_path() -> (tempfile::TempDir, PathBuf) {
939 let dir = tempfile::tempdir().expect("create temp dir");
940 let path = dir.path().join("test.lock");
941 (dir, path)
942 }
943
944 fn write_synthetic_lock(path: &Path, metadata: &LockMetadata) {
945 let mut file = open_new_lock_file(path).expect("create synthetic lock");
946 write_lock_metadata_to_file(&mut file, metadata).expect("write synthetic lock");
947 }
948
949 fn synthetic_metadata(pid: u32, hostname: String, created_at_ms: u64) -> LockMetadata {
950 LockMetadata {
951 pid,
952 hostname,
953 created_at_ms,
954 heartbeat_at_ms: created_at_ms,
955 writer_epoch: format!("synthetic-{pid}-{created_at_ms}"),
956 }
957 }
958
959 fn current_process_metadata() -> LockMetadata {
960 let now = now_ms();
961 synthetic_metadata(std::process::id(), current_hostname(), now)
962 }
963
964 #[test]
965 fn lock_operation_trace_lines_are_debug_not_info() {
966 let source = include_str!("fs_lock.rs");
967 assert!(source.contains("slog_debug!(\"acquired filesystem lock at {}\", path.display())"));
968 assert!(
969 source.contains("slog_debug!(\"released filesystem lock at {}\", self.path.display())")
970 );
971 assert!(!source.contains("slog_info!(\"acquired filesystem lock at {}\", path.display())"));
972 assert!(
973 !source.contains("slog_info!(\"released filesystem lock at {}\", self.path.display())")
974 );
975 }
976
977 #[test]
978 fn acquire_creates_lockfile_and_unlocks_on_drop() {
979 let (_dir, path) = test_lock_path();
980
981 let guard = acquire_with_config(&path, None, test_config()).expect("acquire lock");
982 let metadata = read_lock_metadata(&path).expect("read lock metadata");
983 assert_eq!(metadata.pid, std::process::id());
984 assert_eq!(metadata.hostname, current_hostname());
985 assert_eq!(metadata.created_at_ms, guard.metadata.created_at_ms);
986 assert_eq!(metadata.writer_epoch, guard.metadata.writer_epoch);
987 #[cfg(unix)]
988 {
989 use std::os::unix::fs::PermissionsExt;
990 assert_eq!(
991 fs::metadata(&path).unwrap().permissions().mode() & 0o777,
992 0o600
993 );
994 }
995
996 drop(guard);
997 assert!(!path.exists());
998 }
999
1000 #[test]
1001 fn permission_denied_is_treated_as_transient_create_contention() {
1002 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 contender_path = path.clone();
1063 let (started_tx, started_rx) = mpsc::sync_channel(1);
1064 let (result_tx, result_rx) = mpsc::sync_channel(1);
1065 let contender = std::thread::spawn(move || {
1066 let _ = started_tx.send(());
1067 let _ = result_tx.send(try_acquire_once(&contender_path));
1068 });
1069
1070 started_rx
1071 .recv_timeout(Duration::from_secs(5))
1072 .expect("contender should start");
1073 let result = match result_rx.recv_timeout(Duration::from_secs(2)) {
1074 Ok(result) => result,
1075 Err(mpsc::RecvTimeoutError::Disconnected) => {
1076 contender.join().expect("contender should not panic");
1077 panic!("contender exited without reporting a result");
1078 }
1079 Err(mpsc::RecvTimeoutError::Timeout) => {
1080 drop(guard);
1081 match result_rx.recv_timeout(Duration::from_secs(1)) {
1082 Ok(_) | Err(mpsc::RecvTimeoutError::Disconnected) => {
1083 let _ = contender.join();
1084 }
1085 Err(mpsc::RecvTimeoutError::Timeout) => {}
1086 }
1087 panic!("try-acquire blocked behind the live owner");
1088 }
1089 };
1090
1091 contender.join().expect("contender should exit");
1092 assert!(matches!(result, Err(AcquireError::Timeout)));
1093 }
1094
1095 #[test]
1096 fn acquire_serializes_concurrent_callers() {
1097 let (_dir, path) = test_lock_path();
1098 let path = Arc::new(path);
1099 let barrier = Arc::new(Barrier::new(3));
1100 let inside = Arc::new(AtomicUsize::new(0));
1101 let entered = Arc::new(AtomicUsize::new(0));
1102 let max_inside = Arc::new(AtomicUsize::new(0));
1103
1104 let mut handles = Vec::new();
1105 for _ in 0..2 {
1106 let path = Arc::clone(&path);
1107 let barrier = Arc::clone(&barrier);
1108 let inside = Arc::clone(&inside);
1109 let entered = Arc::clone(&entered);
1110 let max_inside = Arc::clone(&max_inside);
1111 handles.push(thread::spawn(move || {
1112 barrier.wait();
1113 let guard = acquire_with_config(&path, Some(Duration::from_secs(2)), test_config())
1114 .expect("thread acquire lock");
1115 let previous = inside.fetch_add(1, Ordering::SeqCst);
1116 assert_eq!(previous, 0, "two lock holders overlapped");
1117 entered.fetch_add(1, Ordering::SeqCst);
1118 max_inside.fetch_max(previous + 1, Ordering::SeqCst);
1119 thread::sleep(Duration::from_millis(75));
1120 inside.fetch_sub(1, Ordering::SeqCst);
1121 drop(guard);
1122 }));
1123 }
1124
1125 barrier.wait();
1126 for handle in handles {
1127 handle.join().expect("join worker");
1128 }
1129
1130 assert_eq!(entered.load(Ordering::SeqCst), 2);
1131 assert_eq!(max_inside.load(Ordering::SeqCst), 1);
1132 assert!(!path.exists());
1133 }
1134
1135 #[test]
1136 fn failed_atomic_replacement_preserves_existing_destination() {
1137 let dir = tempfile::tempdir().expect("create temp dir");
1138 let source = dir.path().join("source.tmp");
1139 let destination = dir.path().join("artifact.bin");
1140 fs::write(&source, b"new artifact").expect("write source");
1141 fs::write(&destination, b"valid old artifact").expect("write destination");
1142
1143 let error = rename_over_with(&source, &destination, |_from, _to| {
1144 Err(io::Error::new(
1145 io::ErrorKind::PermissionDenied,
1146 "injected replacement failure",
1147 ))
1148 })
1149 .expect_err("replacement must fail");
1150
1151 assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
1152 assert_eq!(
1153 fs::read(&destination).expect("read preserved destination"),
1154 b"valid old artifact"
1155 );
1156 assert_eq!(
1157 fs::read(&source).expect("read retained source"),
1158 b"new artifact"
1159 );
1160 }
1161
1162 #[test]
1163 fn heartbeat_updates_lockfile_timestamp() {
1164 let (_dir, path) = test_lock_path();
1165 let guard = acquire_with_config(&path, None, test_config()).expect("acquire lock");
1166 let initial = read_lock_metadata(&path)
1167 .expect("read initial metadata")
1168 .heartbeat_at_ms;
1169
1170 let deadline = std::time::Instant::now() + Duration::from_millis(2_000);
1180 let mut updated = initial;
1181 while std::time::Instant::now() < deadline {
1182 thread::sleep(Duration::from_millis(50));
1183 match read_lock_metadata(&path) {
1184 Ok(meta) => {
1185 updated = meta.heartbeat_at_ms;
1186 if updated > initial {
1187 break;
1188 }
1189 }
1190 Err(ReadLockError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
1191 continue;
1194 }
1195 Err(other) => panic!("read updated metadata: {other:?}"),
1196 }
1197 }
1198 assert!(
1199 updated > initial,
1200 "heartbeat timestamp did not advance within 2s"
1201 );
1202 drop(guard);
1203 }
1204
1205 #[test]
1206 fn dead_pid_lock_is_reclaimed() {
1207 let (_dir, path) = test_lock_path();
1208 let metadata = synthetic_metadata(999_999_999, current_hostname(), now_ms());
1209 write_synthetic_lock(&path, &metadata);
1210
1211 let guard = acquire_with_config(&path, Some(Duration::from_secs(1)), test_config())
1212 .expect("reclaim dead pid lock");
1213 let metadata = read_lock_metadata(&path).expect("read reclaimed lock");
1214 assert_eq!(metadata.pid, std::process::id());
1215 drop(guard);
1216 }
1217
1218 #[test]
1219 fn zero_timeout_dead_pid_reclaim_acquires_after_removing_stale_file() {
1220 let (_dir, path) = test_lock_path();
1221 let metadata = synthetic_metadata(999_999_999, current_hostname(), now_ms());
1222 write_synthetic_lock(&path, &metadata);
1223
1224 let guard = acquire_with_config(&path, Some(Duration::ZERO), test_config())
1225 .expect("zero-timeout acquire should claim the reaped stale lock");
1226 let metadata = read_lock_metadata(&path).expect("read reclaimed lock");
1227 assert_eq!(metadata.pid, std::process::id());
1228 drop(guard);
1229 }
1230
1231 #[test]
1232 fn stale_heartbeat_from_live_pid_blocks() {
1233 let (_dir, path) = test_lock_path();
1234 let mut metadata = current_process_metadata();
1235 metadata.created_at_ms = now_ms().saturating_sub(60_000);
1236 metadata.heartbeat_at_ms = now_ms().saturating_sub(60_000);
1237 write_synthetic_lock(&path, &metadata);
1238
1239 let result = acquire_with_config(&path, Some(Duration::from_millis(80)), test_config());
1240 assert!(matches!(result, Err(AcquireError::Timeout)));
1241 assert_eq!(read_lock_metadata(&path).expect("read lock"), metadata);
1242
1243 remove_lock_file(&path).expect("cleanup synthetic lock");
1244 }
1245
1246 #[test]
1247 fn healthy_live_owner_blocks() {
1248 let (_dir, path) = test_lock_path();
1249 let metadata = current_process_metadata();
1250 write_synthetic_lock(&path, &metadata);
1251
1252 let result = acquire_with_config(&path, Some(Duration::from_millis(80)), test_config());
1253 assert!(matches!(result, Err(AcquireError::Timeout)));
1254
1255 remove_lock_file(&path).expect("cleanup synthetic lock");
1256 }
1257
1258 #[test]
1259 fn malformed_lockfile_is_reclaimed() {
1260 let (_dir, path) = test_lock_path();
1261 fs::write(&path, b"not valid json").expect("write malformed lock");
1262
1263 let guard = acquire_with_config(&path, Some(Duration::from_secs(1)), test_config())
1264 .expect("reclaim malformed lock");
1265 let metadata = read_lock_metadata(&path).expect("read reclaimed lock");
1266 assert_eq!(metadata.pid, std::process::id());
1267 drop(guard);
1268 }
1269
1270 #[test]
1271 fn cross_host_lock_is_not_stolen_before_extended_stale_threshold() {
1272 let (_dir, path) = test_lock_path();
1273 let now = now_ms();
1274 let metadata = LockMetadata {
1275 pid: std::process::id(),
1276 hostname: format!("{}-other", current_hostname()),
1277 created_at_ms: now,
1278 heartbeat_at_ms: now,
1279 writer_epoch: format!("cross-host-{now}"),
1280 };
1281 write_synthetic_lock(&path, &metadata);
1282
1283 let result = acquire_with_config(&path, Some(Duration::from_millis(80)), test_config());
1284 assert!(matches!(result, Err(AcquireError::Timeout)));
1285 assert_eq!(read_lock_metadata(&path).expect("read lock"), metadata);
1286
1287 remove_lock_file(&path).expect("cleanup synthetic lock");
1288 }
1289
1290 #[test]
1291 fn stale_cross_host_lock_is_reclaimed_after_extended_threshold() {
1292 let (_dir, path) = test_lock_path();
1293 let stale_at =
1294 now_ms().saturating_sub(test_config().cross_host_stale_heartbeat_ms() + 1_000);
1295 let metadata = LockMetadata {
1296 pid: std::process::id(),
1297 hostname: format!("{}-other", current_hostname()),
1298 created_at_ms: stale_at,
1299 heartbeat_at_ms: stale_at,
1300 writer_epoch: format!("cross-host-{stale_at}"),
1301 };
1302 write_synthetic_lock(&path, &metadata);
1303
1304 let guard = acquire_with_config(&path, Some(Duration::from_secs(1)), test_config())
1305 .expect("reclaim stale cross-host lock");
1306 let reclaimed = read_lock_metadata(&path).expect("read reclaimed lock");
1307 assert_eq!(reclaimed.hostname, current_hostname());
1308 assert_ne!(reclaimed.created_at_ms, metadata.created_at_ms);
1309 drop(guard);
1310 }
1311
1312 #[test]
1313 fn live_owner_over_10min_warns_but_blocks() {
1314 let (_dir, path) = test_lock_path();
1315 let mut metadata = current_process_metadata();
1316 metadata.created_at_ms = now_ms().saturating_sub(11 * 60 * 1_000);
1317 metadata.heartbeat_at_ms = now_ms();
1318 write_synthetic_lock(&path, &metadata);
1319
1320 let result = acquire_with_config(&path, Some(Duration::from_millis(80)), test_config());
1321 assert!(matches!(result, Err(AcquireError::Timeout)));
1322 assert_eq!(read_lock_metadata(&path).expect("read lock"), metadata);
1323
1324 remove_lock_file(&path).expect("cleanup synthetic lock");
1325 }
1326
1327 #[test]
1328 fn drop_stops_heartbeat_thread() {
1329 let (_dir, path) = test_lock_path();
1330 let guard = acquire_with_config(&path, None, test_config()).expect("acquire lock");
1331 drop(guard);
1332
1333 thread::sleep(Duration::from_millis(
1334 test_config().heartbeat_interval_ms * 3,
1335 ));
1336 assert!(
1337 !path.exists(),
1338 "heartbeat recreated or kept updating lockfile"
1339 );
1340 }
1341
1342 #[test]
1343 fn heartbeat_error_classification_terminal_vs_transient() {
1344 assert!(heartbeat_error_is_terminal(&HeartbeatError::LockGone));
1346 assert!(heartbeat_error_is_terminal(&HeartbeatError::NotOwner));
1347 assert!(!heartbeat_error_is_terminal(&HeartbeatError::Io(
1350 io::Error::other("disk blip")
1351 )));
1352 let malformed: serde_json::Error =
1353 serde_json::from_str::<LockMetadata>("not json").unwrap_err();
1354 assert!(!heartbeat_error_is_terminal(&HeartbeatError::Malformed(
1355 malformed
1356 )));
1357 }
1358
1359 #[test]
1360 fn heartbeat_survives_transient_malformed_and_recovers() {
1361 let (_dir, path) = test_lock_path();
1369 let guard = acquire_with_config(&path, None, test_config()).expect("acquire lock");
1370 let owner = guard.metadata.clone();
1371
1372 fs::write(&path, b"{ not valid json").expect("corrupt lockfile");
1377
1378 thread::sleep(Duration::from_millis(
1381 test_config().heartbeat_interval_ms * 4,
1382 ));
1383
1384 let sentinel = now_ms().saturating_sub(1_000_000);
1395 let mut restored = owner.clone();
1396 restored.heartbeat_at_ms = sentinel;
1397 atomic_write_lock_metadata(&path, &restored).expect("atomically restore lock metadata");
1398
1399 let deadline = std::time::Instant::now() + Duration::from_millis(3_000);
1402 let mut recovered = false;
1403 while std::time::Instant::now() < deadline {
1404 thread::sleep(Duration::from_millis(25));
1405 match read_lock_metadata(&path) {
1406 Ok(meta)
1407 if meta.created_at_ms == owner.created_at_ms
1408 && meta.heartbeat_at_ms > sentinel =>
1409 {
1410 recovered = true;
1411 break;
1412 }
1413 _ => continue,
1414 }
1415 }
1416 assert!(
1417 recovered,
1418 "heartbeat did not recover after a transient malformed read — thread likely died"
1419 );
1420 drop(guard);
1421 }
1422}