200 lines
6.1 KiB
Rust
200 lines
6.1 KiB
Rust
//! Process-wide advisory lock for the effective IdeA app-data directory.
|
|
|
|
use std::fs::{self, File, OpenOptions};
|
|
use std::io;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use fs4::fs_std::FileExt;
|
|
use thiserror::Error;
|
|
|
|
const LOCK_FILE_NAME: &str = "idea.lock";
|
|
|
|
/// RAII guard holding the exclusive advisory lock on `<app_data_dir>/idea.lock`.
|
|
///
|
|
/// The lock is released when this value is dropped or the process exits.
|
|
#[derive(Debug)]
|
|
pub struct AppDataDirLock {
|
|
lock_path: PathBuf,
|
|
file: File,
|
|
}
|
|
|
|
impl AppDataDirLock {
|
|
/// Path of the lock file currently held by this guard.
|
|
#[must_use]
|
|
pub fn lock_path(&self) -> &Path {
|
|
&self.lock_path
|
|
}
|
|
}
|
|
|
|
impl Drop for AppDataDirLock {
|
|
fn drop(&mut self) {
|
|
let _ = self.file.unlock();
|
|
}
|
|
}
|
|
|
|
/// Error returned while acquiring the app-data lock.
|
|
#[derive(Debug, Error)]
|
|
pub enum AppDataDirLockError {
|
|
/// Another process already holds the lock for this app-data directory.
|
|
#[error("app-data lock already held: {lock_path}")]
|
|
Contended {
|
|
/// Lock file path.
|
|
lock_path: PathBuf,
|
|
},
|
|
/// The lock file or its parent directory could not be accessed.
|
|
#[error("failed to access app-data lock {lock_path}: {source}")]
|
|
Io {
|
|
/// Lock file path, or the intended path when directory creation failed.
|
|
lock_path: PathBuf,
|
|
/// Underlying I/O error.
|
|
#[source]
|
|
source: io::Error,
|
|
},
|
|
}
|
|
|
|
/// Acquires an exclusive non-blocking advisory lock for `app_data_dir`.
|
|
///
|
|
/// The effective lock file is `<app_data_dir>/idea.lock`; different app-data
|
|
/// directories therefore do not block each other.
|
|
///
|
|
/// # Errors
|
|
/// [`AppDataDirLockError::Contended`] when another process already holds the
|
|
/// same lock, or [`AppDataDirLockError::Io`] for filesystem access failures.
|
|
pub fn acquire_app_data_dir_lock(
|
|
app_data_dir: impl AsRef<Path>,
|
|
) -> Result<AppDataDirLock, AppDataDirLockError> {
|
|
let app_data_dir = app_data_dir.as_ref();
|
|
let lock_path = app_data_dir.join(LOCK_FILE_NAME);
|
|
fs::create_dir_all(app_data_dir).map_err(|source| AppDataDirLockError::Io {
|
|
lock_path: lock_path.clone(),
|
|
source,
|
|
})?;
|
|
let file = OpenOptions::new()
|
|
.read(true)
|
|
.write(true)
|
|
.create(true)
|
|
.open(&lock_path)
|
|
.map_err(|source| AppDataDirLockError::Io {
|
|
lock_path: lock_path.clone(),
|
|
source,
|
|
})?;
|
|
match file.try_lock_exclusive() {
|
|
Ok(()) => Ok(AppDataDirLock { lock_path, file }),
|
|
Err(source) if source.kind() == io::ErrorKind::WouldBlock => {
|
|
Err(AppDataDirLockError::Contended { lock_path })
|
|
}
|
|
Err(source) => Err(AppDataDirLockError::Io { lock_path, source }),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::env;
|
|
use std::process::{Command, Stdio};
|
|
use std::thread;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use super::*;
|
|
|
|
struct TempDir(PathBuf);
|
|
|
|
impl TempDir {
|
|
fn new(label: &str) -> Self {
|
|
let path =
|
|
env::temp_dir().join(format!("idea-app-lock-{label}-{}", uuid::Uuid::new_v4()));
|
|
fs::create_dir_all(&path).unwrap();
|
|
Self(path)
|
|
}
|
|
|
|
fn path(&self) -> &Path {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl Drop for TempDir {
|
|
fn drop(&mut self) {
|
|
let _ = fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn contention_on_same_path_is_reported() {
|
|
let tmp = TempDir::new("same-path");
|
|
let _guard = acquire_app_data_dir_lock(tmp.path()).unwrap();
|
|
|
|
let err = acquire_app_data_dir_lock(tmp.path()).unwrap_err();
|
|
|
|
assert!(matches!(err, AppDataDirLockError::Contended { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn drop_releases_lock_and_allows_reacquire() {
|
|
let tmp = TempDir::new("reacquire");
|
|
let guard = acquire_app_data_dir_lock(tmp.path()).unwrap();
|
|
drop(guard);
|
|
|
|
let reacquired = acquire_app_data_dir_lock(tmp.path()).unwrap();
|
|
|
|
assert_eq!(reacquired.lock_path(), tmp.path().join(LOCK_FILE_NAME));
|
|
}
|
|
|
|
#[test]
|
|
fn different_app_data_dirs_can_coexist() {
|
|
let a = TempDir::new("a");
|
|
let b = TempDir::new("b");
|
|
|
|
let lock_a = acquire_app_data_dir_lock(a.path()).unwrap();
|
|
let lock_b = acquire_app_data_dir_lock(b.path()).unwrap();
|
|
|
|
assert_ne!(lock_a.lock_path(), lock_b.lock_path());
|
|
}
|
|
|
|
#[test]
|
|
fn child_process_holds_app_data_lock_until_killed() {
|
|
let Some(dir) = env::var_os("IDEA_LOCK_TEST_CHILD_DIR") else {
|
|
return;
|
|
};
|
|
let Some(ready) = env::var_os("IDEA_LOCK_TEST_CHILD_READY") else {
|
|
return;
|
|
};
|
|
|
|
let _guard = acquire_app_data_dir_lock(PathBuf::from(dir)).unwrap();
|
|
fs::write(ready, b"ready").unwrap();
|
|
thread::sleep(Duration::from_secs(30));
|
|
}
|
|
|
|
#[test]
|
|
fn second_os_process_fails_and_brutal_kill_releases_lock() {
|
|
let tmp = TempDir::new("process");
|
|
let ready = tmp.path().join("child-ready");
|
|
let exe = env::current_exe().unwrap();
|
|
let mut child = Command::new(exe)
|
|
.arg("--exact")
|
|
.arg("lock::tests::child_process_holds_app_data_lock_until_killed")
|
|
.arg("--nocapture")
|
|
.env("IDEA_LOCK_TEST_CHILD_DIR", tmp.path())
|
|
.env("IDEA_LOCK_TEST_CHILD_READY", &ready)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.spawn()
|
|
.unwrap();
|
|
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
while !ready.exists() && Instant::now() < deadline {
|
|
thread::sleep(Duration::from_millis(20));
|
|
}
|
|
assert!(ready.exists(), "child did not acquire the lock in time");
|
|
|
|
let err = acquire_app_data_dir_lock(tmp.path()).unwrap_err();
|
|
assert!(matches!(err, AppDataDirLockError::Contended { .. }));
|
|
|
|
child.kill().unwrap();
|
|
let status = child.wait().unwrap();
|
|
assert!(!status.success());
|
|
|
|
let reacquired = acquire_app_data_dir_lock(tmp.path()).unwrap();
|
|
assert_eq!(reacquired.lock_path(), tmp.path().join(LOCK_FILE_NAME));
|
|
}
|
|
}
|