merge feature/67-correct-repository-branch dans develop
This commit is contained in:
@ -106,6 +106,8 @@ pub mod openai_tools;
|
|||||||
pub mod stream;
|
pub mod stream;
|
||||||
mod ticket_dto;
|
mod ticket_dto;
|
||||||
|
|
||||||
|
pub use infrastructure::{acquire_app_data_dir_lock, AppDataDirLock, AppDataDirLockError};
|
||||||
|
|
||||||
use crate::mcp_endpoint::{mcp_endpoint, AppMcpRuntimeProvider, McpEndpoint};
|
use crate::mcp_endpoint::{mcp_endpoint, AppMcpRuntimeProvider, McpEndpoint};
|
||||||
use crate::openai_tools::{AppOpenAiToolInvoker, LateBoundOpenAiToolInvoker};
|
use crate::openai_tools::{AppOpenAiToolInvoker, LateBoundOpenAiToolInvoker};
|
||||||
|
|
||||||
|
|||||||
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -128,6 +128,13 @@ pub fn run_from_args(args: Vec<String>) -> ExitCode {
|
|||||||
return ExitCode::from(2);
|
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()
|
match tokio::runtime::Builder::new_multi_thread()
|
||||||
.enable_all()
|
.enable_all()
|
||||||
@ -147,6 +154,19 @@ pub fn run_from_args(args: Vec<String>) -> 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.
|
/// Configuration shared by the standalone and embedded web-server entry points.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct ServerConfig {
|
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::<Vec<_>>();
|
||||||
|
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]
|
#[test]
|
||||||
fn default_app_data_dir_uses_tauri_identifier_with_env_precedence() {
|
fn default_app_data_dir_uses_tauri_identifier_with_env_precedence() {
|
||||||
let _lock = ENV_LOCK.lock().unwrap();
|
let _lock = ENV_LOCK.lock().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user