fix: isolate core processes per installation directory

- Match core processes by their actual executable paths
- Prevent different EasyCLIProxyAPI installations from interfering
- Adopt orphaned core processes from the current cpa-core directory
- Rescan and terminate owned core processes during shutdown
- Enforce a single app instance per installation directory
- Clean up core processes that fail to enter the Windows job object
- Add multi-directory process isolation and instance lock tests
This commit is contained in:
lzt404 committed 2026-08-26 01:56:46 +08:00
1 parent c4a66fd99c
commit 647806386b
7 files changed
+566 -94

No files matched your search

+1
View File
@@ -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",
] }
+206 -76
View File
@@ -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::<Vec<_>>();
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<PathBuf, String> {
@@ -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<u32> {
#[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<u32> {
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::<u32>().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<u32> {
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<u32> {
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::<Vec<_>>();
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::<u32>().ok())
.flatten()
})
.collect()
let mut entry = unsafe { mem::zeroed::<PROCESSENTRY32W>() };
entry.dwSize = mem::size_of::<PROCESSENTRY32W>() 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<u32> {
pub(crate) fn find_candidate_core_process_ids() -> Vec<u32> {
Command::new("pgrep")
.args(["-x", core_binary_name()])
.output()
@@ -1627,17 +1658,99 @@ pub(crate) fn find_core_process_ids_by_name() -> Vec<u32> {
.unwrap_or_default()
}
#[cfg(target_os = "linux")]
pub(crate) fn find_candidate_core_process_ids() -> Vec<u32> {
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::<u32>().ok())
.collect()
})
.unwrap_or_default()
}
#[cfg(target_os = "linux")]
pub(crate) fn process_executable_path(process_id: u32) -> Option<PathBuf> {
fs::read_link(format!("/proc/{process_id}/exe")).ok()
}
#[cfg(target_os = "macos")]
pub(crate) fn process_executable_path(process_id: u32) -> Option<PathBuf> {
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<PathBuf> {
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<Vec<u32>, 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();
+100
View File
@@ -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<AppInstanceGuard, String> {
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<AppInstanceGuard, String> {
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::<Vec<_>>();
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<AppInstanceGuard, String> {
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) };
}
}
+115 -18
View File
@@ -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<Option<Child>>,
adopted_processes: Mutex<Vec<AdoptedCoreProcess>>,
starting: AtomicBool,
#[cfg(windows)]
job: Mutex<Option<isize>>,
}
#[derive(Clone)]
struct AdoptedCoreProcess {
process_id: u32,
binary_path: PathBuf,
}
struct GuiConfigState {
inner: Mutex<GuiConfigFile>,
}
@@ -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<u32> {
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<u32>) -> 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<AdoptedCoreProcess> {
self.adopted_processes
.lock()
.map(|mut processes| std::mem::take(&mut *processes))
.unwrap_or_default()
}
fn store_child(&self, child: Child) -> Result<u32, String> {
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::<Vec<_>>()
.join(", ")
);
} else if should_start_core_on_launch(&config) {
if let Err(error) = start_core_process_inner(process_state.inner(), &config) {
eprintln!("自动启动 CPA 内核失败: {error}");
}
+1
View File
@@ -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;
+116
View File
@@ -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<_>>(),
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(()));
+27
View File
@@ -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();
}