diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cf58c8e..17c8b38 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -50,6 +50,7 @@ windows-sys = { version = "0.59", features = [ "Win32_Foundation", "Win32_Security", "Win32_Storage_FileSystem", + "Win32_System_Diagnostics_ToolHelp", "Win32_System_JobObjects", "Win32_System_Threading", ] } diff --git a/src-tauri/src/core_runtime.rs b/src-tauri/src/core_runtime.rs index 3026dec..115747e 100644 --- a/src-tauri/src/core_runtime.rs +++ b/src-tauri/src/core_runtime.rs @@ -1003,7 +1003,11 @@ pub(crate) fn start_core_process_inner( let binary_path = find_core_binary(&install_dir) .ok_or_else(|| "未安装 CPA 内核,请先安装最新版".to_string())?; - if process_state.managed_pid().is_some() || is_core_running(&binary_path) { + let existing_process_ids = find_core_process_ids(&binary_path); + if process_state.managed_pid().is_some() || !existing_process_ids.is_empty() { + if !existing_process_ids.is_empty() { + process_state.adopt_process_ids(&binary_path, existing_process_ids)?; + } return Err("CPA 内核已经在运行".to_string()); } let management_address = core_management_address(&gui_config.host, gui_config.port)?; @@ -1124,26 +1128,52 @@ pub(crate) fn configure_networked_command(command: &mut Command, proxy_url: &str } pub(crate) fn stop_core_process_inner(process_state: &CoreProcessState) -> Result<(), String> { + let mut stopped_any = false; + let mut errors = Vec::new(); + if let Some(mut child) = process_state.take_child() { - terminate_child(&mut child)?; + stopped_any = true; + if let Err(error) = terminate_child(&mut child) { + errors.push(error); + } process_state.clear_lifetime_guard(); - return Ok(()); } - let install_dir = core_install_dir()?; - let binary_path = find_core_binary(&install_dir) - .ok_or_else(|| "未安装 CPA 内核,请先安装最新版".to_string())?; - let process_ids = find_core_process_ids(&binary_path); - - if process_ids.is_empty() { - return Err("CPA 内核当前未运行".to_string()); + let mut process_ids = process_state + .take_adopted_processes() + .into_iter() + .filter(|process| { + process_executable_path(process.process_id) + .as_deref() + .is_some_and(|path| executable_paths_match(&process.binary_path, path)) + }) + .map(|process| process.process_id) + .collect::>(); + if let Ok(install_dir) = core_install_dir() { + if let Some(binary_path) = find_core_binary(&install_dir) { + process_ids.extend(find_core_process_ids(&binary_path)); + } } + process_ids.sort_unstable(); + process_ids.dedup(); for process_id in process_ids { - terminate_process_id(process_id)?; + if !is_process_alive(process_id) { + continue; + } + stopped_any = true; + if let Err(error) = terminate_process_id(process_id) { + errors.push(error); + } } - Ok(()) + if !errors.is_empty() { + Err(errors.join(";")) + } else if stopped_any { + Ok(()) + } else { + Err("CPA 内核当前未运行".to_string()) + } } pub(crate) fn core_install_dir() -> Result { @@ -1545,75 +1575,76 @@ pub(crate) fn is_core_running(binary_path: &Path) -> bool { } pub(crate) fn find_core_process_ids(binary_path: &Path) -> Vec { - #[cfg(target_os = "linux")] - { - find_core_process_ids_linux(binary_path) - } - - #[cfg(not(target_os = "linux"))] - { - let _ = binary_path; - find_core_process_ids_by_name() - } -} - -#[cfg(target_os = "linux")] -pub(crate) fn find_core_process_ids_linux(binary_path: &Path) -> Vec { - let Ok(expected) = fs::canonicalize(binary_path) else { - return Vec::new(); - }; - let output = Command::new("pgrep") - .args(["-x", core_binary_name()]) - .output(); - let Ok(output) = output else { - return Vec::new(); - }; - - String::from_utf8_lossy(&output.stdout) - .lines() - .filter_map(|line| line.trim().parse::().ok()) - .filter(|pid| { - fs::read_link(format!("/proc/{pid}/exe")) - .ok() - .and_then(|path| fs::canonicalize(path).ok()) - .map(|path| path == expected) - .unwrap_or(false) + find_candidate_core_process_ids() + .into_iter() + .filter(|process_id| { + process_executable_path(*process_id) + .as_deref() + .is_some_and(|path| executable_paths_match(binary_path, path)) }) .collect() } +pub(crate) fn executable_paths_match(expected: &Path, actual: &Path) -> bool { + let expected = fs::canonicalize(expected).unwrap_or_else(|_| expected.to_path_buf()); + let actual = fs::canonicalize(actual).unwrap_or_else(|_| actual.to_path_buf()); + + #[cfg(windows)] + { + path_to_string(&expected).eq_ignore_ascii_case(&path_to_string(&actual)) + } + + #[cfg(not(windows))] + { + expected == actual + } +} + #[cfg(all(not(target_os = "linux"), target_os = "windows"))] -pub(crate) fn find_core_process_ids_by_name() -> Vec { - let image_name = core_binary_name(); - let filter = format!("IMAGENAME eq {image_name}"); - let mut command = Command::new("tasklist"); - command.args(["/FI", &filter, "/FO", "CSV", "/NH"]); - configure_background_command(&mut command); - let output = command.output(); - let Ok(output) = output else { - return Vec::new(); +pub(crate) fn find_candidate_core_process_ids() -> Vec { + use std::{ffi::OsString, mem, os::windows::ffi::OsStringExt}; + use windows_sys::Win32::{ + Foundation::{CloseHandle, INVALID_HANDLE_VALUE}, + System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, + TH32CS_SNAPPROCESS, + }, }; - String::from_utf8_lossy(&output.stdout) - .lines() - .filter_map(|line| { - let columns = line - .trim() - .trim_matches('"') - .split("\",\"") - .collect::>(); - let name = columns.first()?; - let pid = columns.get(1)?; + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }; + if snapshot == INVALID_HANDLE_VALUE { + return Vec::new(); + } - name.eq_ignore_ascii_case(image_name) - .then(|| pid.parse::().ok()) - .flatten() - }) - .collect() + let mut entry = unsafe { mem::zeroed::() }; + entry.dwSize = mem::size_of::() as u32; + let mut process_ids = Vec::new(); + if unsafe { Process32FirstW(snapshot, &mut entry) } != 0 { + loop { + let name_length = entry + .szExeFile + .iter() + .position(|character| *character == 0) + .unwrap_or(entry.szExeFile.len()); + let image_name = OsString::from_wide(&entry.szExeFile[..name_length]); + if entry.th32ProcessID != 0 + && image_name + .to_string_lossy() + .eq_ignore_ascii_case(core_binary_name()) + { + process_ids.push(entry.th32ProcessID); + } + if unsafe { Process32NextW(snapshot, &mut entry) } == 0 { + break; + } + } + } + unsafe { CloseHandle(snapshot) }; + process_ids } #[cfg(all(not(target_os = "linux"), not(target_os = "windows")))] -pub(crate) fn find_core_process_ids_by_name() -> Vec { +pub(crate) fn find_candidate_core_process_ids() -> Vec { Command::new("pgrep") .args(["-x", core_binary_name()]) .output() @@ -1627,17 +1658,99 @@ pub(crate) fn find_core_process_ids_by_name() -> Vec { .unwrap_or_default() } +#[cfg(target_os = "linux")] +pub(crate) fn find_candidate_core_process_ids() -> Vec { + Command::new("pgrep") + .args(["-x", core_binary_name()]) + .output() + .ok() + .map(|output| { + String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| line.trim().parse::().ok()) + .collect() + }) + .unwrap_or_default() +} + +#[cfg(target_os = "linux")] +pub(crate) fn process_executable_path(process_id: u32) -> Option { + fs::read_link(format!("/proc/{process_id}/exe")).ok() +} + +#[cfg(target_os = "macos")] +pub(crate) fn process_executable_path(process_id: u32) -> Option { + use std::{ffi::OsString, os::unix::ffi::OsStringExt}; + + extern "C" { + fn proc_pidpath(pid: i32, buffer: *mut std::ffi::c_void, buffer_size: u32) -> i32; + } + + let mut buffer = vec![0_u8; 4096]; + let length = unsafe { + proc_pidpath( + process_id.try_into().ok()?, + buffer.as_mut_ptr().cast(), + buffer.len() as u32, + ) + }; + if length <= 0 { + return None; + } + buffer.truncate(length as usize); + Some(PathBuf::from(OsString::from_vec(buffer))) +} + +#[cfg(windows)] +pub(crate) fn process_executable_path(process_id: u32) -> Option { + use std::{ffi::OsString, os::windows::ffi::OsStringExt}; + use windows_sys::Win32::{ + Foundation::{CloseHandle, HANDLE}, + System::Threading::{ + OpenProcess, QueryFullProcessImageNameW, PROCESS_QUERY_LIMITED_INFORMATION, + }, + }; + + let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, process_id) }; + if handle.is_null() { + return None; + } + let mut buffer = vec![0_u16; 32_768]; + let mut length = buffer.len() as u32; + let success = + unsafe { QueryFullProcessImageNameW(handle, 0, buffer.as_mut_ptr(), &mut length) }; + unsafe { CloseHandle(handle as HANDLE) }; + if success == 0 { + return None; + } + buffer.truncate(length as usize); + Some(PathBuf::from(OsString::from_wide(&buffer))) +} + +pub(crate) fn adopt_existing_core_processes( + process_state: &CoreProcessState, +) -> Result, String> { + let install_dir = core_install_dir()?; + let Some(binary_path) = find_core_binary(&install_dir) else { + process_state.clear_adopted_processes()?; + return Ok(Vec::new()); + }; + let process_ids = find_core_process_ids(&binary_path); + process_state.adopt_process_ids(&binary_path, process_ids.clone())?; + Ok(process_ids) +} + pub(crate) fn shutdown_managed_core( process_state: &CoreProcessState, gui_config_state: &GuiConfigState, ) { - let was_running = process_state.managed_pid().is_some(); + let was_running = process_state.managed_pid().is_some() + || core_install_dir() + .ok() + .and_then(|install_dir| find_core_binary(&install_dir)) + .is_some_and(|binary_path| is_core_running(&binary_path)); let _ = gui_config_state.set_run_on_startup(was_running); - - if let Some(mut child) = process_state.take_child() { - let _ = terminate_child(&mut child); - } - process_state.clear_lifetime_guard(); + let _ = stop_core_process_inner(process_state); } #[cfg(windows)] @@ -1782,6 +1895,23 @@ pub(crate) fn send_process_signal(process_id: u32, signal: &str) -> Result<(), S } } +#[cfg(windows)] +pub(crate) fn is_process_alive(process_id: u32) -> bool { + use windows_sys::Win32::{ + Foundation::{CloseHandle, WAIT_TIMEOUT}, + System::Threading::{OpenProcess, WaitForSingleObject}, + }; + + const SYNCHRONIZE_ACCESS: u32 = 0x0010_0000; + let handle = unsafe { OpenProcess(SYNCHRONIZE_ACCESS, 0, process_id) }; + if handle.is_null() { + return false; + } + let result = unsafe { WaitForSingleObject(handle, 0) }; + unsafe { CloseHandle(handle) }; + result == WAIT_TIMEOUT +} + #[cfg(not(windows))] pub(crate) fn is_process_alive(process_id: u32) -> bool { let process_id = process_id.to_string(); diff --git a/src-tauri/src/instance_lock.rs b/src-tauri/src/instance_lock.rs new file mode 100644 index 0000000..f725a06 --- /dev/null +++ b/src-tauri/src/instance_lock.rs @@ -0,0 +1,100 @@ +use sha2::{Digest, Sha256}; +use std::{fs, path::Path}; + +const APP_INSTANCE_LOCK_PREFIX: &str = "EasyCLIProxyAPI-instance"; + +pub(crate) struct AppInstanceGuard { + #[cfg(windows)] + handle: isize, + #[cfg(unix)] + _file: fs::File, +} + +pub(crate) fn acquire_app_instance_guard() -> Result { + let executable_dir = super::executable_dir()?; + acquire_app_instance_guard_for(&executable_dir) +} + +pub(crate) fn app_instance_key(executable_dir: &Path) -> String { + let resolved = fs::canonicalize(executable_dir) + .unwrap_or_else(|_| executable_dir.to_path_buf()) + .to_string_lossy() + .to_string(); + #[cfg(windows)] + let resolved = resolved.to_lowercase(); + let digest = Sha256::digest(resolved.as_bytes()); + format!("{digest:x}") +} + +#[cfg(windows)] +pub(crate) fn acquire_app_instance_guard_for( + executable_dir: &Path, +) -> Result { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::{ + Foundation::{CloseHandle, GetLastError, ERROR_ALREADY_EXISTS}, + System::Threading::CreateMutexW, + }; + + let name = format!( + "Local\\{APP_INSTANCE_LOCK_PREFIX}-{}", + app_instance_key(executable_dir) + ); + let wide_name = std::ffi::OsStr::new(&name) + .encode_wide() + .chain(Some(0)) + .collect::>(); + let handle = unsafe { CreateMutexW(std::ptr::null(), 0, wide_name.as_ptr()) }; + if handle.is_null() { + return Err(format!( + "创建当前目录的软件实例锁失败: {}", + std::io::Error::last_os_error() + )); + } + if unsafe { GetLastError() } == ERROR_ALREADY_EXISTS { + unsafe { CloseHandle(handle) }; + return Err("当前 EasyCLIProxyAPI 目录已经有一个软件实例在运行".to_string()); + } + + Ok(AppInstanceGuard { + handle: handle as isize, + }) +} + +#[cfg(unix)] +pub(crate) fn acquire_app_instance_guard_for( + executable_dir: &Path, +) -> Result { + use std::{fs::OpenOptions, os::fd::AsRawFd}; + + let lock_path = std::env::temp_dir().join(format!( + "{APP_INSTANCE_LOCK_PREFIX}-{}.lock", + app_instance_key(executable_dir) + )); + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .open(&lock_path) + .map_err(|error| format!("打开当前目录的软件实例锁失败: {error}"))?; + let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + if result != 0 { + let error = std::io::Error::last_os_error(); + let raw_error = error.raw_os_error(); + if raw_error == Some(libc::EWOULDBLOCK) || raw_error == Some(libc::EAGAIN) { + return Err("当前 EasyCLIProxyAPI 目录已经有一个软件实例在运行".to_string()); + } + return Err(format!("锁定当前 EasyCLIProxyAPI 目录失败: {error}")); + } + + Ok(AppInstanceGuard { _file: file }) +} + +#[cfg(windows)] +impl Drop for AppInstanceGuard { + fn drop(&mut self) { + use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; + + unsafe { CloseHandle(self.handle as HANDLE) }; + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 0f8f55c..8e5acfe 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -9,6 +9,7 @@ mod codex_sessions; mod configuration_watcher; mod core_config; mod core_runtime; +mod instance_lock; mod management_api; mod oauth_browser; mod provider_health; @@ -36,6 +37,7 @@ use core_config::*; use core_runtime::*; use flate2::read::GzDecoder; use futures_util::StreamExt; +use instance_lock::*; #[cfg(target_os = "macos")] use objc2::MainThreadMarker; #[cfg(target_os = "macos")] @@ -290,11 +292,18 @@ struct AppUpdateInner { #[derive(Default)] struct CoreProcessState { child: Mutex>, + adopted_processes: Mutex>, starting: AtomicBool, #[cfg(windows)] job: Mutex>, } +#[derive(Clone)] +struct AdoptedCoreProcess { + process_id: u32, + binary_path: PathBuf, +} + struct GuiConfigState { inner: Mutex, } @@ -1441,6 +1450,7 @@ impl CoreProcessState { fn new(starting: bool) -> Self { Self { child: Mutex::new(None), + adopted_processes: Mutex::new(Vec::new()), starting: AtomicBool::new(starting), #[cfg(windows)] job: Mutex::new(None), @@ -1456,21 +1466,29 @@ impl CoreProcessState { } fn managed_pid(&self) -> Option { - let Ok(mut child) = self.child.lock() else { - return None; - }; + if let Ok(mut child) = self.child.lock() { + if let Some(process) = child.as_mut() { + if let Ok(None) = process.try_wait() { + return Some(process.id()); + } - let process = child.as_mut()?; - - if let Ok(None) = process.try_wait() { - return Some(process.id()); + *child = None; + drop(child); + self.clear_lifetime_guard(); + } } - *child = None; - drop(child); - self.clear_lifetime_guard(); - - None + self.adopted_processes + .lock() + .ok() + .and_then(|mut processes| { + processes.retain(|process| { + process_executable_path(process.process_id) + .as_deref() + .is_some_and(|path| executable_paths_match(&process.binary_path, path)) + }); + processes.first().map(|process| process.process_id) + }) } fn clear_lifetime_guard(&self) { @@ -1486,12 +1504,57 @@ impl CoreProcessState { self.child.lock().ok().and_then(|mut child| child.take()) } + fn adopt_process_ids(&self, binary_path: &Path, process_ids: Vec) -> Result<(), String> { + let mut adopted = self + .adopted_processes + .lock() + .map_err(|_| "接管的内核进程状态锁已损坏".to_string())?; + *adopted = process_ids + .into_iter() + .filter(|process_id| is_process_alive(*process_id)) + .map(|process_id| AdoptedCoreProcess { + process_id, + binary_path: binary_path.to_path_buf(), + }) + .collect(); + adopted.sort_unstable_by_key(|process| process.process_id); + adopted.dedup_by_key(|process| process.process_id); + Ok(()) + } + + fn clear_adopted_processes(&self) -> Result<(), String> { + self.adopted_processes + .lock() + .map(|mut processes| processes.clear()) + .map_err(|_| "接管的内核进程状态锁已损坏".to_string()) + } + + fn take_adopted_processes(&self) -> Vec { + self.adopted_processes + .lock() + .map(|mut processes| std::mem::take(&mut *processes)) + .unwrap_or_default() + } + fn store_child(&self, child: Child) -> Result { let pid = child.id(); + self.clear_adopted_processes()?; #[cfg(windows)] { - let job = attach_child_to_windows_job(&child)?; + let job = match attach_child_to_windows_job(&child) { + Ok(job) => job, + Err(error) => { + let mut child = child; + let cleanup_error = terminate_child(&mut child).err(); + return Err(match cleanup_error { + Some(cleanup_error) => { + format!("{error};清理未托管的内核进程也失败: {cleanup_error}") + } + None => error, + }); + } + }; let Ok(mut managed_child) = self.child.lock() else { close_windows_handle(job); return Err("内核进程状态锁已损坏".to_string()); @@ -1506,10 +1569,19 @@ impl CoreProcessState { #[cfg(not(windows))] { - let mut managed_child = self - .child - .lock() - .map_err(|_| "内核进程状态锁已损坏".to_string())?; + let mut managed_child = match self.child.lock() { + Ok(managed_child) => managed_child, + Err(_) => { + let mut child = child; + let cleanup_error = terminate_child(&mut child).err(); + return Err(match cleanup_error { + Some(cleanup_error) => format!( + "内核进程状态锁已损坏;清理未托管的内核进程也失败: {cleanup_error}" + ), + None => "内核进程状态锁已损坏".to_string(), + }); + } + }; *managed_child = Some(child); } @@ -1852,6 +1924,14 @@ fn main() { } } + let _instance_guard = match acquire_app_instance_guard() { + Ok(guard) => guard, + Err(error) => { + eprintln!("{error}"); + return; + } + }; + let portable_update_ack = portable_update_ack_argument(); let gui_config = match load_or_create_gui_config() { Ok(config) => config, @@ -2020,7 +2100,24 @@ fn main() { Err(error) => eprintln!("自动安装 CPA 离线内核失败: {error}"), } - if should_start_core_on_launch(&config) { + let adopted_process_ids = match adopt_existing_core_processes(process_state.inner()) + { + Ok(process_ids) => process_ids, + Err(error) => { + eprintln!("扫描并接管当前目录的 CPA 内核失败: {error}"); + Vec::new() + } + }; + if !adopted_process_ids.is_empty() { + eprintln!( + "已接管当前目录中运行的 CPA 内核: PID {}", + adopted_process_ids + .iter() + .map(u32::to_string) + .collect::>() + .join(", ") + ); + } else if should_start_core_on_launch(&config) { if let Err(error) = start_core_process_inner(process_state.inner(), &config) { eprintln!("自动启动 CPA 内核失败: {error}"); } diff --git a/src-tauri/src/tests.rs b/src-tauri/src/tests.rs index 1b3b146..9d8f1be 100644 --- a/src-tauri/src/tests.rs +++ b/src-tauri/src/tests.rs @@ -7,6 +7,7 @@ mod app_settings; mod app_update; mod core_config; mod core_runtime; +mod instance_lock; mod model_aliases; mod platform; mod provider_health; diff --git a/src-tauri/src/tests/core_runtime.rs b/src-tauri/src/tests/core_runtime.rs index 8ddd1b2..d00eb35 100644 --- a/src-tauri/src/tests/core_runtime.rs +++ b/src-tauri/src/tests/core_runtime.rs @@ -9,6 +9,122 @@ fn core_process_starting_state_tracks_automatic_launch() { assert!(!state.is_starting()); } +#[test] +fn executable_path_matching_keeps_core_instances_directory_scoped() { + let root = agent_test_home("core-process-path-scope"); + let first_dir = root.join("first").join("cpa-core"); + let second_dir = root.join("second").join("cpa-core"); + fs::create_dir_all(&first_dir).unwrap(); + fs::create_dir_all(&second_dir).unwrap(); + let first_binary = first_dir.join(core_binary_name()); + let second_binary = second_dir.join(core_binary_name()); + fs::write(&first_binary, b"first").unwrap(); + fs::write(&second_binary, b"second").unwrap(); + + assert!(executable_paths_match(&first_binary, &first_binary)); + assert!(!executable_paths_match(&first_binary, &second_binary)); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn core_process_discovery_sleep_helper() { + if env::var_os("EASYCLIPROXYAPI_PROCESS_DISCOVERY_TEST_HELPER").is_some() { + thread::sleep(Duration::from_secs(10)); + } +} + +#[test] +fn running_core_process_discovery_ignores_the_same_binary_name_in_another_directory() { + let root = agent_test_home("running-core-process-scope"); + let first_dir = root.join("first").join("cpa-core"); + let second_dir = root.join("second").join("cpa-core"); + fs::create_dir_all(&first_dir).unwrap(); + fs::create_dir_all(&second_dir).unwrap(); + let first_binary = first_dir.join(core_binary_name()); + let second_binary = second_dir.join(core_binary_name()); + + let source_binary = env::current_exe().unwrap(); + fs::copy(&source_binary, &first_binary).unwrap(); + fs::copy(&source_binary, &second_binary).unwrap(); + let arguments = [ + "--exact", + "tests::core_runtime::core_process_discovery_sleep_helper", + "--nocapture", + ]; + let mut first = Command::new(&first_binary) + .args(&arguments) + .env("EASYCLIPROXYAPI_PROCESS_DISCOVERY_TEST_HELPER", "1") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let mut second = Command::new(&second_binary) + .args(&arguments) + .env("EASYCLIPROXYAPI_PROCESS_DISCOVERY_TEST_HELPER", "1") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + thread::sleep(Duration::from_millis(200)); + + let first_running = first.try_wait().unwrap().is_none(); + let second_running = second.try_wait().unwrap().is_none(); + let candidate_process_ids = find_candidate_core_process_ids(); + let first_actual_path = process_executable_path(first.id()); + let second_actual_path = process_executable_path(second.id()); + let first_matches = find_core_process_ids(&first_binary); + let second_matches = find_core_process_ids(&second_binary); + + let _ = first.kill(); + let _ = second.kill(); + let _ = first.wait(); + let _ = second.wait(); + assert_eq!( + first_matches, + vec![first.id()], + "running={first_running}, candidate={}, actual={first_actual_path:?}, expected={first_binary:?}", + candidate_process_ids.contains(&first.id()) + ); + assert_eq!( + second_matches, + vec![second.id()], + "running={second_running}, candidate={}, actual={second_actual_path:?}, expected={second_binary:?}", + candidate_process_ids.contains(&second.id()) + ); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn current_process_executable_path_can_be_resolved() { + let expected = env::current_exe().unwrap(); + let actual = process_executable_path(std::process::id()).unwrap(); + assert!(executable_paths_match(&expected, &actual)); +} + +#[test] +fn core_process_state_tracks_and_releases_adopted_processes() { + let state = CoreProcessState::new(false); + let binary_path = env::current_exe().unwrap(); + state + .adopt_process_ids(&binary_path, vec![std::process::id(), std::process::id()]) + .unwrap(); + + assert_eq!(state.managed_pid(), Some(std::process::id())); + assert_eq!( + state + .take_adopted_processes() + .into_iter() + .map(|process| process.process_id) + .collect::>(), + vec![std::process::id()] + ); + assert_eq!(state.managed_pid(), None); +} + #[test] fn successful_core_install_remains_successful_when_restart_succeeds() { let result = combine_install_and_restart_results(Ok("installed"), Ok(())); diff --git a/src-tauri/src/tests/instance_lock.rs b/src-tauri/src/tests/instance_lock.rs new file mode 100644 index 0000000..07269b0 --- /dev/null +++ b/src-tauri/src/tests/instance_lock.rs @@ -0,0 +1,27 @@ +use super::support::*; +use super::*; + +#[test] +fn app_instance_keys_are_stable_and_directory_scoped() { + let first = agent_test_home("instance-key-first"); + let second = agent_test_home("instance-key-second"); + + assert_eq!(app_instance_key(&first), app_instance_key(&first)); + assert_ne!(app_instance_key(&first), app_instance_key(&second)); + + fs::remove_dir_all(first).unwrap(); + fs::remove_dir_all(second).unwrap(); +} + +#[test] +fn app_instance_guard_rejects_a_second_copy_and_releases_on_drop() { + let root = agent_test_home("instance-lock"); + let first = acquire_app_instance_guard_for(&root).unwrap(); + + let duplicate = acquire_app_instance_guard_for(&root); + assert!(duplicate.is_err()); + + drop(first); + assert!(acquire_app_instance_guard_for(&root).is_ok()); + fs::remove_dir_all(root).unwrap(); +}