From 8d4a7f003be5e26f18a2eaa7e1c81a8f5e955cdf Mon Sep 17 00:00:00 2001 From: jadenjoe Date: Tue, 8 Sep 2026 01:30:09 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20macOS=20=E4=B8=8A?= =?UTF-8?q?=E5=86=85=E6=A0=B8=E6=9B=B4=E6=96=B0=E5=90=8E=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E5=8D=B3=E8=A2=AB=20SIGKILL=20=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题现象:macOS 上 CPA 内核启动后立即退出,错误信息为 "CPA 内核启动后立即退出: signal: 9 (SIGKILL)"(见 #224)。 根因:macOS 内核会按文件(vnode/inode)缓存代码签名校验结果。 内核更新流程 overlay_directory 对已存在的二进制直接 fs::copy 原地覆写,导致同一 inode 的缓存签名失效;此后对该文件的每一次 exec 都会在 main() 之前被内核以 SIGKILL 杀死。字节完全相同的 副本(新 inode)可以正常执行,只有被原地覆写过的那个文件被杀, 且在本次开机会话内无法自愈。 修复: - overlay_directory 改为 copy_file_replace:先写入同目录临时 文件再原子 rename 替换目标文件,更新后的内核始终是全新 inode, 不再触发签名失效; - start_core_process_inner 检测到子进程被 SIGKILL 立即退出时, 自动用同样的"临时副本 + rename"方式重物化内核二进制并重试一次, 已中毒的旧安装无需重装即可自愈;仍失败时提示重新安装内核; - 内核 stderr 改为写入 logs/core-start-output.log(此前为 Stdio::null 丢弃),"exit code: 0" 这类静默退出可以据此排查; - 新增回归测试,用 inode 断言确保更新不会原地覆写可执行文件。 验证:cargo check 通过;tests::core_runtime 26/26 通过; 新增 3 个测试通过;在 macOS 26 实机复现并验证修复后内核可正常 启动并监听管理端口。 --- src-tauri/src/core_runtime.rs | 185 +++++++++++++++++++++++++--- src-tauri/src/tests/core_runtime.rs | 97 +++++++++++++++ 2 files changed, 263 insertions(+), 19 deletions(-) diff --git a/src-tauri/src/core_runtime.rs b/src-tauri/src/core_runtime.rs index 269b16d7..b2a06635 100644 --- a/src-tauri/src/core_runtime.rs +++ b/src-tauri/src/core_runtime.rs @@ -1231,25 +1231,122 @@ 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)); + 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)?; +fn core_start_stdio(install_dir: &Path) -> 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_dir = install_dir + .parent() + .map(|parent| parent.join("logs")) + .unwrap_or_else(|| install_dir.to_path_buf()); + let _ = fs::create_dir_all(&log_dir); + let log_path = log_dir.join("core-start-output.log"); + 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()) +} - Ok(()) +#[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 +2260,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 +2292,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 +2304,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/tests/core_runtime.rs b/src-tauri/src/tests/core_runtime.rs index b4b43c3c..d886e42a 100644 --- a/src-tauri/src/tests/core_runtime.rs +++ b/src-tauri/src/tests/core_runtime.rs @@ -537,3 +537,100 @@ 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(); +} From fa11bb206b3973173db35f7742e69fd9afe4a028 Mon Sep 17 00:00:00 2001 From: jadenjoe Date: Tue, 8 Sep 2026 08:34:40 +0800 Subject: [PATCH 2/2] fix(core): write startup output to managed logs directory --- src-tauri/src/core_config/settings.rs | 4 ++++ src-tauri/src/core_runtime.rs | 18 ++++++++------- src-tauri/src/management_api.rs | 10 +++----- src-tauri/src/tests/core_runtime.rs | 33 +++++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 15 deletions(-) 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 b2a06635..766c8c9a 100644 --- a/src-tauri/src/core_runtime.rs +++ b/src-tauri/src/core_runtime.rs @@ -1238,7 +1238,7 @@ pub(crate) fn start_core_process_inner( .current_dir(&install_dir) .stdin(Stdio::null()) .stdout(Stdio::null()) - .stderr(core_start_stdio(&install_dir)); + .stderr(core_start_stdio(&install_dir, &gui_config.auth_dir)); configure_background_command(&mut command); command }; @@ -1287,16 +1287,18 @@ pub(crate) fn start_core_process_inner( } } -fn core_start_stdio(install_dir: &Path) -> Stdio { +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") +} + +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_dir = install_dir - .parent() - .map(|parent| parent.join("logs")) - .unwrap_or_else(|| install_dir.to_path_buf()); - let _ = fs::create_dir_all(&log_dir); - let log_path = log_dir.join("core-start-output.log"); + 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) 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 d886e42a..b24a0805 100644 --- a/src-tauri/src/tests/core_runtime.rs +++ b/src-tauri/src/tests/core_runtime.rs @@ -634,3 +634,36 @@ fn overlay_directory_replaces_existing_binary_with_fresh_inode() { 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(); +}