Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src-tauri/src/core_config/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -867,6 +867,10 @@ pub(crate) fn auth_dir_path_for_core(auth_dir: &str, install_dir: &Path) -> Path
}
}

pub(crate) fn core_logs_dir_path(auth_dir: &str, install_dir: &Path) -> PathBuf {
auth_dir_path_for_core(auth_dir, install_dir).join("logs")
}

#[cfg(any(target_os = "macos", test))]
fn normalize_path_lexically(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
Expand Down
187 changes: 168 additions & 19 deletions src-tauri/src/core_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1231,25 +1231,124 @@ pub(crate) fn start_core_process_inner(

let config_path = merge_core_config_for_start(&install_dir, gui_config)?;
let config_path = path_to_string(&config_path);
let mut command = Command::new(&binary_path);
command
.args(["-config", &config_path])
.current_dir(&install_dir)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
configure_background_command(&mut command);

let mut child = spawn_core_child(command)?;

if let Err(error) = wait_for_core_management_port(&mut child, management_address) {
let build_command = || {
let mut command = Command::new(&binary_path);
command
.args(["-config", &config_path])
.current_dir(&install_dir)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(core_start_stdio(&install_dir, &gui_config.auth_dir));
configure_background_command(&mut command);
command
};

let mut retried_after_sigkill = false;
loop {
let mut child = spawn_core_child(build_command())?;

let start_error = match wait_for_core_management_port(&mut child, management_address) {
Ok(()) => {
process_state.store_child(child)?;
return Ok(());
}
Err(error) => error,
};

let killed_by_signal = child
.try_wait()
.ok()
.flatten()
.is_some_and(|status| exited_through_kill_signal(&status));

let _ = terminate_child(&mut child);
return Err(error);

#[cfg(target_os = "macos")]
if killed_by_signal
&& !retried_after_sigkill
&& heal_tainted_core_binary(&binary_path)
{
retried_after_sigkill = true;
continue;
}
#[cfg(not(target_os = "macos"))]
{
let _ = (&killed_by_signal, &retried_after_sigkill);
}

#[cfg(target_os = "macos")]
if killed_by_signal {
return Err(format!(
"{start_error};系统多次终止 CPA 内核进程,请在内核管理中重新安装内核后重试"
));
}

return Err(start_error);
}
}

process_state.store_child(child)?;
pub(crate) fn core_start_log_path(install_dir: &Path, auth_dir: &str) -> PathBuf {
core_logs_dir_path(auth_dir, install_dir).join("core-start-output.log")
}

Ok(())
pub(crate) fn core_start_stdio(install_dir: &Path, auth_dir: &str) -> Stdio {
// Keep the core's early output on disk: when the core dies right after
// spawn (for example exit code 0 or a kernel SIGKILL), this file is the
// only trace of what it printed before exiting.
let log_path = core_start_log_path(install_dir, auth_dir);
if let Some(parent) = log_path.parent() {
let _ = fs::create_dir_all(parent);
}
File::options()
.append(true)
.create(true)
.open(&log_path)
.map(|file| {
let mut file = file;
let _ = writeln!(file, "===== CPA 内核启动 {} =====", unix_now());
Stdio::from(file)
})
.unwrap_or(Stdio::null())
}

#[cfg(unix)]
fn exited_through_kill_signal(status: &std::process::ExitStatus) -> bool {
use std::os::unix::process::ExitStatusExt;

status.code().is_none() && status.signal() == Some(libc::SIGKILL)
}

#[cfg(not(unix))]
fn exited_through_kill_signal(_status: &std::process::ExitStatus) -> bool {
false
}

#[cfg(target_os = "macos")]
fn heal_tainted_core_binary(binary_path: &Path) -> bool {
// macOS caches the code-signature validation result per file (vnode).
// When the binary file has been overwritten in place by an earlier update,
// the kernel treats every new exec of that same file as an invalid
// signature and kills the process with SIGKILL before main() runs.
// Re-materializing the file through a sibling temporary file + rename
// gives exec a fresh inode and clears the poisoned cache for this path.
let (Some(parent), Some(file_name)) = (binary_path.parent(), binary_path.file_name()) else {
return false;
};
let temp_path = parent.join(format!(
".{}.heal-{}-{}",
file_name.to_string_lossy(),
std::process::id(),
unix_now()
));
if fs::copy(binary_path, &temp_path).is_err() {
return false;
}
if fs::rename(&temp_path, binary_path).is_ok() {
true
} else {
let _ = fs::remove_file(&temp_path);
false
}
}

pub(crate) fn wait_for_core_management_port(
Expand Down Expand Up @@ -2163,7 +2262,7 @@ pub(crate) fn overlay_install_dir(install_dir: &Path, staging_dir: &Path) -> Res
fs::remove_dir_all(staging_dir).map_err(|err| format!("清理内核暂存目录失败: {err}"))
}

fn overlay_directory(source_dir: &Path, target_dir: &Path) -> Result<(), String> {
pub(crate) fn overlay_directory(source_dir: &Path, target_dir: &Path) -> Result<(), String> {
for entry in fs::read_dir(source_dir)
.map_err(|err| format!("读取内核暂存目录失败 {}: {err}", path_to_string(source_dir)))?
{
Expand Down Expand Up @@ -2195,9 +2294,7 @@ fn overlay_directory(source_dir: &Path, target_dir: &Path) -> Result<(), String>
path_to_string(&target_path)
));
}
fs::copy(&source_path, &target_path).map_err(|err| {
format!("覆盖内核文件失败 {}: {err}", path_to_string(&target_path))
})?;
copy_file_replace(&source_path, &target_path)?;
} else {
return Err(format!(
"内核暂存目录包含不支持的条目: {}",
Expand All @@ -2209,6 +2306,58 @@ fn overlay_directory(source_dir: &Path, target_dir: &Path) -> Result<(), String>
Ok(())
}

pub(crate) fn copy_file_replace(source_path: &Path, target_path: &Path) -> Result<(), String> {
// Replace the target through a sibling temporary file + rename instead of
// copying over an existing file in place. On macOS the kernel caches
// code-signature validation per file (vnode): overwriting a previously
// executed binary in place invalidates the cached signature and every
// later exec of that same file is killed with SIGKILL immediately
// ("CPA 内核启动后立即退出: signal: 9 (SIGKILL)"). Renaming in a fresh
// inode keeps the installed binary exec-safe after every update.
if !target_path.exists() {
return fs::copy(source_path, target_path)
.map(|_| ())
.map_err(|err| format!("覆盖内核文件失败 {}: {err}", path_to_string(target_path)));
}

let Some(parent) = target_path.parent() else {
return Err(format!(
"覆盖内核文件失败 {}: 无法确定父目录",
path_to_string(target_path)
));
};
let Some(file_name) = target_path.file_name() else {
return Err(format!(
"覆盖内核文件失败 {}: 无法确定文件名",
path_to_string(target_path)
));
};
let temp_path = parent.join(format!(
".{}.overlay-{}-{}",
file_name.to_string_lossy(),
std::process::id(),
unix_now()
));

if let Err(err) = fs::copy(source_path, &temp_path) {
let _ = fs::remove_file(&temp_path);
return Err(format!(
"覆盖内核文件失败 {}: {err}",
path_to_string(target_path)
));
}

if let Err(err) = fs::rename(&temp_path, target_path) {
let _ = fs::remove_file(&temp_path);
return Err(format!(
"覆盖内核文件失败 {}: {err}",
path_to_string(target_path)
));
}

Ok(())
}

pub(crate) fn extract_tar_gz(archive_path: &Path, install_dir: &Path) -> Result<(), String> {
let archive_file =
File::open(archive_path).map_err(|err| format!("打开 tar.gz 失败: {err}"))?;
Expand Down
10 changes: 3 additions & 7 deletions src-tauri/src/management_api.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
#[cfg(target_os = "windows")]
use super::windows_explorer_executable;
use super::{
auth_dir_path_for_core, configure_background_command, core_install_dir, core_origin,
current_core_tls_settings, is_hashed_management_secret_key, open_oauth_url_inner,
auth_dir_path_for_core, configure_background_command, core_install_dir, core_logs_dir_path,
core_origin, current_core_tls_settings, is_hashed_management_secret_key, open_oauth_url_inner,
path_to_string, truncate_for_error, GuiConfigFile, GuiConfigState,
};
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
error::Error,
fs,
path::{Path, PathBuf},
path::Path,
process::{Command, Stdio},
sync::LazyLock,
time::Duration,
Expand Down Expand Up @@ -152,10 +152,6 @@ pub(crate) fn open_core_logs_directory(
open_directory_in_file_manager(&logs_dir)
}

fn core_logs_dir_path(auth_dir: &str, install_dir: &Path) -> PathBuf {
auth_dir_path_for_core(auth_dir, install_dir).join("logs")
}

fn open_directory_in_file_manager(path: &Path) -> Result<(), String> {
#[cfg(target_os = "windows")]
let mut command = Command::new(windows_explorer_executable());
Expand Down
130 changes: 130 additions & 0 deletions src-tauri/src/tests/core_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -537,3 +537,133 @@ fn release_page_assets_parse_download_links_and_sha256() {
.browser_download_url
.ends_with("/releases/download/v1.2.3/CLIProxyAPI_1.2.3_linux_amd64.tar.gz"));
}

#[test]
fn copy_file_replace_replaces_existing_target_with_fresh_inode() {
#[cfg(unix)]
use std::os::unix::fs::MetadataExt;

let root = agent_test_home("copy-file-replace-fresh-inode");
let staging_dir = root.join("staging");
let install_dir = root.join("install");
fs::create_dir_all(&staging_dir).unwrap();
fs::create_dir_all(&install_dir).unwrap();

let source = staging_dir.join(core_binary_name());
let target = install_dir.join(core_binary_name());
fs::write(&source, b"new core bytes").unwrap();
fs::write(&target, b"old core bytes").unwrap();
#[cfg(unix)]
let old_inode = fs::metadata(&target).unwrap().ino();

copy_file_replace(&source, &target).unwrap();

assert_eq!(fs::read(&target).unwrap(), b"new core bytes");
#[cfg(unix)]
assert_ne!(
fs::metadata(&target).unwrap().ino(),
old_inode,
"replaced binary must get a fresh inode so macOS does not kill exec with SIGKILL"
);

let leftovers: Vec<_> = fs::read_dir(&install_dir)
.unwrap()
.filter_map(|entry| entry.ok())
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.filter(|name| name.starts_with('.'))
.collect();
assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}");

fs::remove_dir_all(root).unwrap();
}

#[test]
fn copy_file_replace_creates_missing_target() {
let root = agent_test_home("copy-file-replace-missing-target");
let source_dir = root.join("source");
let target_dir = root.join("target");
fs::create_dir_all(&source_dir).unwrap();
fs::create_dir_all(&target_dir).unwrap();

let source = source_dir.join(core_binary_name());
let target = target_dir.join(core_binary_name());
fs::write(&source, b"core bytes").unwrap();

copy_file_replace(&source, &target).unwrap();

assert_eq!(fs::read(&target).unwrap(), b"core bytes");

fs::remove_dir_all(root).unwrap();
}

#[test]
fn overlay_directory_replaces_existing_binary_with_fresh_inode() {
#[cfg(unix)]
use std::os::unix::fs::MetadataExt;

let root = agent_test_home("overlay-directory-fresh-inode");
let staging_dir = root.join("cpa-core.staging");
let install_dir = root.join("cpa-core");
fs::create_dir_all(&staging_dir).unwrap();
fs::create_dir_all(&install_dir).unwrap();

let binary_name = core_binary_name();
let staged_binary = staging_dir.join(&binary_name);
let installed_binary = install_dir.join(&binary_name);
fs::write(&staged_binary, b"updated core").unwrap();
fs::write(&installed_binary, b"outdated core").unwrap();
fs::write(staging_dir.join("config.example.yaml"), b"config").unwrap();
#[cfg(unix)]
let old_inode = fs::metadata(&installed_binary).unwrap().ino();

overlay_directory(&staging_dir, &install_dir).unwrap();

assert_eq!(fs::read(&installed_binary).unwrap(), b"updated core");
assert_eq!(
fs::read(install_dir.join("config.example.yaml")).unwrap(),
b"config"
);
#[cfg(unix)]
assert_ne!(
fs::metadata(&installed_binary).unwrap().ino(),
old_inode,
"overlay must not overwrite the binary in place; macOS kills exec of a modified executable with SIGKILL"
);
// Staging cleanup happens in overlay_install_dir, not overlay_directory.
assert!(staging_dir.exists());

fs::remove_dir_all(root).unwrap();
}

#[test]
fn core_start_log_path_follows_managed_logs_directory() {
let base_dir = PathBuf::from("test-base");
let install_dir = base_dir.join("cpa-core");

assert_eq!(
core_start_log_path(&install_dir, "../oauth"),
base_dir.join("oauth").join("logs").join("core-start-output.log")
);

assert_eq!(
core_start_log_path(&install_dir, "custom-auth"),
install_dir.join("custom-auth").join("logs").join("core-start-output.log")
);
}

#[test]
fn core_start_stdio_creates_log_file_in_managed_logs_directory() {
let root = agent_test_home("core-start-stdio-log-dir");
let install_dir = root.join("cpa-core");
fs::create_dir_all(&install_dir).unwrap();

let stdio = core_start_stdio(&install_dir, "../oauth");
drop(stdio);

let expected_log_path = root.join("oauth").join("logs").join("core-start-output.log");
assert!(expected_log_path.exists(), "log file must exist at {expected_log_path:?}");
let content = fs::read_to_string(&expected_log_path).unwrap();
assert!(content.contains("===== CPA 内核启动"));

fs::remove_dir_all(root).unwrap();
}