diff --git a/src-tauri/src/core_config/settings.rs b/src-tauri/src/core_config/settings.rs index 3901a99d..61014d4f 100644 --- a/src-tauri/src/core_config/settings.rs +++ b/src-tauri/src/core_config/settings.rs @@ -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(); diff --git a/src-tauri/src/core_runtime.rs b/src-tauri/src/core_runtime.rs index 269b16d7..766c8c9a 100644 --- a/src-tauri/src/core_runtime.rs +++ b/src-tauri/src/core_runtime.rs @@ -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( @@ -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)))? { @@ -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!( "内核暂存目录包含不支持的条目: {}", @@ -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}"))?; diff --git a/src-tauri/src/management_api.rs b/src-tauri/src/management_api.rs index 6e48ebf7..98a35a68 100644 --- a/src-tauri/src/management_api.rs +++ b/src-tauri/src/management_api.rs @@ -1,8 +1,8 @@ #[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}; @@ -10,7 +10,7 @@ use std::{ collections::HashMap, error::Error, fs, - path::{Path, PathBuf}, + path::Path, process::{Command, Stdio}, sync::LazyLock, time::Duration, @@ -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()); diff --git a/src-tauri/src/tests/core_runtime.rs b/src-tauri/src/tests/core_runtime.rs index b4b43c3c..b24a0805 100644 --- a/src-tauri/src/tests/core_runtime.rs +++ b/src-tauri/src/tests/core_runtime.rs @@ -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(); +}