use std::io;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use std::thread;
use std::time::{Duration, SystemTime};
const STALE: Duration = Duration::from_millis(15_000);
const HEARTBEAT: Duration = Duration::from_millis(7_500);
const RETRIES: u32 = 10;
const MIN_BACKOFF: Duration = Duration::from_millis(100);
const MAX_BACKOFF: Duration = Duration::from_millis(1_000);
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum LockError {
#[error("another process is writing credentials right now; try again in a few seconds")]
Busy,
#[error("cannot take the credential write lock: {0}")]
Io(#[source] io::Error),
}
type Stop = Arc<(Mutex<bool>, Condvar)>;
impl LockError {
pub fn code(&self) -> &'static str {
match self {
LockError::Busy => "switch_in_progress",
LockError::Io(_) => "lock_unavailable",
}
}
}
pub struct Guard {
path: PathBuf,
stop: Stop,
beat: Option<thread::JoinHandle<()>>,
compromised: Arc<AtomicBool>,
}
impl Guard {
pub fn compromised(&self) -> bool {
self.compromised.load(Ordering::Acquire)
}
}
impl Drop for Guard {
fn drop(&mut self) {
let (flag, wake) = &*self.stop;
*flag.lock().unwrap_or_else(|e| e.into_inner()) = true;
wake.notify_all();
if let Some(handle) = self.beat.take() {
let _ = handle.join();
}
if !self.compromised() {
let _ = std::fs::remove_dir(&self.path);
}
}
}
fn age(path: &Path) -> Option<Duration> {
let mtime = std::fs::metadata(path).ok()?.modified().ok()?;
SystemTime::now().duration_since(mtime).ok()
}
fn mtime(path: &Path) -> io::Result<SystemTime> {
std::fs::metadata(path)?.modified()
}
fn touch(path: &Path, at: SystemTime) -> io::Result<SystemTime> {
std::fs::File::open(path)?.set_modified(at)?;
mtime(path)
}
pub fn acquire(target: &Path) -> Result<Guard, LockError> {
let path = PathBuf::from(format!("{}.lock", target.display()));
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(LockError::Io)?;
}
let mut backoff = MIN_BACKOFF;
for attempt in 0..=RETRIES {
match std::fs::create_dir(&path) {
Ok(()) => return start(path).map_err(LockError::Io),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
if age(&path).is_some_and(|a| a > STALE) {
let _ = std::fs::remove_dir(&path);
continue;
}
}
Err(e) => return Err(LockError::Io(e)),
}
if attempt < RETRIES {
thread::sleep(backoff);
backoff = (backoff * 2).min(MAX_BACKOFF);
}
}
Err(LockError::Busy)
}
fn start(path: PathBuf) -> io::Result<Guard> {
let mut held = mtime(&path)?;
let stop: Stop = Arc::new((Mutex::new(false), Condvar::new()));
let compromised = Arc::new(AtomicBool::new(false));
let beat = {
let (path, stop, compromised) = (path.clone(), Arc::clone(&stop), Arc::clone(&compromised));
thread::spawn(move || {
let (flag, wake) = &*stop;
let mut stopped = flag.lock().unwrap_or_else(|e| e.into_inner());
loop {
let (next, _) = wake
.wait_timeout_while(stopped, HEARTBEAT, |stop| !*stop)
.unwrap_or_else(|e| e.into_inner());
stopped = next;
if *stopped {
return;
}
match mtime(&path) {
Ok(found) if found == held => {}
_ => {
compromised.store(true, Ordering::Release);
return;
}
}
match touch(&path, SystemTime::now()) {
Ok(stored) => held = stored,
Err(_) => {
compromised.store(true, Ordering::Release);
return;
}
}
}
})
};
Ok(Guard {
path,
stop,
beat: Some(beat),
compromised,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn age_past_staleness(lock: &Path) {
touch(lock, SystemTime::now() - Duration::from_secs(3600)).unwrap();
}
fn scratch(name: &str) -> PathBuf {
let p =
std::env::temp_dir().join(format!("pitboard-lock-test-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).unwrap();
p.join("target")
}
#[test]
fn acquires_and_releases() {
let t = scratch("basic");
let lock = t.with_extension("").parent().unwrap().join("target.lock");
{
let _g = acquire(&t).expect("should acquire");
assert!(lock.is_dir(), "the lock directory should exist while held");
}
assert!(
!lock.exists(),
"dropping the guard must remove the directory"
);
}
#[test]
fn refuses_while_another_holder_is_alive() {
let t = scratch("busy");
let _held = acquire(&t).expect("first acquire");
assert!(matches!(acquire(&t), Err(LockError::Busy)));
}
#[test]
fn reclaims_a_lock_left_behind_by_a_dead_process() {
let t = scratch("stale");
let lock = PathBuf::from(format!("{}.lock", t.display()));
std::fs::create_dir_all(&lock).unwrap();
age_past_staleness(&lock);
let _g = acquire(&t).expect("a stale lock must be reclaimable");
}
#[test]
fn releasing_is_immediate() {
let t = scratch("release");
let started = std::time::Instant::now();
drop(acquire(&t).unwrap());
assert!(
started.elapsed() < Duration::from_millis(250),
"release took {:?}",
started.elapsed()
);
assert!(!PathBuf::from(format!("{}.lock", t.display())).exists());
}
#[test]
fn the_heartbeat_keeps_a_held_lock_young() {
let t = scratch("beat");
let g = acquire(&t).unwrap();
let lock = PathBuf::from(format!("{}.lock", t.display()));
let when_taken = mtime(&lock).unwrap();
thread::sleep(HEARTBEAT + Duration::from_millis(400));
assert!(
mtime(&lock).unwrap() > when_taken,
"the heartbeat never touched the lock"
);
assert!(age(&lock).unwrap() < STALE);
assert!(!g.compromised(), "nobody else touched it");
}
#[test]
fn a_lock_somebody_else_touched_is_never_ours_again() {
let t = scratch("compromised");
let g = acquire(&t).unwrap();
let lock = PathBuf::from(format!("{}.lock", t.display()));
age_past_staleness(&lock);
thread::sleep(HEARTBEAT + Duration::from_millis(400));
assert!(
g.compromised(),
"a lock whose mtime this guard did not set is not this guard's"
);
drop(g);
assert!(
lock.exists(),
"and releasing it must not take away the lock that now belongs to somebody else"
);
let _ = std::fs::remove_dir(&lock);
}
#[test]
fn a_lock_that_vanished_is_not_quietly_remade() {
let t = scratch("vanished");
let g = acquire(&t).unwrap();
let lock = PathBuf::from(format!("{}.lock", t.display()));
std::fs::remove_dir(&lock).unwrap();
thread::sleep(HEARTBEAT + Duration::from_millis(400));
assert!(g.compromised());
assert!(!lock.exists(), "the heartbeat did not put it back");
}
#[test]
fn what_the_filesystem_stored_is_what_gets_remembered() {
let t = scratch("granularity");
let _g = acquire(&t).unwrap();
let lock = PathBuf::from(format!("{}.lock", t.display()));
let asked = SystemTime::now();
let stored = touch(&lock, asked).unwrap();
assert_eq!(
stored,
mtime(&lock).unwrap(),
"reading it back twice gives the same answer, whatever it is"
);
}
}