//! 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 `/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 `/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, ) -> Result { 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)); } }