diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index e63426f..082b4b5 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -106,6 +106,8 @@ pub mod openai_tools; pub mod stream; mod ticket_dto; +pub use infrastructure::{acquire_app_data_dir_lock, AppDataDirLock, AppDataDirLockError}; + use crate::mcp_endpoint::{mcp_endpoint, AppMcpRuntimeProvider, McpEndpoint}; use crate::openai_tools::{AppOpenAiToolInvoker, LateBoundOpenAiToolInvoker}; diff --git a/crates/infrastructure/src/lock.rs b/crates/infrastructure/src/lock.rs new file mode 100644 index 0000000..ed28e58 --- /dev/null +++ b/crates/infrastructure/src/lock.rs @@ -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 `/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)); + } +} diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index 6853d1f..f714198 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -128,6 +128,13 @@ pub fn run_from_args(args: Vec) -> ExitCode { return ExitCode::from(2); } }; + let _app_data_lock = match backend::acquire_app_data_dir_lock(&config.app_data_dir) { + Ok(lock) => lock, + Err(err) => { + eprintln!("idea --serve: {}", serve_app_data_lock_message(&err)); + return ExitCode::from(1); + } + }; match tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -147,6 +154,19 @@ pub fn run_from_args(args: Vec) -> ExitCode { } } +fn serve_app_data_lock_message(err: &backend::AppDataDirLockError) -> String { + match err { + backend::AppDataDirLockError::Contended { lock_path } => format!( + "app-data dir is already locked at {}; another IdeA instance is using this app-data-dir", + lock_path.display() + ), + backend::AppDataDirLockError::Io { lock_path, source } => format!( + "failed to access app-data lock at {}: {source}", + lock_path.display() + ), + } +} + /// Configuration shared by the standalone and embedded web-server entry points. #[derive(Clone, Debug)] pub struct ServerConfig { @@ -5036,6 +5056,31 @@ mod tests { } } + #[test] + fn idea_serve_lock_failure_stops_before_backend_store_writes() { + let app_data_dir = + std::env::temp_dir().join(format!("idea-server-lock-{}", Uuid::new_v4())); + let web_root = create_web_root(); + let _guard = backend::acquire_app_data_dir_lock(&app_data_dir).unwrap(); + + let code = run_from_args(vec![ + "--app-data-dir".to_owned(), + app_data_dir.to_string_lossy().into_owned(), + "--web-root".to_owned(), + web_root.to_string_lossy().into_owned(), + ]); + + assert_eq!(code, ExitCode::from(1)); + let mut entries = std::fs::read_dir(&app_data_dir) + .unwrap() + .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) + .collect::>(); + entries.sort(); + assert_eq!(entries, vec!["idea.lock"]); + let _ = std::fs::remove_dir_all(app_data_dir); + let _ = std::fs::remove_dir_all(web_root); + } + #[test] fn default_app_data_dir_uses_tauri_identifier_with_env_precedence() { let _lock = ENV_LOCK.lock().unwrap();