feat(lock): implémentation lock inter-process app-data-dir V1 — acquisition non-bloquante avec RAII
This commit is contained in:
199
crates/infrastructure/src/lock.rs
Normal file
199
crates/infrastructure/src/lock.rs
Normal file
@ -0,0 +1,199 @@
|
||||
//! 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user