use std::collections::HashSet;
use std::fs::File;
use std::path::Path;
use std::sync::{Mutex, OnceLock};
use crate::error::{Error, Result};
#[cfg(unix)]
type LockIdentity = (u64, u64);
#[cfg(not(unix))]
type LockIdentity = std::path::PathBuf;
#[cfg(unix)]
fn lock_identity(metadata: &std::fs::Metadata) -> LockIdentity {
use std::os::unix::fs::MetadataExt;
(metadata.dev(), metadata.ino())
}
static LOCKED_IDENTITIES: OnceLock<Mutex<HashSet<LockIdentity>>> = OnceLock::new();
fn locked_identities() -> std::sync::MutexGuard<'static, HashSet<LockIdentity>> {
LOCKED_IDENTITIES
.get_or_init(|| Mutex::new(HashSet::new()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
pub struct RepositoryLock {
lock_file: Option<File>,
registered_identity: LockIdentity,
}
impl RepositoryLock {
pub fn acquire(repository_directory: &Path) -> Result<Self> {
let in_use = |detail: &str| Error::InvalidFormat {
details: format!(
"the repository at {} is locked by {detail} — \
is an AEM or Oak instance still running?",
repository_directory.display()
),
};
let lock_path = repository_directory.join("repo.lock");
let mut registry = locked_identities();
#[cfg(unix)]
let (lock_file, identity) = {
match std::fs::metadata(&lock_path) {
Ok(metadata) => {
let identity = lock_identity(&metadata);
if registry.contains(&identity) {
return Err(in_use("this process"));
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
let file = open_lock_file(&lock_path)?;
let identity = lock_identity(&file.metadata()?);
if registry.contains(&identity) {
std::mem::forget(file);
return Err(in_use("this process"));
}
(file, identity)
};
#[cfg(not(unix))]
let (lock_file, identity) = {
let file = open_lock_file(&lock_path)?;
let identity = std::fs::canonicalize(&lock_path)?;
if registry.contains(&identity) {
return Err(in_use("this process"));
}
(file, identity)
};
#[allow(
clippy::clone_on_copy,
reason = "the identity is a PathBuf on non-Unix targets"
)]
registry.insert(identity.clone());
if let Err(source) = lock_exclusively(&lock_file) {
registry.remove(&identity);
return Err(if source.kind() == std::io::ErrorKind::WouldBlock {
in_use("another process")
} else {
Error::InputOutput(source)
});
}
Ok(Self {
lock_file: Some(lock_file),
registered_identity: identity,
})
}
}
fn open_lock_file(lock_path: &Path) -> Result<File> {
Ok(std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(lock_path)?)
}
impl Drop for RepositoryLock {
fn drop(&mut self) {
let mut registry = locked_identities();
drop(self.lock_file.take());
registry.remove(&self.registered_identity);
}
}
#[cfg(unix)]
fn lock_exclusively(file: &File) -> std::io::Result<()> {
use std::os::unix::io::AsRawFd;
#[cfg(any(
target_os = "linux",
target_os = "android",
target_os = "macos",
target_os = "ios"
))]
const SET_LOCK_COMMAND: libc::c_int = libc::F_OFD_SETLK;
#[cfg(not(any(
target_os = "linux",
target_os = "android",
target_os = "macos",
target_os = "ios"
)))]
const SET_LOCK_COMMAND: libc::c_int = libc::F_SETLK;
let mut lock: libc::flock = unsafe { std::mem::zeroed() };
lock.l_type = libc::F_WRLCK as libc::c_short;
lock.l_whence = libc::SEEK_SET as libc::c_short;
let result = unsafe { libc::fcntl(file.as_raw_fd(), SET_LOCK_COMMAND, &raw mut lock) };
if result == 0 {
Ok(())
} else {
let error = std::io::Error::last_os_error();
if matches!(error.raw_os_error(), Some(libc::EAGAIN | libc::EACCES)) {
Err(std::io::Error::new(std::io::ErrorKind::WouldBlock, error))
} else {
Err(error)
}
}
}
#[cfg(not(unix))]
fn lock_exclusively(file: &File) -> std::io::Result<()> {
file.try_lock().map_err(|error| match error {
std::fs::TryLockError::WouldBlock => std::io::Error::from(std::io::ErrorKind::WouldBlock),
std::fs::TryLockError::Error(error) => error,
})
}
#[cfg(test)]
mod tests {
use super::RepositoryLock;
struct TestDirectory {
path: std::path::PathBuf,
}
impl TestDirectory {
fn new(name: &str) -> Self {
let path =
std::env::temp_dir().join(format!("froe-lock-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).expect("create test directory");
Self { path }
}
}
impl Drop for TestDirectory {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
#[test]
fn lock_creates_the_lock_file_and_releases_on_drop() {
let directory = TestDirectory::new("release");
let lock = RepositoryLock::acquire(&directory.path).expect("acquire");
assert!(directory.path.join("repo.lock").exists());
drop(lock);
let _second = RepositoryLock::acquire(&directory.path).expect("reacquire");
assert!(
directory.path.join("repo.lock").exists(),
"the lock file is never deleted"
);
}
#[test]
fn concurrent_acquisitions_are_excluded() {
let directory = TestDirectory::new("exclusion");
let held = RepositoryLock::acquire(&directory.path).expect("acquire");
let second = RepositoryLock::acquire(&directory.path);
assert!(second.is_err(), "the second acquisition must fail");
let message = second.err().expect("error").to_string();
assert!(message.contains("locked by this process"), "{message}");
drop(held);
RepositoryLock::acquire(&directory.path).expect("reacquire after release");
}
#[cfg(unix)]
#[test]
fn renaming_the_directory_does_not_defeat_same_process_exclusion() {
let directory = TestDirectory::new("rename-alias");
let renamed = directory.path.with_extension("renamed");
let _ = std::fs::remove_dir_all(&renamed);
let held = RepositoryLock::acquire(&directory.path).expect("acquire");
std::fs::rename(&directory.path, &renamed).expect("rename directory");
let through_new_path = RepositoryLock::acquire(&renamed);
assert!(
through_new_path.is_err(),
"the renamed path is the same lock file and must be refused"
);
let message = through_new_path.err().expect("error").to_string();
assert!(message.contains("locked by this process"), "{message}");
drop(held);
RepositoryLock::acquire(&renamed).expect("reacquire after release");
let _ = std::fs::remove_dir_all(&renamed);
}
}