use std::{
io::{Read, Write},
path::{Path, PathBuf},
time::{Duration, Instant},
};
use fs4::FileExt;
const NOTIFY_AFTER: Duration = Duration::from_secs(1);
const TIMEOUT: Duration = Duration::from_secs(600);
const POLL: Duration = Duration::from_millis(50);
#[derive(Debug, thiserror::Error)]
pub enum LockError {
#[error("failed to open the lock file '{path}': {source}")]
Open {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error(
"timed out after {} minutes waiting for another midenup operation (pid {holder}) to \
finish. If that process is gone, the lock is already released and retrying will succeed.",
TIMEOUT.as_secs() / 60
)]
Timeout { holder: String },
}
#[derive(Debug)]
pub struct HomeLock {
file: std::fs::File,
}
impl Drop for HomeLock {
fn drop(&mut self) {
let _ = FileExt::unlock(&self.file);
}
}
pub fn lock_path(home: &Path) -> PathBuf {
home.join(".lock")
}
pub fn acquire(home: &Path) -> Result<HomeLock, LockError> {
let path = lock_path(home);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|source| LockError::Open { path: path.clone(), source })?;
}
let mut file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&path)
.map_err(|source| LockError::Open { path: path.clone(), source })?;
let start = Instant::now();
let mut notified = false;
loop {
if FileExt::try_lock(&file).is_ok() {
let _ = file.set_len(0);
let _ = write!(file, "{}", std::process::id());
let _ = file.flush();
return Ok(HomeLock { file });
}
if start.elapsed() >= TIMEOUT {
return Err(LockError::Timeout { holder: holder_of(&path) });
}
if !notified && start.elapsed() >= NOTIFY_AFTER {
crate::info!("waiting for another midenup operation to finish...");
notified = true;
}
std::thread::sleep(POLL);
}
}
fn holder_of(path: &Path) -> String {
let mut contents = String::new();
std::fs::File::open(path)
.and_then(|mut file| file.read_to_string(&mut contents))
.ok()
.map(|_| contents.trim().to_string())
.filter(|contents| !contents.is_empty())
.unwrap_or_else(|| "unknown".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_lock_is_exclusive_and_released_on_drop() {
let temp = tempdir::TempDir::new("lock").unwrap();
let home = temp.path();
let held = acquire(home).expect("should acquire");
let other = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(lock_path(home))
.unwrap();
assert!(FileExt::try_lock(&other).is_err(), "the lock must exclude a second holder");
drop(held);
assert!(FileExt::try_lock(&other).is_ok(), "dropping the lock must release it");
}
#[test]
fn the_holder_records_its_pid_for_diagnostics() {
let temp = tempdir::TempDir::new("lock-pid").unwrap();
let _held = acquire(temp.path()).expect("should acquire");
assert_eq!(holder_of(&lock_path(temp.path())), std::process::id().to_string());
}
#[test]
fn a_released_lock_can_be_retaken() {
let temp = tempdir::TempDir::new("lock-retake").unwrap();
drop(acquire(temp.path()).unwrap());
let _again = acquire(temp.path()).expect("must be retakeable");
assert_eq!(holder_of(&lock_path(temp.path())), std::process::id().to_string());
}
}