1use std::path::{Path, PathBuf};
53use std::sync::Arc;
54
55use serde::{Deserialize, Serialize};
56use thiserror::Error;
57
58use super::atomic::write_atomic;
59
60pub const RENEW_INTERVAL_MS: u64 = 5_000;
65
66pub const LEASE_TTL_MS: u64 = RENEW_INTERVAL_MS * 3;
69
70#[derive(Debug, Error)]
71pub enum LeaseError {
72 #[error("session '{instance_id}' is already owned by another AgentMux process (boot {owner_boot_id}, renewed {age_ms}ms ago)")]
73 HeldByOther {
74 instance_id: String,
75 owner_boot_id: String,
76 age_ms: u64,
77 holder: Option<LeaseHolder>,
80 },
81 #[error("io: {0}")]
82 Io(#[from] std::io::Error),
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct LeaseHolder {
90 pub owner_boot_id: String,
91 pub block_id: String,
92 pub pid: u32,
93 pub channel: String,
94 pub version: String,
95 pub hostname: String,
96 pub epoch: u64,
97 pub acquired_at_ms: i64,
98 pub renewed_at_ms: i64,
99}
100
101#[derive(Debug, Clone, Default)]
105pub struct ClaimantInfo {
106 pub channel: String,
107 pub version: String,
108}
109
110#[derive(Debug, Serialize, Deserialize)]
111struct LeaseFile {
112 instance_id: String,
113 owner_boot_id: String,
114 block_id: String,
115 session_id: Option<String>,
120 pid: u32,
121 acquired_at_ms: i64,
122 renewed_at_ms: i64,
123 #[serde(default)]
128 epoch: u64,
129 #[serde(default)]
130 hostname: String,
131 #[serde(default)]
134 pid_start_s: u64,
135 #[serde(default)]
136 channel: String,
137 #[serde(default)]
138 version: String,
139 #[serde(default)]
146 claim_id: String,
147}
148
149impl LeaseFile {
150 fn holder(&self) -> LeaseHolder {
151 LeaseHolder {
152 owner_boot_id: self.owner_boot_id.clone(),
153 block_id: self.block_id.clone(),
154 pid: self.pid,
155 channel: self.channel.clone(),
156 version: self.version.clone(),
157 hostname: self.hostname.clone(),
158 epoch: self.epoch,
159 acquired_at_ms: self.acquired_at_ms,
160 renewed_at_ms: self.renewed_at_ms,
161 }
162 }
163}
164
165#[derive(Debug, Clone)]
169pub struct Lease {
170 instance_id: String,
171 owner_boot_id: Arc<str>,
172 block_id: String,
173 claim_id: String,
174 epoch: u64,
175 path: PathBuf,
176}
177
178impl Lease {
179 pub fn instance_id(&self) -> &str {
180 &self.instance_id
181 }
182
183 pub fn epoch(&self) -> u64 {
186 self.epoch
187 }
188}
189
190use agentmux_common::time::now_ms;
191
192enum OnDisk {
194 Missing,
195 Unreadable { age_ms: u64 },
197 Valid(LeaseFile),
198}
199
200pub struct LeaseStore {
201 root: PathBuf,
202 ttl_ms: u64,
203}
204
205impl LeaseStore {
206 pub fn open(registry_root: &Path) -> std::io::Result<Self> {
208 let root = registry_root.join("leases");
209 std::fs::create_dir_all(&root)?;
210 Ok(Self {
211 root,
212 ttl_ms: LEASE_TTL_MS,
213 })
214 }
215
216 #[cfg(test)]
219 pub(crate) fn open_with_ttl(registry_root: &Path, ttl_ms: u64) -> std::io::Result<Self> {
220 let mut s = Self::open(registry_root)?;
221 s.ttl_ms = ttl_ms;
222 Ok(s)
223 }
224
225 fn content_path(&self, instance_id: &str) -> PathBuf {
226 self.root.join(format!("{instance_id}.lease.json"))
227 }
228
229 fn lock_path(&self, instance_id: &str) -> PathBuf {
230 self.root.join(format!("{instance_id}.lock"))
231 }
232
233 fn epoch_path(&self, instance_id: &str) -> PathBuf {
234 self.root.join(format!("{instance_id}.epoch"))
235 }
236
237 fn read(path: &Path) -> std::io::Result<OnDisk> {
238 let bytes = match std::fs::read(path) {
239 Ok(b) => b,
240 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(OnDisk::Missing),
241 Err(e) => return Err(e),
242 };
243 match serde_json::from_slice::<LeaseFile>(&bytes) {
244 Ok(file) => Ok(OnDisk::Valid(file)),
245 Err(_) => {
246 let age_ms = std::fs::metadata(path)
247 .and_then(|m| m.modified())
248 .ok()
249 .and_then(|t| t.elapsed().ok())
250 .map(|d| d.as_millis() as u64)
251 .unwrap_or(0);
254 Ok(OnDisk::Unreadable { age_ms })
255 }
256 }
257 }
258
259 fn next_epoch(&self, instance_id: &str, floor: u64) -> std::io::Result<u64> {
264 let path = self.epoch_path(instance_id);
265 let stored = std::fs::read_to_string(&path)
266 .ok()
267 .and_then(|s| s.trim().parse::<u64>().ok())
268 .unwrap_or(0);
269 let next = stored.max(floor) + 1;
270 write_atomic(&path, format!("{next}\n").as_bytes())?;
271 Ok(next)
272 }
273
274 pub fn claim(
277 &self,
278 instance_id: &str,
279 boot_id: &Arc<str>,
280 block_id: &str,
281 session_id_hint: Option<&str>,
282 ) -> Result<Lease, LeaseError> {
283 self.claim_as(instance_id, boot_id, block_id, session_id_hint, &ClaimantInfo::default())
284 }
285
286 pub fn claim_as(
305 &self,
306 instance_id: &str,
307 boot_id: &Arc<str>,
308 block_id: &str,
309 session_id_hint: Option<&str>,
310 info: &ClaimantInfo,
311 ) -> Result<Lease, LeaseError> {
312 let _cs = CriticalSection::enter(&self.lock_path(instance_id))?;
313 let path = self.content_path(instance_id);
314
315 let (kept_epoch, floor) = match Self::read(&path)? {
316 OnDisk::Missing => (None, 0),
317 OnDisk::Unreadable { age_ms } => {
318 if age_ms <= self.ttl_ms {
319 return Err(LeaseError::HeldByOther {
320 instance_id: instance_id.to_string(),
321 owner_boot_id: "<unreadable lease file>".to_string(),
322 age_ms,
323 holder: None,
324 });
325 }
326 tracing::warn!(
327 instance_id,
328 age_ms,
329 "lease: unreadable lease file not rewritten for a full TTL — reclaiming"
330 );
331 (None, 0)
332 }
333 OnDisk::Valid(existing) => {
334 let age_ms = (now_ms() - existing.renewed_at_ms).max(0) as u64;
335 if existing.owner_boot_id == boot_id.as_ref() && same_block(&existing, block_id) {
336 (Some(existing.epoch), existing.epoch)
340 } else if existing.owner_boot_id == boot_id.as_ref() {
341 if age_ms <= self.ttl_ms {
343 return Err(LeaseError::HeldByOther {
344 instance_id: instance_id.to_string(),
345 owner_boot_id: existing.owner_boot_id.clone(),
346 age_ms,
347 holder: Some(existing.holder()),
348 });
349 }
350 (None, existing.epoch)
351 } else {
352 if age_ms <= self.ttl_ms && !holder_is_dead(&existing) {
353 return Err(LeaseError::HeldByOther {
354 instance_id: instance_id.to_string(),
355 owner_boot_id: existing.owner_boot_id.clone(),
356 age_ms,
357 holder: Some(existing.holder()),
358 });
359 }
360 (None, existing.epoch)
362 }
363 }
364 };
365
366 let epoch = match kept_epoch {
367 Some(e) if e > 0 => e,
368 _ => self.next_epoch(instance_id, floor)?,
369 };
370 let claim_id = uuid::Uuid::new_v4().to_string();
371 let bytes = lease_bytes(instance_id, boot_id, block_id, session_id_hint, epoch, &claim_id, info)?;
372 write_atomic(&path, &bytes)?;
373 Ok(Lease {
374 instance_id: instance_id.to_string(),
375 owner_boot_id: Arc::clone(boot_id),
376 block_id: block_id.to_string(),
377 claim_id,
378 epoch,
379 path,
380 })
381 }
382
383 pub fn renew(&self, lease: &Lease) -> Result<(), LeaseError> {
390 let _cs = CriticalSection::enter(&self.lock_path(&lease.instance_id))?;
391 let existing = self.owned_file(lease)?;
392 let renewed = LeaseFile {
393 renewed_at_ms: now_ms(),
394 ..existing
395 };
396 let mut bytes = serde_json::to_vec_pretty(&renewed).map_err(std::io::Error::from)?;
397 bytes.push(b'\n');
398 write_atomic(&lease.path, &bytes)?;
399 Ok(())
400 }
401
402 pub fn verify(&self, lease: &Lease) -> Result<(), LeaseError> {
406 let _cs = CriticalSection::enter(&self.lock_path(&lease.instance_id))?;
407 self.owned_file(lease).map(|_| ())
408 }
409
410 fn owned_file(&self, lease: &Lease) -> Result<LeaseFile, LeaseError> {
413 let existing = match Self::read(&lease.path)? {
414 OnDisk::Valid(f) => f,
415 OnDisk::Missing | OnDisk::Unreadable { .. } => {
418 return Err(LeaseError::HeldByOther {
419 instance_id: lease.instance_id.clone(),
420 owner_boot_id: "<unknown — lease file missing or unreadable>".to_string(),
421 age_ms: 0,
422 holder: None,
423 });
424 }
425 };
426 if !is_this_claim(&existing, lease) {
430 let age_ms = (now_ms() - existing.renewed_at_ms).max(0) as u64;
431 return Err(LeaseError::HeldByOther {
432 instance_id: lease.instance_id.clone(),
433 owner_boot_id: existing.owner_boot_id.clone(),
434 age_ms,
435 holder: Some(existing.holder()),
436 });
437 }
438 Ok(existing)
439 }
440
441 pub fn release(&self, lease: &Lease) -> Result<(), LeaseError> {
448 let _cs = CriticalSection::enter(&self.lock_path(&lease.instance_id))?;
449 let existing = match Self::read(&lease.path)? {
450 OnDisk::Valid(f) => f,
451 OnDisk::Missing | OnDisk::Unreadable { .. } => return Ok(()),
452 };
453 if !is_this_claim(&existing, lease) {
454 return Ok(());
455 }
456 match std::fs::remove_file(&lease.path) {
457 Ok(()) => Ok(()),
458 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
459 Err(e) => Err(e.into()),
460 }
461 }
462
463 pub fn live_holder_other_than(
470 &self,
471 instance_id: &str,
472 own_boot_id: &str,
473 ) -> std::io::Result<Option<LeaseHolder>> {
474 let _cs = CriticalSection::enter(&self.lock_path(instance_id))?;
475 Ok(match Self::read(&self.content_path(instance_id))? {
476 OnDisk::Missing => None,
477 OnDisk::Unreadable { age_ms } => (age_ms <= self.ttl_ms).then(|| LeaseHolder {
478 owner_boot_id: "<unreadable lease file>".to_string(),
479 block_id: String::new(),
480 pid: 0,
481 channel: String::new(),
482 version: String::new(),
483 hostname: String::new(),
484 epoch: 0,
485 acquired_at_ms: 0,
486 renewed_at_ms: 0,
487 }),
488 OnDisk::Valid(f) => {
489 let age_ms = (now_ms() - f.renewed_at_ms).max(0) as u64;
490 (f.owner_boot_id != own_boot_id && age_ms <= self.ttl_ms && !holder_is_dead(&f))
491 .then(|| f.holder())
492 }
493 })
494 }
495}
496
497fn same_block(file: &LeaseFile, block_id: &str) -> bool {
500 file.block_id.is_empty() || file.block_id == block_id
501}
502
503fn is_this_claim(file: &LeaseFile, lease: &Lease) -> bool {
506 file.owner_boot_id == lease.owner_boot_id.as_ref()
507 && same_block(file, &lease.block_id)
508 && (file.claim_id.is_empty() || file.claim_id == lease.claim_id)
509}
510
511fn this_host() -> String {
514 whoami::fallible::hostname()
515 .unwrap_or_default()
516 .trim()
517 .to_lowercase()
518}
519
520fn process_start_s(pid: u32) -> Option<u64> {
523 let target = sysinfo::Pid::from(pid as usize);
524 let mut sys = sysinfo::System::new();
525 sys.refresh_processes_specifics(
526 sysinfo::ProcessesToUpdate::Some(&[target]),
527 true,
528 sysinfo::ProcessRefreshKind::nothing(),
529 );
530 sys.process(target).map(|p| p.start_time())
531}
532
533fn holder_is_dead(file: &LeaseFile) -> bool {
539 if file.hostname.is_empty() || file.pid == 0 || file.hostname != this_host() {
540 return false;
541 }
542 match process_start_s(file.pid) {
543 None => true,
544 Some(start) => file.pid_start_s != 0 && start != file.pid_start_s,
545 }
546}
547
548fn lease_bytes(
549 instance_id: &str,
550 boot_id: &Arc<str>,
551 block_id: &str,
552 session_id_hint: Option<&str>,
553 epoch: u64,
554 claim_id: &str,
555 info: &ClaimantInfo,
556) -> Result<Vec<u8>, std::io::Error> {
557 let now = now_ms();
558 let pid = std::process::id();
559 let file = LeaseFile {
560 instance_id: instance_id.to_string(),
561 owner_boot_id: boot_id.to_string(),
562 block_id: block_id.to_string(),
563 session_id: session_id_hint.map(str::to_string),
564 pid,
565 acquired_at_ms: now,
566 renewed_at_ms: now,
567 epoch,
568 hostname: this_host(),
569 pid_start_s: process_start_s(pid).unwrap_or(0),
570 channel: info.channel.clone(),
571 version: info.version.clone(),
572 claim_id: claim_id.to_string(),
573 };
574 let mut bytes = serde_json::to_vec_pretty(&file).map_err(std::io::Error::from)?;
575 bytes.push(b'\n');
576 Ok(bytes)
577}
578
579struct CriticalSection {
585 _file: std::fs::File,
586}
587
588impl CriticalSection {
589 fn enter(lock_path: &Path) -> std::io::Result<Self> {
603 let file = std::fs::OpenOptions::new()
604 .create(true)
605 .write(true)
606 .open(lock_path)?;
607 lock_exclusive(&file)?;
608 Ok(Self { _file: file })
609 }
610}
611
612#[cfg(unix)]
613pub(crate) fn lock_exclusive(file: &std::fs::File) -> std::io::Result<()> {
614 use std::os::unix::io::AsRawFd;
615 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
617 return Err(std::io::Error::last_os_error());
618 }
619 Ok(())
620}
621
622#[cfg(windows)]
623pub(crate) fn lock_exclusive(file: &std::fs::File) -> std::io::Result<()> {
624 use std::os::windows::io::AsRawHandle;
625 use windows_sys::Win32::Storage::FileSystem::{LockFileEx, LOCKFILE_EXCLUSIVE_LOCK};
626 use windows_sys::Win32::System::IO::OVERLAPPED;
627
628 let handle = file.as_raw_handle() as windows_sys::Win32::Foundation::HANDLE;
629 let mut overlapped: OVERLAPPED = unsafe { std::mem::zeroed() };
634 let ok = unsafe {
636 LockFileEx(handle, LOCKFILE_EXCLUSIVE_LOCK, 0, u32::MAX, u32::MAX, &mut overlapped)
637 };
638 if ok == 0 {
639 return Err(std::io::Error::last_os_error());
640 }
641 Ok(())
642}
643
644#[cfg(unix)]
648pub(crate) fn try_lock_exclusive(file: &std::fs::File) -> std::io::Result<bool> {
649 use std::os::unix::io::AsRawFd;
650 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 {
651 return Ok(true);
652 }
653 let err = std::io::Error::last_os_error();
654 if err.kind() == std::io::ErrorKind::WouldBlock {
655 Ok(false)
656 } else {
657 Err(err)
658 }
659}
660
661#[cfg(windows)]
662pub(crate) fn try_lock_exclusive(file: &std::fs::File) -> std::io::Result<bool> {
663 use std::os::windows::io::AsRawHandle;
664 use windows_sys::Win32::Foundation::ERROR_LOCK_VIOLATION;
665 use windows_sys::Win32::Storage::FileSystem::{LockFileEx, LOCKFILE_EXCLUSIVE_LOCK, LOCKFILE_FAIL_IMMEDIATELY};
666 use windows_sys::Win32::System::IO::OVERLAPPED;
667
668 let handle = file.as_raw_handle() as windows_sys::Win32::Foundation::HANDLE;
669 let mut overlapped: OVERLAPPED = unsafe { std::mem::zeroed() };
670 let ok = unsafe {
671 LockFileEx(
672 handle,
673 LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY,
674 0,
675 u32::MAX,
676 u32::MAX,
677 &mut overlapped,
678 )
679 };
680 if ok != 0 {
681 return Ok(true);
682 }
683 let err = std::io::Error::last_os_error();
684 if err.raw_os_error() == Some(ERROR_LOCK_VIOLATION as i32) {
685 Ok(false)
686 } else {
687 Err(err)
688 }
689}
690
691#[cfg(test)]
692mod tests {
693 use super::*;
694
695 fn store(ttl_ms: u64) -> (tempfile::TempDir, LeaseStore) {
696 let tmp = tempfile::tempdir().unwrap();
697 let s = LeaseStore::open_with_ttl(tmp.path(), ttl_ms).unwrap();
698 (tmp, s)
699 }
700
701 fn boot(id: &str) -> Arc<str> {
702 Arc::from(id)
703 }
704
705 fn lease_path(tmp: &tempfile::TempDir, id: &str) -> PathBuf {
706 tmp.path().join("leases").join(format!("{id}.lease.json"))
707 }
708
709 fn on_disk(tmp: &tempfile::TempDir, id: &str) -> LeaseFile {
710 serde_json::from_slice(&std::fs::read(lease_path(tmp, id)).unwrap()).unwrap()
711 }
712
713 fn set_holder_process(tmp: &tempfile::TempDir, id: &str, pid: u32, start_s: u64) {
716 let mut f = on_disk(tmp, id);
717 f.pid = pid;
718 f.pid_start_s = start_s;
719 std::fs::write(lease_path(tmp, id), serde_json::to_vec(&f).unwrap()).unwrap();
720 }
721
722 fn exited_pid() -> u32 {
726 const NO_SUCH_PID: u32 = 4_000_000_000;
727 assert!(process_start_s(NO_SUCH_PID).is_none(), "pid {NO_SUCH_PID} unexpectedly running");
728 NO_SUCH_PID
729 }
730
731 #[test]
732 fn claim_succeeds_when_no_existing_lease() {
733 let (_tmp, s) = store(60_000);
734 let lease = s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
735 assert_eq!(lease.instance_id(), "agent-1");
736 }
737
738 #[test]
739 fn claim_fails_when_held_by_other_and_fresh() {
740 let (_tmp, s) = store(60_000);
741 s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
742 let err = s.claim("agent-1", &boot("boot-b"), "block-2", None).unwrap_err();
743 match err {
744 LeaseError::HeldByOther { owner_boot_id, holder, .. } => {
745 assert_eq!(owner_boot_id, "boot-a");
746 let holder = holder.expect("a readable lease names its holder");
747 assert_eq!(holder.block_id, "block-1");
748 assert_eq!(holder.pid, std::process::id());
749 }
750 other => panic!("expected HeldByOther, got {other:?}"),
751 }
752 }
753
754 #[test]
755 fn claim_is_idempotent_for_same_owner() {
756 let (_tmp, s) = store(60_000);
757 let first = s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
758 let second = s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
759 assert_eq!(first.epoch(), second.epoch(), "a same-owner re-claim is not an ownership change");
760 }
761
762 #[test]
763 fn claim_reclaims_after_ttl_expiry() {
764 let (_tmp, s) = store(50);
768 s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
769 std::thread::sleep(std::time::Duration::from_millis(1_000));
770 let reclaimed = s.claim("agent-1", &boot("boot-b"), "block-2", None);
771 assert!(reclaimed.is_ok(), "expected expired lease to be reclaimable, got {reclaimed:?}");
772 }
773
774 #[test]
776 fn a_fresh_unreadable_lease_is_held_not_evicted() {
777 let (tmp, s) = store(60_000);
778 let path = lease_path(&tmp, "agent-1");
779 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
780 std::fs::write(&path, b"not json").unwrap();
781 let err = s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap_err();
782 assert!(matches!(err, LeaseError::HeldByOther { holder: None, .. }), "got {err:?}");
783 assert!(s.live_holder_other_than("agent-1", "boot-a").unwrap().is_some());
784 }
785
786 #[test]
787 fn an_unreadable_lease_nobody_rewrote_for_a_ttl_is_reclaimed() {
788 let (tmp, s) = store(50);
789 let path = lease_path(&tmp, "agent-1");
790 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
791 std::fs::write(&path, b"not json").unwrap();
792 std::thread::sleep(std::time::Duration::from_millis(1_000));
793 let claimed = s.claim("agent-1", &boot("boot-a"), "block-1", None);
794 assert!(claimed.is_ok(), "expected a stale unreadable lease to be reclaimable, got {claimed:?}");
795 }
796
797 #[test]
800 fn a_lease_in_the_old_format_is_still_honoured() {
801 let (tmp, s) = store(60_000);
802 let path = lease_path(&tmp, "agent-1");
803 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
804 let old = serde_json::json!({
805 "instance_id": "agent-1", "owner_boot_id": "boot-old", "block_id": "b",
806 "session_id": null, "pid": 1, "acquired_at_ms": now_ms(), "renewed_at_ms": now_ms(),
807 });
808 std::fs::write(&path, serde_json::to_vec(&old).unwrap()).unwrap();
809 let err = s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap_err();
810 match err {
811 LeaseError::HeldByOther { owner_boot_id, .. } => assert_eq!(owner_boot_id, "boot-old"),
812 other => panic!("expected HeldByOther, got {other:?}"),
813 }
814 }
815
816 #[test]
819 fn epochs_strictly_increase_across_release_and_reclaim() {
820 let (_tmp, s) = store(60_000);
821 let a = s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
822 s.release(&a).unwrap();
823 let b = s.claim("agent-1", &boot("boot-b"), "block-2", None).unwrap();
824 s.release(&b).unwrap();
825 let c = s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
826 assert!(a.epoch() >= 1);
827 assert!(b.epoch() > a.epoch(), "{} !> {}", b.epoch(), a.epoch());
828 assert!(c.epoch() > b.epoch(), "{} !> {}", c.epoch(), b.epoch());
829 }
830
831 #[test]
832 fn epochs_increase_across_ttl_reclaim_too() {
833 let (_tmp, s) = store(50);
834 let a = s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
835 std::thread::sleep(std::time::Duration::from_millis(1_000));
836 let b = s.claim("agent-1", &boot("boot-b"), "block-2", None).unwrap();
837 assert!(b.epoch() > a.epoch());
838 }
839
840 #[test]
841 fn a_holder_whose_process_is_gone_is_reclaimed_immediately() {
842 let (tmp, s) = store(60_000);
843 s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
844 set_holder_process(&tmp, "agent-1", exited_pid(), 0);
845 let reclaimed = s.claim("agent-1", &boot("boot-b"), "block-2", None);
846 assert!(reclaimed.is_ok(), "a dead holder must not wait out the TTL, got {reclaimed:?}");
847 assert!(s.live_holder_other_than("agent-1", "boot-c").unwrap().is_some());
848 }
849
850 #[test]
853 fn a_reused_pid_does_not_keep_a_lease_alive() {
854 let (tmp, s) = store(60_000);
855 s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
856 let me = std::process::id();
857 let my_start = process_start_s(me).expect("this test process is running");
858 set_holder_process(&tmp, "agent-1", me, my_start.saturating_sub(3_600));
859 assert!(s.claim("agent-1", &boot("boot-b"), "block-2", None).is_ok());
860 }
861
862 #[test]
863 fn a_holder_on_another_host_is_never_presumed_dead() {
864 let (tmp, s) = store(60_000);
865 s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
866 let mut f = on_disk(&tmp, "agent-1");
867 f.hostname = "some-other-host".to_string();
868 f.pid = exited_pid();
869 std::fs::write(lease_path(&tmp, "agent-1"), serde_json::to_vec(&f).unwrap()).unwrap();
870 assert!(s.claim("agent-1", &boot("boot-b"), "block-2", None).is_err());
871 }
872
873 #[test]
874 fn renew_extends_ttl_and_blocks_reclaim_by_other() {
875 let (_tmp, s) = store(5_000);
881 let lease = s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
882 std::thread::sleep(std::time::Duration::from_millis(20));
883 s.renew(&lease).unwrap();
884 std::thread::sleep(std::time::Duration::from_millis(20));
885 let err = s.claim("agent-1", &boot("boot-b"), "block-2", None).unwrap_err();
887 assert!(matches!(err, LeaseError::HeldByOther { .. }));
888 }
889
890 #[test]
891 fn renew_fails_when_already_reclaimed_by_other() {
892 let (tmp, s) = store(50);
893 let lease = s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
894 std::thread::sleep(std::time::Duration::from_millis(1_000));
895 s.claim("agent-1", &boot("boot-b"), "block-2", None).unwrap();
896
897 let err = s.renew(&lease).unwrap_err();
898 assert!(matches!(err, LeaseError::HeldByOther { .. }));
899
900 assert_eq!(on_disk(&tmp, "agent-1").owner_boot_id, "boot-b");
902 }
903
904 #[test]
906 fn verify_detects_a_lost_lease_without_writing() {
907 let (tmp, s) = store(50);
908 let lease = s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
909 assert!(s.verify(&lease).is_ok());
910 std::thread::sleep(std::time::Duration::from_millis(1_000));
911 assert!(s.verify(&lease).is_ok());
913 s.claim("agent-1", &boot("boot-b"), "block-2", None).unwrap();
914 assert!(matches!(s.verify(&lease), Err(LeaseError::HeldByOther { .. })));
915 assert_eq!(on_disk(&tmp, "agent-1").owner_boot_id, "boot-b");
916 }
917
918 #[test]
919 fn release_removes_the_lease_file() {
920 let (tmp, s) = store(60_000);
921 let lease = s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
922 s.release(&lease).unwrap();
923 assert!(!lease_path(&tmp, "agent-1").exists());
924 }
925
926 #[test]
927 fn release_is_a_noop_when_already_reclaimed_by_other() {
928 let (tmp, s) = store(50);
929 let lease = s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
930 std::thread::sleep(std::time::Duration::from_millis(1_000));
931 s.claim("agent-1", &boot("boot-b"), "block-2", None).unwrap();
932
933 s.release(&lease).unwrap();
934
935 assert!(lease_path(&tmp, "agent-1").exists(), "release must not delete another owner's lease");
936 assert_eq!(on_disk(&tmp, "agent-1").owner_boot_id, "boot-b");
937 }
938
939 #[test]
943 fn a_second_block_of_the_same_process_is_refused_while_the_first_holds_it() {
944 let (_tmp, s) = store(60_000);
945 let first = s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
946 let err = s.claim("agent-1", &boot("boot-a"), "block-2", None).unwrap_err();
947 match err {
948 LeaseError::HeldByOther { holder, .. } => assert_eq!(holder.unwrap().block_id, "block-1"),
949 other => panic!("expected HeldByOther, got {other:?}"),
950 }
951 assert!(s.verify(&first).is_ok(), "the holder keeps the agent");
952 s.release(&first).unwrap();
953 assert!(s.claim("agent-1", &boot("boot-a"), "block-2", None).is_ok());
954 }
955
956 #[test]
961 fn a_same_block_reclaim_supersedes_the_older_handle() {
962 let (tmp, s) = store(60_000);
963 let old = s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
964 let new = s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
965 assert_eq!(old.epoch(), new.epoch(), "same owner and block: not an ownership change");
966 s.release(&old).unwrap();
967 assert!(lease_path(&tmp, "agent-1").exists(), "the old handle's release must not free the new claim");
968 assert!(s.verify(&new).is_ok());
969 assert!(s.renew(&new).is_ok());
970 assert!(matches!(s.verify(&old), Err(LeaseError::HeldByOther { .. })));
971 assert!(matches!(s.renew(&old), Err(LeaseError::HeldByOther { .. })));
972 s.release(&new).unwrap();
973 assert!(!lease_path(&tmp, "agent-1").exists());
974 }
975
976 #[test]
980 fn a_block_superseded_after_expiry_is_fenced_and_cannot_release() {
981 let (tmp, s) = store(50);
982 let first = s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
983 std::thread::sleep(std::time::Duration::from_millis(1_000));
984 let second = s.claim("agent-1", &boot("boot-a"), "block-2", None).unwrap();
985 assert!(second.epoch() > first.epoch(), "a block change is an ownership change");
986 assert!(matches!(s.verify(&first), Err(LeaseError::HeldByOther { .. })));
987 assert!(matches!(s.renew(&first), Err(LeaseError::HeldByOther { .. })));
988 s.release(&first).unwrap();
989 assert_eq!(on_disk(&tmp, "agent-1").block_id, "block-2");
990 }
991
992 #[test]
993 fn release_is_idempotent_on_missing_file() {
994 let (_tmp, s) = store(60_000);
995 let lease = s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
996 s.release(&lease).unwrap();
997 assert!(s.release(&lease).is_ok());
998 }
999
1000 #[test]
1001 fn live_holder_ignores_self_and_names_the_holder() {
1002 let (_tmp, s) = store(60_000);
1005 s.claim_as(
1006 "agent-1",
1007 &boot("boot-a"),
1008 "block-1",
1009 None,
1010 &ClaimantInfo { channel: "stable".into(), version: "0.57.4".into() },
1011 )
1012 .unwrap();
1013 assert!(s.live_holder_other_than("agent-1", "boot-a").unwrap().is_none());
1014 let other = s.live_holder_other_than("agent-1", "boot-b").unwrap().unwrap();
1015 assert_eq!((other.channel.as_str(), other.version.as_str()), ("stable", "0.57.4"));
1016 }
1017
1018 #[test]
1019 fn live_holder_ignores_an_expired_lease() {
1020 let (_tmp, s) = store(50);
1021 s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
1022 std::thread::sleep(std::time::Duration::from_millis(1_000));
1023 assert!(s.live_holder_other_than("agent-1", "boot-b").unwrap().is_none());
1024 }
1025
1026 #[test]
1027 fn concurrent_claim_race_has_exactly_one_winner() {
1028 let tmp = tempfile::tempdir().unwrap();
1029 let root = tmp.path().to_path_buf();
1030 let n = 8;
1031 let handles: Vec<_> = (0..n)
1032 .map(|i| {
1033 let root = root.clone();
1034 std::thread::spawn(move || {
1035 let s = LeaseStore::open(&root).unwrap();
1036 s.claim("agent-1", &boot(&format!("boot-{i}")), "block", None)
1037 .is_ok()
1038 })
1039 })
1040 .collect();
1041 let wins: usize = handles.into_iter().map(|h| h.join().unwrap()).filter(|ok| *ok).count();
1042 assert_eq!(wins, 1, "exactly one claimant should win the race");
1043 }
1044}