use crate::service::Permit;
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,
permit: Permit,
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 _ = crate::host::fs::remove_dir(self.permit, &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(permit: Permit, path: &Path, at: SystemTime) -> io::Result<SystemTime> {
crate::host::fs::touch_dir(permit, path, at)?;
mtime(path)
}
pub fn acquire(permit: Permit, target: &Path) -> Result<Guard, LockError> {
let path = PathBuf::from(format!("{}.lock", target.display()));
if let Some(parent) = path.parent() {
crate::host::fs::create_dir_all(permit, parent).map_err(LockError::Io)?;
}
let mut backoff = MIN_BACKOFF;
for attempt in 0..=RETRIES {
match crate::host::fs::create_dir(permit, &path) {
Ok(()) => return start(permit, path).map_err(LockError::Io),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
if age(&path).is_some_and(|a| a > STALE) {
let _ = crate::host::fs::remove_dir(permit, &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(permit: Permit, 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(permit, &path, SystemTime::now()) {
Ok(stored) => held = stored,
Err(_) => {
compromised.store(true, Ordering::Release);
return;
}
}
}
})
};
Ok(Guard {
path,
permit,
stop,
beat: Some(beat),
compromised,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn age_past_staleness(lock: &Path) {
touch(
Permit::for_a_test(),
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]
#[cfg_attr(windows, ignore = "W16: Pitboard's locks on Windows")]
fn acquires_and_releases() {
let t = scratch("basic");
let lock = t.with_extension("").parent().unwrap().join("target.lock");
{
let _g = acquire(Permit::for_a_test(), &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(Permit::for_a_test(), &t).expect("first acquire");
assert!(matches!(
acquire(Permit::for_a_test(), &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(Permit::for_a_test(), &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(Permit::for_a_test(), &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(Permit::for_a_test(), &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(Permit::for_a_test(), &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(Permit::for_a_test(), &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(Permit::for_a_test(), &t).unwrap();
let lock = PathBuf::from(format!("{}.lock", t.display()));
let asked = SystemTime::now();
let stored = touch(Permit::for_a_test(), &lock, asked).unwrap();
assert_eq!(
stored,
mtime(&lock).unwrap(),
"reading it back twice gives the same answer, whatever it is"
);
}
}