From d6293abf988d2d8d1a884039840a3bc832e59d50 Mon Sep 17 00:00:00 2001 From: hcw <1416522360@qq.com> Date: Wed, 28 Oct 2026 19:43:03 +0800 Subject: [PATCH 1/8] =?UTF-8?q?=E6=A0=B8=E9=AA=8C=E5=B9=B6=E6=8C=81?= =?UTF-8?q?=E6=9C=89=E6=A1=8C=E9=9D=A2=E8=BF=9B=E7=A8=8B=E6=A0=91=E8=BA=AB?= =?UTF-8?q?=E4=BB=BD=E4=BB=A5=E5=AE=89=E5=85=A8=E6=B8=85=E7=90=86=E6=AE=8B?= =?UTF-8?q?=E7=95=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/codex-plus-core/src/lib.rs | 5 + crates/codex-plus-core/src/multi_worker.rs | 459 +++++++++++++- .../src/multi_worker/windows_process_tree.rs | 508 +++++++++++++++ .../windows_process_tree_tests.rs | 580 ++++++++++++++++++ .../src/windows_integration.rs | 322 +++++++--- 5 files changed, 1769 insertions(+), 105 deletions(-) create mode 100644 crates/codex-plus-core/src/multi_worker/windows_process_tree.rs create mode 100644 crates/codex-plus-core/src/multi_worker/windows_process_tree_tests.rs diff --git a/crates/codex-plus-core/src/lib.rs b/crates/codex-plus-core/src/lib.rs index ea0d3414c5..c271480b58 100644 --- a/crates/codex-plus-core/src/lib.rs +++ b/crates/codex-plus-core/src/lib.rs @@ -103,6 +103,11 @@ pub(crate) mod xai_responses; // 门控在模块级会导致 `if cfg!(windows)` 这类运行时分支在非 Windows 平台找不到符号。 mod windows_integration; #[cfg(windows)] +pub use windows_integration::{ + WindowsProcessInfo, process_arguments as windows_process_arguments, + process_birth_id as windows_process_birth_id, +}; +#[cfg(windows)] mod windows_taskbar; #[cfg(windows)] diff --git a/crates/codex-plus-core/src/multi_worker.rs b/crates/codex-plus-core/src/multi_worker.rs index 656a92df32..c7d9293704 100644 --- a/crates/codex-plus-core/src/multi_worker.rs +++ b/crates/codex-plus-core/src/multi_worker.rs @@ -4,7 +4,48 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; -/// 端口只是连接地址;停止实例必须同时持有这一代的进程和 Browser 身份。 +#[cfg(any(windows, test))] +mod windows_process_tree; +#[cfg(windows)] +pub use windows_process_tree::CodexInstanceProcessTree; + +// 非 Windows 兼容接口仅确认原根退出,不提供 Windows 的自有后代清理能力。 +#[cfg(not(windows))] +pub struct CodexInstanceProcessTree { + identity: CodexInstanceIdentity, +} + +#[cfg(not(windows))] +impl CodexInstanceProcessTree { + pub fn capture(identity: &CodexInstanceIdentity) -> anyhow::Result { + anyhow::ensure!(valid_stop_identity(identity), "多开实例身份字段不完整"); + Ok(Self { + identity: identity.clone(), + }) + } + + pub fn refresh(&mut self) -> anyhow::Result<()> { + Ok(()) + } + + pub fn save_handoff_checkpoint(&mut self, _path: &std::path::Path) -> anyhow::Result<()> { + Ok(()) + } + + pub fn restore_handoff_checkpoint(&mut self, _path: &std::path::Path) -> anyhow::Result<()> { + Ok(()) + } + + pub fn cleanup_after_exit(self) -> anyhow::Result<()> { + anyhow::ensure!( + codex_instance_process_exited(&self.identity), + "原代 Codex 尚未确认退出" + ); + Ok(()) + } +} + +/// 端口只是连接地址;停止实例必须核验保存的进程身份,不能依赖 CDP 健康。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CodexInstanceIdentity { @@ -12,6 +53,7 @@ pub struct CodexInstanceIdentity { pub generation: u64, pub debug_port: u16, pub helper_port: u16, + /// 仅 instance_id == "main" 可为空,表示已验证 argv 没有 --user-data-dir。 pub user_data_dir: PathBuf, pub browser_id: String, pub process_id: u32, @@ -19,11 +61,219 @@ pub struct CodexInstanceIdentity { pub executable: PathBuf, } +fn valid_stop_identity(identity: &CodexInstanceIdentity) -> bool { + !identity.instance_id.trim().is_empty() + && identity.generation != 0 + && !identity.browser_id.is_empty() + && identity.process_id != 0 + && identity.process_id != std::process::id() + && identity.process_birth_id != 0 + && identity.debug_port != 0 + && identity.helper_port != 0 + && (identity.user_data_dir.is_absolute() + || (identity.instance_id == "main" && identity.user_data_dir.as_os_str().is_empty())) + && identity.executable.is_absolute() +} + +/// 主实例可沿用 Electron 默认目录;None 保存“没有目录参数”,不推测默认路径。 +pub async fn capture_main_codex_instance_identity( + generation: u64, + debug_port: u16, + helper_port: u16, + user_data_dir: Option<&std::path::Path>, + executable: &std::path::Path, +) -> anyhow::Result { + capture_codex_instance_identity( + "main", + generation, + debug_port, + helper_port, + user_data_dir.unwrap_or_else(|| std::path::Path::new("")), + executable, + ) + .await +} + +#[cfg(any(windows, target_os = "macos"))] +fn canonical_identity_user_data(instance_id: &str, path: &Path) -> anyhow::Result { + if instance_id == "main" && path.as_os_str().is_empty() { + Ok(PathBuf::new()) + } else { + anyhow::ensure!(!path.as_os_str().is_empty(), "多开实例必须指定精确数据目录"); + Ok(path.canonicalize()?) + } +} + +#[cfg(any(windows, target_os = "macos", test))] +fn codex_identity_arguments_match(arguments: &[String], identity: &CodexInstanceIdentity) -> bool { + if identity.instance_id == "main" && identity.user_data_dir.as_os_str().is_empty() { + codex_scoped_arguments_match(arguments, None, identity.debug_port) + } else { + codex_arguments_match(arguments, &identity.user_data_dir, identity.debug_port) + } +} + +/// 只确认原代退出;权限、身份字段或查询不确定时返回 false,调用方须保留 worker。 +pub fn codex_instance_process_exited(identity: &CodexInstanceIdentity) -> bool { + if !valid_stop_identity(identity) { + return false; + } + #[cfg(windows)] + { + crate::windows_integration::process_generation_exited( + identity.process_id, + identity.process_birth_id, + ) + .unwrap_or(false) + } + #[cfg(target_os = "macos")] + { + match crate::watcher::inspect_process_instance(identity.process_id) { + crate::watcher::ProcessInstanceState::NotRunning => true, + crate::watcher::ProcessInstanceState::Running { + birth_id: Some(birth), + .. + } => macos_birth_hash(&birth) != identity.process_birth_id, + _ => false, + } + } + #[cfg(not(any(windows, target_os = "macos")))] + { + // 非 Windows 暂不根据一次 PID 查询推断创建代;保持保守行为。 + false + } +} + +/// 仅确认原根退出才返回 false;读取失败、映像/参数/CDP 矛盾均为 Err。 +pub async fn codex_instance_needs_worker(identity: &CodexInstanceIdentity) -> anyhow::Result { + anyhow::ensure!(valid_stop_identity(identity), "多开实例身份字段不完整"); + #[cfg(windows)] + { + if crate::windows_integration::process_generation_exited( + identity.process_id, + identity.process_birth_id, + )? { + return Ok(false); + } + let process = windows_process_tree::capture_root(identity, false)?; + // CDP 断连不结束 worker;可读的不同 Browser 则必须报身份冲突。 + check_stop_browser_identity(identity).await?; + return Ok(process.is_alive()?); + } + #[cfg(target_os = "macos")] + { + if codex_instance_process_exited(identity) { + return Ok(false); + } + anyhow::ensure!( + macos_birth_token(identity.process_id) == Some(identity.process_birth_id), + "无法核验原代多开进程创建身份" + ); + let args = macos_process_arguments(identity.process_id)?; + anyhow::ensure!( + codex_identity_arguments_match(&args, identity) + && args + .first() + .and_then(|path| Path::new(path).canonicalize().ok()) + .as_ref() + == Some(&identity.executable), + "多开进程映像或参数身份冲突" + ); + check_stop_browser_identity(identity).await?; + return Ok(!codex_instance_process_exited(identity)); + } + #[cfg(not(any(windows, target_os = "macos")))] + { + anyhow::bail!("当前系统尚不支持可靠确认多开进程退出") + } +} + +/// 同时检查两个 loopback 地址族,不能用页面列表不可读掩盖 Browser 冲突。 +#[cfg(any(windows, target_os = "macos", test))] +async fn check_stop_browser_identity(identity: &CodexInstanceIdentity) -> anyhow::Result<()> { + use anyhow::Context; + let client = reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .timeout(std::time::Duration::from_millis(750)) + .build()?; + for host in ["127.0.0.1", "[::1]"] { + let response = client + .get(format!( + "http://{host}:{}/json/version", + identity.debug_port + )) + .send() + .await; + let mut response = match response { + Ok(response) => response + .error_for_status() + .context("CDP Browser 响应无效")?, + // 仅传输失联允许退回保存身份;收到可读但无效的身份仍然拒绝。 + Err(error) if error.is_connect() || error.is_timeout() => continue, + Err(error) => return Err(error).context("无法核验 CDP Browser"), + }; + let mut bytes = Vec::new(); + let mut disconnected = false; + loop { + let chunk = match response.chunk().await { + Ok(Some(chunk)) => chunk, + Ok(None) => break, + Err(error) if error.is_timeout() || error.is_body() || error.is_connect() => { + // 已收到完整可读身份时仍执行否决,不能用后续断连掩盖冲突。 + if let Ok(browser) = + serde_json::from_slice::(&bytes) + { + verify_stop_browser(identity, &browser)?; + } + disconnected = true; + break; + } + Err(error) => return Err(error).context("CDP Browser 响应读取失败"), + }; + anyhow::ensure!( + bytes.len() + chunk.len() <= 64 * 1024, + "CDP Browser 响应过大" + ); + bytes.extend_from_slice(&chunk); + } + if disconnected { + continue; + } + let browser: crate::cdp::CdpBrowserIdentity = + serde_json::from_slice(&bytes).context("CDP Browser 身份格式无效")?; + verify_stop_browser(identity, &browser)?; + } + Ok(()) +} + +#[cfg(any(windows, target_os = "macos", test))] +fn verify_stop_browser( + identity: &CodexInstanceIdentity, + browser: &crate::cdp::CdpBrowserIdentity, +) -> anyhow::Result<()> { + crate::cdp::validate_cdp_websocket_url(&browser.web_socket_debugger_url, identity.debug_port)?; + anyhow::ensure!( + browser.browser_id()? == identity.browser_id, + "CDP Browser 身份冲突,拒绝停止或结束 worker" + ); + Ok(()) +} + #[cfg(any(windows, target_os = "macos", test))] pub(crate) fn codex_arguments_match( arguments: &[String], user_data_dir: &Path, debug_port: u16, +) -> bool { + codex_scoped_arguments_match(arguments, Some(user_data_dir), debug_port) +} + +#[cfg(any(windows, target_os = "macos", test))] +fn codex_scoped_arguments_match( + arguments: &[String], + user_data_dir: Option<&Path>, + debug_port: u16, ) -> bool { let mut user_paths = Vec::new(); let mut ports = Vec::new(); @@ -45,11 +295,13 @@ pub(crate) fn codex_arguments_match( ports.push(value.as_str()); } } - if ports.len() != 1 - || ports[0].parse::().ok() != Some(debug_port) - || debug_port == 0 - || user_paths.len() != 1 - { + if ports.len() != 1 || ports[0].parse::().ok() != Some(debug_port) || debug_port == 0 { + return false; + } + let Some(user_data_dir) = user_data_dir else { + return user_paths.is_empty(); + }; + if user_paths.len() != 1 { return false; } let Ok(expected) = user_data_dir.canonicalize() else { @@ -161,9 +413,14 @@ fn macos_birth_token(pid: u32) -> Option { else { return None; }; - Some(birth.bytes().fold(0xcbf29ce484222325_u64, |hash, byte| { + Some(macos_birth_hash(&birth)) +} + +#[cfg(target_os = "macos")] +fn macos_birth_hash(birth: &str) -> u64 { + birth.bytes().fold(0xcbf29ce484222325_u64, |hash, byte| { (hash ^ byte as u64).wrapping_mul(0x100000001b3) - })) + }) } #[cfg(target_os = "macos")] @@ -190,7 +447,7 @@ pub async fn capture_codex_instance_identity( generation, debug_port, helper_port, - user_data_dir: user_data_dir.canonicalize()?, + user_data_dir: canonical_identity_user_data(instance_id, user_data_dir)?, browser_id, process_id, process_birth_id, @@ -207,7 +464,8 @@ pub async fn capture_codex_instance_identity( pub async fn codex_instance_identity_matches( identity: &CodexInstanceIdentity, ) -> anyhow::Result { - if identity.generation == 0 + if !valid_stop_identity(identity) + || identity.generation == 0 || identity.instance_id.is_empty() || identity.browser_id.is_empty() || identity.debug_port == 0 @@ -217,7 +475,7 @@ pub async fn codex_instance_identity_matches( return Ok(false); } let args = macos_process_arguments(identity.process_id)?; - if !codex_arguments_match(&args, &identity.user_data_dir, identity.debug_port) + if !codex_identity_arguments_match(&args, identity) || args .first() .and_then(|path| Path::new(path).canonicalize().ok()) @@ -291,7 +549,7 @@ pub async fn capture_codex_instance_identity( !instance_id.trim().is_empty() && generation != 0 && debug_port != 0 && helper_port != 0, "多开实例身份字段不完整" ); - let user_data_dir = user_data_dir.canonicalize()?; + let user_data_dir = canonical_identity_user_data(instance_id, user_data_dir)?; let executable = executable .canonicalize() .unwrap_or_else(|_| executable.to_path_buf()); @@ -300,10 +558,6 @@ pub async fn capture_codex_instance_identity( .context("无法确认多开调试端口所属进程")?; let arguments = crate::windows_integration::process_arguments(process.process_id) .context("无法读取多开进程数据目录")?; - anyhow::ensure!( - codex_arguments_match(&arguments, &user_data_dir, debug_port), - "多开进程参数与实例身份不一致" - ); let identity = CodexInstanceIdentity { instance_id: instance_id.into(), generation, @@ -315,6 +569,10 @@ pub async fn capture_codex_instance_identity( process_birth_id: process.birth_id, executable, }; + anyhow::ensure!( + codex_identity_arguments_match(&arguments, &identity), + "多开进程参数与实例身份不一致" + ); anyhow::ensure!( process.is_alive()? && codex_instance_identity_matches(&identity).await?, "多开进程在确认身份期间已改变" @@ -326,7 +584,8 @@ pub async fn capture_codex_instance_identity( pub async fn codex_instance_identity_matches( identity: &CodexInstanceIdentity, ) -> anyhow::Result { - if identity.instance_id.is_empty() + if !valid_stop_identity(identity) + || identity.instance_id.is_empty() || identity.generation == 0 || identity.browser_id.is_empty() || identity.process_id == 0 @@ -349,7 +608,7 @@ pub async fn codex_instance_identity_matches( let Some(arguments) = crate::windows_integration::process_arguments(process.process_id) else { return Ok(false); }; - if !codex_arguments_match(&arguments, &identity.user_data_dir, identity.debug_port) { + if !codex_identity_arguments_match(&arguments, identity) { return Ok(false); } Ok( @@ -362,10 +621,14 @@ pub async fn codex_instance_identity_matches( pub async fn terminate_verified_codex_instance( identity: &CodexInstanceIdentity, ) -> anyhow::Result { - if !codex_instance_identity_matches(identity).await? { + if !valid_stop_identity(identity) { return Ok(false); } - crate::windows_integration::terminate_verified_codex_instance(identity) + // 捕获到退出确认全程持有句柄,CDP 只提供额外的否决证据。 + let mut tree = CodexInstanceProcessTree::capture(identity)?; + check_stop_browser_identity(identity).await?; + tree.stop_and_wait()?; + Ok(true) } #[cfg(any(windows, test))] @@ -392,6 +655,30 @@ pub(crate) fn worker_identity_matches( expected == actual } +#[cfg(any(windows, test))] +pub(crate) fn worker_image_matches(image: &Path, arguments: &[String]) -> bool { + let Some(argv_image) = arguments.first().map(Path::new) else { + return false; + }; + // 相对 argv[0] 不能借用新管理器的工作目录解释为旧 worker 映像。 + if !image.is_absolute() || !argv_image.is_absolute() { + return false; + } + let (Ok(actual), Ok(argument)) = (image.canonicalize(), argv_image.canonicalize()) else { + return false; + }; + #[cfg(windows)] + { + actual + .to_string_lossy() + .eq_ignore_ascii_case(&argument.to_string_lossy()) + } + #[cfg(not(windows))] + { + actual == argument + } +} + /// 直接从仍由父进程持有的 Child 句柄读取创建身份,避免按 PID 重新打开另一进程。 pub fn worker_birth_id(child: &std::process::Child) -> Option { #[cfg(windows)] @@ -414,6 +701,16 @@ pub fn terminate_verified_worker( crate::windows_integration::terminate_verified_multi_worker(pid, options_path, expected_birth) } +/// 只读确认同代 worker;仅原代已退出返回 false,身份冲突或查询不确定均为 Err。 +#[cfg(windows)] +pub fn verified_worker_is_running( + pid: u32, + options_path: &Path, + expected_birth: Option, +) -> anyhow::Result { + crate::windows_integration::verified_multi_worker_is_running(pid, options_path, expected_birth) +} + #[cfg(test)] mod tests { use super::*; @@ -455,6 +752,38 @@ mod tests { assert!(!codex_arguments_match(&args[..4], &own, 9341)); } + #[test] + fn default_main_requires_absent_user_data_argument_and_does_not_relax_multi_instance() { + let temp = tempfile::tempdir().unwrap(); + let mut identity = CodexInstanceIdentity { + instance_id: "main".into(), + generation: 1, + debug_port: 9341, + helper_port: 9342, + user_data_dir: PathBuf::new(), + browser_id: "main-browser".into(), + process_id: 42, + process_birth_id: 1, + executable: temp.path().join("ChatGPT.exe"), + }; + let args = vec!["ChatGPT.exe".into(), "--remote-debugging-port=9341".into()]; + assert!(valid_stop_identity(&identity)); + assert!(codex_identity_arguments_match(&args, &identity)); + assert!(!codex_arguments_match(&args, Path::new(""), 9341)); + let mut explicit = args.clone(); + explicit.push(format!("--user-data-dir={}", temp.path().display())); + assert!(!codex_identity_arguments_match(&explicit, &identity)); + let mut duplicate = args.clone(); + duplicate.push("--remote-debugging-port=9341".into()); + assert!(!codex_identity_arguments_match(&duplicate, &identity)); + identity.instance_id = "multi".into(); + assert!(!valid_stop_identity(&identity)); + assert!(!codex_identity_arguments_match(&args, &identity)); + identity.user_data_dir = temp.path().canonicalize().unwrap(); + assert!(codex_identity_arguments_match(&explicit, &identity)); + assert!(!codex_identity_arguments_match(&args, &identity)); + } + fn arguments(options: &Path) -> Vec { vec![ "CodexPlusPlus.exe".into(), @@ -524,4 +853,94 @@ mod tests { &missing )); } + + #[test] + fn worker_image_uses_its_own_argv_not_the_new_manager_location() { + let temp = tempfile::tempdir().unwrap(); + let old_dir = temp.path().join("old version"); + let new_dir = temp.path().join("new version"); + std::fs::create_dir(&old_dir).unwrap(); + std::fs::create_dir(&new_dir).unwrap(); + let old_worker = old_dir.join("manager.exe"); + let new_manager = new_dir.join("manager.exe"); + let neighbor = old_dir.join("neighbor.exe"); + let options = temp.path().join("worker-options.json"); + std::fs::write(&old_worker, "").unwrap(); + std::fs::write(&new_manager, "").unwrap(); + std::fs::write(&neighbor, "").unwrap(); + std::fs::write(&options, "{}").unwrap(); + let mut args = arguments(&options); + args[0] = old_worker.to_string_lossy().into_owned(); + assert!(worker_identity_matches(Some(41), Some(41), &args, &options)); + assert!(worker_image_matches(&old_worker, &args)); + assert!(!worker_image_matches(&new_manager, &args)); + assert!(!worker_image_matches(&neighbor, &args)); + assert!(!worker_image_matches( + &old_worker, + &[new_manager.to_string_lossy().into_owned()] + )); + assert!(!worker_image_matches( + &old_worker, + &["old-worker.exe".into()] + )); + assert!(!worker_image_matches(&old_worker, &[])); + } + + #[tokio::test] + async fn readable_browser_conflict_is_rejected_without_a_page_list() { + use wiremock::{Mock, MockServer, ResponseTemplate, matchers::path}; + let server = MockServer::start().await; + Mock::given(path("/json/version")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "Browser": "fixture", + "webSocketDebuggerUrl": format!("ws://127.0.0.1:{}/devtools/browser/neighbor", + server.address().port()) + }))) + .mount(&server) + .await; + let temp = tempfile::tempdir().unwrap(); + let identity = CodexInstanceIdentity { + instance_id: "own".into(), + generation: 1, + debug_port: server.address().port(), + helper_port: 9342, + user_data_dir: temp.path().into(), + browser_id: "own".into(), + process_id: 42, + process_birth_id: 1, + executable: temp.path().join("ChatGPT.exe"), + }; + assert!(check_stop_browser_identity(&identity).await.is_err()); + let mut matching = identity; + matching.browser_id = "neighbor".into(); + assert!(check_stop_browser_identity(&matching).await.is_ok()); + } + + #[tokio::test] + async fn browser_transport_loss_allows_saved_identity_but_invalid_response_does_not() { + use wiremock::{Mock, MockServer, ResponseTemplate, matchers::path}; + let server = MockServer::start().await; + let temp = tempfile::tempdir().unwrap(); + let mut identity = CodexInstanceIdentity { + instance_id: "own".into(), + generation: 1, + debug_port: server.address().port(), + helper_port: 9342, + user_data_dir: temp.path().into(), + browser_id: "own".into(), + process_id: 42, + process_birth_id: 1, + executable: temp.path().join("ChatGPT.exe"), + }; + Mock::given(path("/json/version")) + .respond_with(ResponseTemplate::new(200).set_body_string("invalid browser")) + .mount(&server) + .await; + assert!(check_stop_browser_identity(&identity).await.is_err()); + drop(server); + let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); + identity.debug_port = listener.local_addr().unwrap().port(); + drop(listener); + assert!(check_stop_browser_identity(&identity).await.is_ok()); + } } diff --git a/crates/codex-plus-core/src/multi_worker/windows_process_tree.rs b/crates/codex-plus-core/src/multi_worker/windows_process_tree.rs new file mode 100644 index 0000000000..14ced82847 --- /dev/null +++ b/crates/codex-plus-core/src/multi_worker/windows_process_tree.rs @@ -0,0 +1,508 @@ +use super::{CodexInstanceIdentity, codex_identity_arguments_match}; +use std::path::Path; + +#[cfg(windows)] +use crate::windows_integration::{VerifiedStopProcess, bounded_process_snapshot}; +#[cfg(windows)] +use std::time::{Duration, Instant}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +enum Role { + Desktop, + Electron, + AppServer, +} + +fn same_image(actual: &Path, expected: &Path) -> bool { + let normalize = |path: &Path| { + path.to_string_lossy() + .trim_start_matches(r"\\?\") + .replace('/', "\\") + .to_ascii_lowercase() + }; + normalize(actual) == normalize(expected) +} + +fn flag_values<'a>(args: &'a [String], flag: &str) -> Option> { + let mut values = Vec::new(); + let mut tokens = args.iter().skip(1); + while let Some(token) = tokens.next() { + if token == flag { + values.push(tokens.next()?.as_str()); + } else if let Some(value) = token.strip_prefix(flag).and_then(|s| s.strip_prefix('=')) { + values.push(value); + } + } + Some(values) +} + +fn child_role( + identity: &CodexInstanceIdentity, + parent_role: Role, + image: &Path, + args: &[String], +) -> Option { + // app-server 派生的终端、工具、用户代码不属于桌面的可清理进程树。 + if parent_role == Role::AppServer || args.is_empty() { + return None; + } + if same_image(image, &identity.executable) { + let types = flag_values(args, "--type")?; + if types.len() != 1 + || !matches!( + types[0], + "renderer" | "gpu-process" | "utility" | "zygote" | "sandbox-helper" + ) + { + return None; + } + // Chromium 子进程可不带这些参数;如出现,必须精确指向自己的实例。 + let user = flag_values(args, "--user-data-dir")?; + let port = flag_values(args, "--remote-debugging-port")?; + if !user.is_empty() || !port.is_empty() { + let mut check = args.to_vec(); + if user.is_empty() && !identity.user_data_dir.as_os_str().is_empty() { + check.push(format!( + "--user-data-dir={}", + identity.user_data_dir.display() + )); + } + if port.is_empty() { + check.push(format!("--remote-debugging-port={}", identity.debug_port)); + } + if !codex_identity_arguments_match(&check, identity) { + return None; + } + } + return Some(Role::Electron); + } + let trusted = identity + .executable + .parent()? + .join("resources") + .join("codex.exe"); + // 不接受 resources 的目录联接/符号链接跳转到安装目录之外。 + let canonical = trusted.canonicalize().ok()?; + if !same_image(&canonical, &trusted) || !same_image(image, &canonical) { + return None; + } + // 桌面内置 CLI 的角色必须是精确的第一个子命令。 + if args.get(1).map(String::as_str) == Some("app-server") { + Some(Role::AppServer) + } else { + None + } +} + +fn child_lifetime_matches( + expected_parent: u32, + actual_parent: u32, + parent_birth: u64, + parent_exit: u64, + child_birth: u64, +) -> bool { + expected_parent == actual_parent + && child_birth >= parent_birth + && (parent_exit == 0 || child_birth <= parent_exit) +} + +#[cfg(windows)] +pub(super) fn capture_root( + identity: &CodexInstanceIdentity, + terminate: bool, +) -> anyhow::Result { + anyhow::ensure!( + super::valid_stop_identity(identity), + "多开实例身份字段不完整" + ); + let process = VerifiedStopProcess::capture(identity.process_id, terminate)? + .ok_or_else(|| anyhow::anyhow!("原代多开进程已退出"))?; + anyhow::ensure!( + process.birth_id == identity.process_birth_id, + "多开进程创建身份冲突" + ); + anyhow::ensure!( + same_image(&process.image()?, &identity.executable), + "多开进程映像身份冲突" + ); + let args = process.arguments()?; + anyhow::ensure!( + codex_identity_arguments_match(&args, identity) + && flag_values(&args, "--type").is_some_and(|types| types.is_empty()), + "多开进程参数身份冲突" + ); + anyhow::ensure!(process.is_alive()?, "原代多开进程已退出"); + Ok(process) +} + +/// worker 在 ready 时捕获并持续 refresh;保留所有已收养代的句柄直到清理确认。 +/// 只收养桌面自身的 Electron 角色和安装目录内的 app-server,绝不遍历用户工具。 +#[cfg(windows)] +pub struct CodexInstanceProcessTree { + identity: CodexInstanceIdentity, + processes: Vec<(VerifiedStopProcess, Role)>, + lineage: Vec, +} + +#[cfg(windows)] +#[derive(Clone, serde::Serialize, serde::Deserialize)] +struct TreeMember { + pid: u32, + birth: u64, + parent: u32, + exit: u64, + role: Role, +} + +#[cfg(windows)] +#[derive(serde::Serialize, serde::Deserialize)] +struct TreeCheckpoint { + identity: CodexInstanceIdentity, + members: Vec, +} + +#[cfg(windows)] +impl CodexInstanceProcessTree { + const MAX_TRACKED: usize = 512; + const MAX_SNAPSHOT: usize = 32768; + const MAX_REFRESH_TIME: Duration = Duration::from_secs(2); + const STOP_TIMEOUT: Duration = Duration::from_secs(8); + const STOP_PASSES: usize = 8; + + pub fn capture(identity: &CodexInstanceIdentity) -> anyhow::Result { + let root = capture_root(identity, true)?; + let mut tree = Self { + identity: identity.clone(), + processes: vec![(root, Role::Desktop)], + lineage: Vec::new(), + }; + tree.refresh()?; + Ok(tree) + } + + pub fn refresh(&mut self) -> anyhow::Result<()> { + self.refresh_until(Instant::now() + Self::MAX_REFRESH_TIME) + } + + /// 交接保留已确认的祖先链,避免中间父进程退出后丢失仍活着的后代。 + pub fn save_handoff_checkpoint(&mut self, path: &Path) -> anyhow::Result<()> { + self.refresh()?; + let mut members = self.lineage.clone(); + for (process, role) in &self.processes { + if let Some(member) = members.iter_mut() + .find(|member| member.pid == process.process_id && member.birth == process.birth_id) + { + member.exit = process.exit_time()?; + continue; + } + members.push(TreeMember { + pid: process.process_id, + birth: process.birth_id, + parent: process.parent_id()?, + exit: process.exit_time()?, + role: *role, + }); + } + anyhow::ensure!(members.len() <= Self::MAX_TRACKED, "进程树交接祖先记录超过安全上限"); + let checkpoint = TreeCheckpoint { identity: self.identity.clone(), members }; + crate::settings::atomic_write(path, &serde_json::to_vec(&checkpoint)?) + } + + pub fn restore_handoff_checkpoint(&mut self, path: &Path) -> anyhow::Result<()> { + let metadata = std::fs::symlink_metadata(path)?; + anyhow::ensure!( + metadata.is_file() && !metadata.file_type().is_symlink() && metadata.len() <= 262144, + "进程树交接记录类型或大小无效" + ); + let checkpoint: TreeCheckpoint = serde_json::from_slice(&std::fs::read(path)?)?; + anyhow::ensure!( + checkpoint.identity == self.identity + && !checkpoint.members.is_empty() + && checkpoint.members.len() <= Self::MAX_TRACKED, + "进程树交接记录不属于当前实例" + ); + let root = &checkpoint.members[0]; + anyhow::ensure!( + root.pid == self.identity.process_id + && root.birth == self.identity.process_birth_id + && root.role == Role::Desktop, + "进程树交接记录缺少原代根" + ); + let mut restored = Vec::new(); + let mut seen = std::collections::HashSet::from([root.pid]); + for (index, member) in checkpoint.members.iter().enumerate().skip(1) { + anyhow::ensure!(seen.insert(member.pid), "进程树交接记录包含重复 PID"); + let parent = checkpoint.members[..index].iter() + .find(|parent| parent.pid == member.parent) + .ok_or_else(|| anyhow::anyhow!("进程树交接记录祖先链断裂"))?; + anyhow::ensure!( + parent.role != Role::AppServer + && member.role != Role::Desktop + && child_lifetime_matches( + parent.pid, member.parent, parent.birth, parent.exit, member.birth + ), + "进程树交接记录包含无效后代" + ); + let Some(process) = VerifiedStopProcess::capture(member.pid, true)? else { + continue; + }; + // PID 已复用表示原代退出;不收养或结束复用 PID 的新进程。 + if process.birth_id != member.birth || !process.is_alive()? { + continue; + } + anyhow::ensure!( + process.parent_id()? == member.parent + && child_role(&self.identity, parent.role, &process.image()?, &process.arguments()?) + == Some(member.role), + "进程树交接后代身份冲突" + ); + if !self.processes.iter().any(|(known, _)| known.process_id == member.pid) { + restored.push((process, member.role)); + } + } + anyhow::ensure!( + self.processes.len() + restored.len() <= Self::MAX_TRACKED, + "续接后的自有进程数量超过安全上限" + ); + self.processes.extend(restored); + self.lineage = checkpoint.members; + self.refresh() + } + + fn refresh_until(&mut self, deadline: Instant) -> anyhow::Result<()> { + use std::collections::HashSet; + let snapshot = bounded_process_snapshot(Self::MAX_SNAPSHOT)?; + let mut known: HashSet = self.processes.iter().map(|(p, _)| p.process_id).collect(); + let mut index = 0; + while index < self.processes.len() { + anyhow::ensure!(Instant::now() < deadline, "多开进程树追踪超时"); + let (parent, parent_role) = &self.processes[index]; + let (parent_pid, parent_birth, parent_role) = + (parent.process_id, parent.birth_id, *parent_role); + if parent_role == Role::AppServer { + index += 1; + continue; + } + for candidate in snapshot + .iter() + .filter(|p| p.parent_process_id == parent_pid) + { + anyhow::ensure!(Instant::now() < deadline, "多开进程树追踪超时"); + if known.contains(&candidate.process_id) { + continue; + } + // 先按快照文件名排除 shell 等无关工具,随后仍从持有句柄核验完整映像。 + let desktop_name = self.identity.executable.file_name().unwrap_or_default(); + if !candidate + .exe_file + .eq_ignore_ascii_case(&desktop_name.to_string_lossy()) + && !candidate.exe_file.eq_ignore_ascii_case("codex.exe") + { + continue; + } + let Some(child) = VerifiedStopProcess::capture(candidate.process_id, true)? else { + continue; + }; + if !child.is_alive()? { + continue; + } + // parent_exit 必须在捕获子代后读取,防止父代先退出、PID 后复用。 + if !child_lifetime_matches( + parent_pid, + child.parent_id()?, + parent_birth, + self.processes[index].0.exit_time()?, + child.birth_id, + ) { + continue; + } + let facts = child + .image() + .and_then(|image| child.arguments().map(|args| (image, args))); + let (image, args) = match facts { + Ok(facts) => facts, + Err(_) if !child.is_alive()? => continue, + Err(error) => return Err(error), + }; + let Some(role) = child_role(&self.identity, parent_role, &image, &args) else { + continue; + }; + anyhow::ensure!( + self.processes.len() < Self::MAX_TRACKED, + "多开自有进程数量超过安全上限" + ); + known.insert(child.process_id); + self.processes.push((child, role)); + } + index += 1; + } + Ok(()) + } + + pub fn cleanup_after_exit(mut self) -> anyhow::Result<()> { + anyhow::ensure!( + !self.processes[0].0.is_alive()?, + "原代 Codex 仍存活,拒绝残留清理" + ); + self.stop_and_wait() + } + + pub(super) fn stop_and_wait(&mut self) -> anyhow::Result<()> { + // 从第一轮捕获至最后一次退出确认均持有原句柄。共享总时间,避免 N * timeout。 + let deadline = Instant::now() + Self::STOP_TIMEOUT; + self.refresh_until(deadline)?; + self.processes[0].0.terminate()?; + for _ in 0..Self::STOP_PASSES { + anyhow::ensure!(Instant::now() < deadline, "多开自有进程清理超时"); + self.refresh_until(deadline)?; + for (process, _) in self.processes.iter().rev() { + anyhow::ensure!(Instant::now() < deadline, "多开自有进程清理超时"); + process.terminate()?; + } + loop { + let mut alive = false; + for (process, _) in &self.processes { + alive |= process.is_alive()?; + } + if !alive { + break; + } + anyhow::ensure!(Instant::now() < deadline, "多开自有进程尚未确认退出"); + std::thread::sleep(Duration::from_millis(20)); + } + let before = self.processes.len(); + self.refresh_until(deadline)?; + if self.processes.len() == before { + return Ok(()); + } + } + anyhow::bail!("多开自有进程持续派生,未确认清理完成") + } +} + +#[cfg(all(test, windows))] +#[path = "windows_process_tree_tests.rs"] +mod native_tests; + +#[cfg(test)] +mod tests { + use super::*; + + fn identity(root: &Path) -> CodexInstanceIdentity { + CodexInstanceIdentity { + instance_id: "own".into(), + generation: 1, + debug_port: 9341, + helper_port: 9342, + user_data_dir: root.into(), + browser_id: "browser-own".into(), + process_id: 42, + process_birth_id: 100, + executable: root.join("ChatGPT.exe"), + } + } + + #[test] + fn only_desktop_electron_roles_and_trusted_app_server_are_adopted() { + let dir = tempfile::tempdir().unwrap(); + let identity = identity(dir.path()); + std::fs::create_dir(dir.path().join("resources")).unwrap(); + let cli = dir.path().join("resources/codex.exe"); + std::fs::write(&cli, "").unwrap(); + let cli = cli.canonicalize().unwrap(); + for role in [ + "renderer", + "gpu-process", + "utility", + "zygote", + "sandbox-helper", + ] { + let args = vec!["ChatGPT.exe".into(), format!("--type={role}")]; + assert_eq!( + child_role(&identity, Role::Desktop, &identity.executable, &args), + Some(Role::Electron) + ); + assert_eq!( + child_role(&identity, Role::AppServer, &identity.executable, &args), + None + ); + } + let args = vec![ + "codex.exe".into(), + "app-server".into(), + "--analytics-default-enabled".into(), + ]; + assert_eq!( + child_role(&identity, Role::Desktop, &cli, &args), + Some(Role::AppServer) + ); + assert_eq!(child_role(&identity, Role::AppServer, &cli, &args), None); + assert_eq!( + child_role( + &identity, + Role::Desktop, + &dir.path().join("codex.exe"), + &args + ), + None + ); + assert_eq!( + child_role( + &identity, + Role::Desktop, + &cli, + &["codex.exe".into(), "exec".into()] + ), + None + ); + assert_eq!( + child_role( + &identity, + Role::Desktop, + Path::new("cmd.exe"), + &["cmd.exe".into(), "/c".into(), "tool".into()] + ), + None + ); + } + + #[test] + fn independent_window_neighbor_and_ambiguous_roles_are_preserved() { + let dir = tempfile::tempdir().unwrap(); + let identity = identity(dir.path()); + for args in [ + vec!["ChatGPT.exe".into()], + vec!["ChatGPT.exe".into(), "--type=browser".into()], + vec![ + "ChatGPT.exe".into(), + "--type=renderer".into(), + "--type=utility".into(), + ], + vec!["ChatGPT.exe".into(), "--type".into()], + vec![ + "ChatGPT.exe".into(), + "--type=renderer".into(), + "--remote-debugging-port=9343".into(), + ], + vec![ + "ChatGPT.exe".into(), + "--type=renderer".into(), + "--user-data-dir=missing-neighbor".into(), + ], + ] { + assert_eq!( + child_role(&identity, Role::Desktop, &identity.executable, &args), + None + ); + } + } + + #[test] + fn parent_birth_exit_interval_rejects_recycled_parent_and_unrelated_child() { + assert!(child_lifetime_matches(10, 10, 100, 200, 150)); + assert!(child_lifetime_matches(10, 10, 100, 0, 250)); + assert!(!child_lifetime_matches(10, 11, 100, 0, 150)); + assert!(!child_lifetime_matches(10, 10, 100, 200, 201)); + assert!(!child_lifetime_matches(10, 10, 100, 0, 99)); + } +} diff --git a/crates/codex-plus-core/src/multi_worker/windows_process_tree_tests.rs b/crates/codex-plus-core/src/multi_worker/windows_process_tree_tests.rs new file mode 100644 index 0000000000..eab1f480cb --- /dev/null +++ b/crates/codex-plus-core/src/multi_worker/windows_process_tree_tests.rs @@ -0,0 +1,580 @@ +use super::*; +use crate::multi_worker::{ + codex_instance_needs_worker, codex_instance_process_exited, terminate_verified_codex_instance, + verified_worker_is_running, +}; +use crate::windows_integration::{CREATE_NO_WINDOW, child_process_birth_id}; +use serde::{Deserialize, Serialize}; +use std::os::windows::process::CommandExt; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; + +// 只串行化会拷贝并派生原生进程的测试;纯规则和 CDP 单元测试仍并行。 +static FIXTURE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); +const FIXTURE_NAME: &str = "multi_worker::windows_process_tree::native_tests::fixture_process"; +const FIXTURE_ENV: &str = "CODEX_PLUS_CORE_STOP_FIXTURE_DIR"; +const MODES: &[&str] = &[ + "root", + "renderer", + "gpu", + "appserver", + "independent", + "shell", + "user-tool", + "neighbor", +]; + +#[derive(Serialize, Deserialize)] +struct Record { + pid: u32, + birth: u64, + image: PathBuf, +} + +fn record_child(dir: &Path, mode: &str, child: &Child, image: &Path) { + let record = Record { + pid: child.id(), + birth: child_process_birth_id(child).unwrap(), + image: image.canonicalize().unwrap(), + }; + let pending = dir.join(format!("{mode}.json.pending")); + std::fs::write(&pending, serde_json::to_vec(&record).unwrap()) + .unwrap_or_else(|error| panic!("write {mode} record: {error}")); + std::fs::rename(&pending, dir.join(format!("{mode}.json"))) + .unwrap_or_else(|error| panic!("publish {mode} record: {error}")); +} + +fn capture_logs(command: &mut Command, dir: &Path, mode: &str) { + let stdout = std::fs::File::create(dir.join(format!("{mode}.stdout.log"))).unwrap(); + let stderr = std::fs::File::create(dir.join(format!("{mode}.stderr.log"))).unwrap(); + command + .stdout(Stdio::from(stdout)) + .stderr(Stdio::from(stderr)); +} + +fn log_tail(path: &Path) -> String { + use std::io::{Read, Seek, SeekFrom}; + let Ok(mut file) = std::fs::File::open(path) else { + return String::new(); + }; + let Ok(length) = file.metadata().map(|metadata| metadata.len()) else { + return String::new(); + }; + let _ = file.seek(SeekFrom::Start(length.saturating_sub(2048))); + let mut bytes = Vec::new(); + let _ = file.take(2048).read_to_end(&mut bytes); + String::from_utf8_lossy(&bytes).into_owned() +} + +// 返回 (已就绪, 确认失败, 可打印状态)。缺记录可等待,已退出/身份冲突则立即失败。 +fn fixture_status(dir: &Path, mode: &str) -> (bool, bool, String) { + let ready = mode == "shell" || dir.join(format!("{mode}.ready")).is_file(); + let bytes = match std::fs::read(dir.join(format!("{mode}.json"))) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return ( + false, + false, + format!("{mode}: missing record, ready={ready}"), + ); + } + Err(error) => return (false, true, format!("{mode}: record read failed: {error}")), + }; + let record: Record = match serde_json::from_slice(&bytes) { + Ok(record) => record, + Err(error) => return (false, true, format!("{mode}: invalid record: {error}")), + }; + let state = (|| -> anyhow::Result<(bool, String)> { + let Some(process) = VerifiedStopProcess::capture(record.pid, false)? else { + return Ok((false, "process missing; exit code unavailable".into())); + }; + anyhow::ensure!( + process.birth_id == record.birth, + "recorded PID generation changed" + ); + if !process.is_alive()? { + return Ok(( + false, + match process.exit_code()? { + Some(code) => format!("exited code={code} (0x{code:08X})"), + None => "exit status uncertain".into(), + }, + )); + } + anyhow::ensure!( + same_image(&process.image()?, &record.image), + "recorded image changed" + ); + Ok((true, "running".into())) + })(); + match state { + Ok((alive, state)) => ( + ready && alive, + !alive, + format!( + "{mode}: pid={}, birth={}, ready={ready}, {state}", + record.pid, record.birth + ), + ), + Err(error) => ( + false, + true, + format!( + "{mode}: pid={}, birth={}, ready={ready}, probe failed: {error:#}", + record.pid, record.birth, + ), + ), + } +} + +fn wait_for_fixtures(dir: &Path, modes: &[&str], timeout: Duration) { + let started = Instant::now(); + loop { + let statuses: Vec<_> = modes.iter().map(|mode| fixture_status(dir, mode)).collect(); + if statuses.iter().all(|(ready, _, _)| *ready) { + return; + } + if statuses.iter().any(|(_, failed, _)| *failed) || started.elapsed() >= timeout { + let mut diagnostics = statuses + .into_iter() + .map(|(_, _, line)| line) + .collect::>() + .join("\n"); + for mode in modes { + for stream in ["stdout", "stderr"] { + let tail = log_tail(&dir.join(format!("{mode}.{stream}.log"))); + if !tail.trim().is_empty() { + diagnostics.push_str(&format!("\n--- {mode} {stream} tail ---\n{tail}")); + } + } + } + panic!( + "hidden lifecycle fixtures failed after {:?}, directory={}\n{diagnostics}", + started.elapsed(), + dir.display(), + ); + } + std::thread::sleep(Duration::from_millis(20)); + } +} + +fn spawn_fixture(dir: &Path, mode: &str, port: u16) -> Child { + let image = dir.join(if mode == "appserver" { + "resources/codex.exe" + } else { + "Desktop fixture.exe" + }); + let mut command = Command::new(&image); + // 拷贝的测试二进制仍从原测试目录解析可选的运行时 DLL,不改全局环境。 + let original = std::env::current_exe().unwrap(); + let inherited_path = std::env::var_os("PATH").unwrap_or_default(); + let search_path = std::env::join_paths( + original + .parent() + .into_iter() + .map(Path::to_path_buf) + .chain(std::env::split_paths(&inherited_path)), + ) + .unwrap(); + command.env("PATH", search_path); + if mode == "appserver" { + command.arg("app-server"); + } + command + .args(["--ignored", "--exact", FIXTURE_NAME, "--nocapture", "--"]) + .env(FIXTURE_ENV, dir) + .env("CODEX_PLUS_CORE_STOP_FIXTURE_MODE", mode) + .creation_flags(CREATE_NO_WINDOW) + .stdin(Stdio::null()); + capture_logs(&mut command, dir, mode); + match mode { + "root" | "default-main" => { + command.arg(format!("--remote-debugging-port={port}")); + if mode == "root" { + command.arg(format!( + "--user-data-dir={}", + dir.join("user data").display() + )); + } + } + "renderer" | "user-tool" => { + command.arg("--type=renderer"); + } + "gpu" => { + command.arg("--type=gpu-process"); + } + _ => {} + } + let child = command.spawn().unwrap_or_else(|error| { + panic!( + "spawn fixture mode={mode}, image={}: {error}", + image.display() + ) + }); + record_child(dir, mode, &child, &image); + child +} + +/// 仅由本模块显式运行;测试二进制扮演 Electron/CLI,不启动真实桌面或模型。 +#[test] +#[ignore] +fn fixture_process() { + let Some(dir) = std::env::var_os(FIXTURE_ENV).map(PathBuf::from) else { + return; + }; + let mode = std::env::var("CODEX_PLUS_CORE_STOP_FIXTURE_MODE").unwrap(); + let mut children = Vec::new(); + if mode == "root" { + for child_mode in ["renderer", "appserver", "independent"] { + children.push(spawn_fixture(&dir, child_mode, 0)); + } + let shell = PathBuf::from(std::env::var_os("SystemRoot").unwrap()) + .join("System32/WindowsPowerShell/v1.0/powershell.exe"); + let mut command = Command::new(&shell); + command + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "Start-Sleep -Seconds 30", + ]) + .creation_flags(CREATE_NO_WINDOW) + .stdin(Stdio::null()); + capture_logs(&mut command, &dir, "shell"); + let child = command.spawn().unwrap(); + record_child(&dir, "shell", &child, &shell); + children.push(child); + } else if mode == "renderer" { + children.push(spawn_fixture(&dir, "gpu", 0)); + } else if mode == "appserver" { + // 即使用户工具用了相同 Electron 映像和 --type,也不能越过 app-server 边界收养。 + children.push(spawn_fixture(&dir, "user-tool", 0)); + } + std::fs::write(dir.join(format!("{mode}.ready")), "ready").unwrap(); + std::thread::sleep(Duration::from_secs(30)); + for mut child in children { + let _ = child.kill(); + let _ = child.wait(); + } +} + +struct FixtureGroup { + dir: tempfile::TempDir, + root: Option, + neighbor: Option, +} + +impl FixtureGroup { + fn start(port: u16) -> Self { + let mut group = Self { + dir: tempfile::tempdir().unwrap(), + root: None, + neighbor: None, + }; + let dir = group.dir.path(); + std::fs::create_dir(dir.join("resources")).unwrap(); + std::fs::create_dir(dir.join("user data")).unwrap(); + let image = std::env::current_exe().unwrap(); + std::fs::copy(&image, dir.join("Desktop fixture.exe")).unwrap(); + std::fs::copy(&image, dir.join("resources/codex.exe")).unwrap(); + group.root = Some(spawn_fixture(dir, "root", port)); + group.neighbor = Some(spawn_fixture(dir, "neighbor", 0)); + wait_for_fixtures(dir, MODES, Duration::from_secs(10)); + group + } + + fn process(&self, mode: &str) -> VerifiedStopProcess { + let record: Record = serde_json::from_slice( + &std::fs::read(self.dir.path().join(format!("{mode}.json"))).unwrap(), + ) + .unwrap(); + let process = VerifiedStopProcess::capture(record.pid, true) + .unwrap() + .unwrap(); + assert_eq!(record.birth, process.birth_id); + assert!(same_image(&process.image().unwrap(), &record.image)); + process + } + + fn identity(&self, port: u16) -> CodexInstanceIdentity { + let root = self.process("root"); + CodexInstanceIdentity { + instance_id: "fixture-own".into(), + generation: 1, + debug_port: port, + helper_port: if port == 65535 { 65534 } else { port + 1 }, + user_data_dir: self.dir.path().join("user data").canonicalize().unwrap(), + browser_id: "fixture-browser".into(), + process_id: root.process_id, + process_birth_id: root.birth_id, + executable: root.image().unwrap(), + } + } +} + +fn wait_for_exit(process: &VerifiedStopProcess) { + let deadline = Instant::now() + Duration::from_secs(3); + while process.is_alive().unwrap() { + assert!(Instant::now() < deadline, "fixture process did not exit"); + std::thread::sleep(Duration::from_millis(10)); + } +} + +impl Drop for FixtureGroup { + fn drop(&mut self) { + // 先停止自建根以封住派生,再核对每个记录的 birth/image,回收所有保留正反例。 + if let Some(child) = &mut self.root { + let _ = child.kill(); + let _ = child.wait(); + } + for mode in MODES { + let Ok(bytes) = std::fs::read(self.dir.path().join(format!("{mode}.json"))) else { + continue; + }; + let Ok(record) = serde_json::from_slice::(&bytes) else { + continue; + }; + let Ok(Some(process)) = VerifiedStopProcess::capture(record.pid, true) else { + continue; + }; + if process.birth_id == record.birth + && process + .image() + .is_ok_and(|image| same_image(&image, &record.image)) + { + let _ = process.terminate(); + let deadline = Instant::now() + Duration::from_secs(2); + while process.is_alive().unwrap_or(false) && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + } + } + if let Some(child) = &mut self.neighbor { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +fn unused_port() -> u16 { + std::net::TcpListener::bind(("127.0.0.1", 0)) + .unwrap() + .local_addr() + .unwrap() + .port() +} + +#[tokio::test] +async fn concurrent_stops_confirm_exit_of_the_same_owned_tree() { + let _guard = FIXTURE_LOCK.lock().await; + let port = unused_port(); + let fixtures = FixtureGroup::start(port); + let identity = fixtures.identity(port); + let mut first = CodexInstanceProcessTree::capture(&identity).unwrap(); + let mut second = CodexInstanceProcessTree::capture(&identity).unwrap(); + let preserved: Vec<_> = ["shell", "independent", "user-tool", "neighbor"] + .iter() + .map(|mode| fixtures.process(mode)) + .collect(); + std::thread::scope(|scope| { + let one = scope.spawn(|| first.stop_and_wait()); + let two = scope.spawn(|| second.stop_and_wait()); + one.join().unwrap().unwrap(); + two.join().unwrap().unwrap(); + }); + assert!(codex_instance_process_exited(&identity)); + assert!(preserved.iter().all(|process| process.is_alive().unwrap())); +} + +#[tokio::test] +async fn ready_tracking_cleans_owned_orphans_and_preserves_shell_tools_and_neighbors() { + let _fixture_lock = FIXTURE_LOCK.lock().await; + let port = unused_port(); + let fixtures = FixtureGroup::start(port); + let identity = fixtures.identity(port); + let mut tree = CodexInstanceProcessTree::capture(&identity).unwrap(); + assert_eq!(tree.processes.len(), 4); + assert!(tree.cleanup_after_exit().is_err()); + tree = CodexInstanceProcessTree::capture(&identity).unwrap(); + let owned: Vec<_> = ["root", "renderer", "gpu", "appserver"] + .iter() + .map(|mode| fixtures.process(mode)) + .collect(); + let preserved: Vec<_> = ["shell", "independent", "user-tool", "neighbor"] + .iter() + .map(|mode| fixtures.process(mode)) + .collect(); + assert!(codex_instance_needs_worker(&identity).await.unwrap()); + owned[0].terminate().unwrap(); + wait_for_exit(&owned[0]); + assert!(codex_instance_process_exited(&identity)); + assert!(!codex_instance_needs_worker(&identity).await.unwrap()); + assert!(owned[1].is_alive().unwrap()); + tree.refresh().unwrap(); + tree.cleanup_after_exit().unwrap(); + assert!(owned.iter().all(|p| !p.is_alive().unwrap())); + assert!(preserved.iter().all(|p| p.is_alive().unwrap())); +} + +#[tokio::test] +async fn handoff_checkpoint_preserves_orphans_across_two_worker_replacements() { + let _fixture_lock = FIXTURE_LOCK.lock().await; + let port = unused_port(); + let fixtures = FixtureGroup::start(port); + let identity = fixtures.identity(port); + let mut original = CodexInstanceProcessTree::capture(&identity).unwrap(); + let renderer = fixtures.process("renderer"); + let gpu = fixtures.process("gpu"); + let preserved: Vec<_> = ["shell", "independent", "user-tool", "neighbor"] + .iter().map(|mode| fixtures.process(mode)).collect(); + renderer.terminate().unwrap(); + wait_for_exit(&renderer); + assert!(gpu.is_alive().unwrap()); + let checkpoint = fixtures.dir.path().join("handoff.json"); + original.save_handoff_checkpoint(&checkpoint).unwrap(); + drop(original); + for _ in 0..2 { + let mut resumed = CodexInstanceProcessTree::capture(&identity).unwrap(); + assert!(!resumed.processes.iter().any(|(p, _)| p.process_id == gpu.process_id)); + resumed.restore_handoff_checkpoint(&checkpoint).unwrap(); + assert!(resumed.processes.iter().any(|(p, _)| p.process_id == gpu.process_id)); + resumed.save_handoff_checkpoint(&checkpoint).unwrap(); + } + let mut final_worker = CodexInstanceProcessTree::capture(&identity).unwrap(); + final_worker.restore_handoff_checkpoint(&checkpoint).unwrap(); + let mut wrong: TreeCheckpoint = serde_json::from_slice(&std::fs::read(&checkpoint).unwrap()).unwrap(); + wrong.identity.generation += 1; + std::fs::write(&checkpoint, serde_json::to_vec(&wrong).unwrap()).unwrap(); + assert!(final_worker.restore_handoff_checkpoint(&checkpoint).is_err()); + assert!(gpu.is_alive().unwrap()); + final_worker.stop_and_wait().unwrap(); + assert!(!gpu.is_alive().unwrap()); + assert!(preserved.iter().all(|p| p.is_alive().unwrap())); +} + +#[tokio::test] +async fn wrong_birth_image_arguments_and_browser_conflict_never_stop_the_root() { + let _fixture_lock = FIXTURE_LOCK.lock().await; + use wiremock::{Mock, MockServer, ResponseTemplate, matchers::path}; + // 专属服务才能通过 drop 模拟端口失联;默认池化服务会继续返回 HTTP 404。 + let server = MockServer::builder().start().await; + let port = server.address().port(); + let fixtures = FixtureGroup::start(port); + let identity = fixtures.identity(port); + let root = fixtures.process("root"); + let mut wrong = identity.clone(); + wrong.process_birth_id += 1; + assert!(terminate_verified_codex_instance(&wrong).await.is_err()); + assert!(root.is_alive().unwrap()); + wrong = identity.clone(); + wrong.executable = fixtures.dir.path().join("resources/codex.exe"); + assert!(terminate_verified_codex_instance(&wrong).await.is_err()); + assert!(codex_instance_needs_worker(&wrong).await.is_err()); + assert!(root.is_alive().unwrap()); + wrong = identity.clone(); + wrong.user_data_dir = fixtures.dir.path().canonicalize().unwrap(); + assert!(terminate_verified_codex_instance(&wrong).await.is_err()); + assert!(codex_instance_needs_worker(&wrong).await.is_err()); + assert!(root.is_alive().unwrap()); + wrong = identity.clone(); + wrong.debug_port = if port == 65535 { port - 1 } else { port + 1 }; + assert!(terminate_verified_codex_instance(&wrong).await.is_err()); + assert!(root.is_alive().unwrap()); + Mock::given(path("/json/version")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "Browser": "fixture", + "webSocketDebuggerUrl": format!("ws://127.0.0.1:{port}/devtools/browser/neighbor") + }))) + .mount(&server) + .await; + assert!(terminate_verified_codex_instance(&identity).await.is_err()); + assert!(codex_instance_needs_worker(&identity).await.is_err()); + assert!(!codex_instance_process_exited(&identity)); + assert!(root.is_alive().unwrap()); + drop(server); + let deadline = Instant::now() + Duration::from_secs(2); + while std::net::TcpStream::connect_timeout( + &std::net::SocketAddr::from(([127, 0, 0, 1], port)), + Duration::from_millis(20), + ) + .is_ok() + { + assert!( + Instant::now() < deadline, + "fixture CDP listener did not close" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(codex_instance_needs_worker(&identity).await.unwrap()); + let preserved: Vec<_> = ["shell", "independent", "user-tool", "neighbor"] + .iter() + .map(|mode| fixtures.process(mode)) + .collect(); + assert!(terminate_verified_codex_instance(&identity).await.unwrap()); + assert!(codex_instance_process_exited(&identity)); + assert!(preserved.iter().all(|p| p.is_alive().unwrap())); +} + +#[tokio::test] +async fn default_main_without_user_data_can_be_captured_and_stopped_by_saved_identity() { + let _fixture_lock = FIXTURE_LOCK.lock().await; + let dir = tempfile::tempdir().unwrap(); + std::fs::copy( + std::env::current_exe().unwrap(), + dir.path().join("Desktop fixture.exe"), + ) + .unwrap(); + struct Guard(Child); + impl Drop for Guard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + let port = unused_port(); + let child = Guard(spawn_fixture(dir.path(), "default-main", port)); + wait_for_fixtures(dir.path(), &["default-main"], Duration::from_secs(5)); + let identity = CodexInstanceIdentity { + instance_id: "main".into(), + generation: 1, + debug_port: port, + helper_port: 9342, + user_data_dir: PathBuf::new(), + browser_id: "fixture-browser".into(), + process_id: child.0.id(), + process_birth_id: child_process_birth_id(&child.0).unwrap(), + executable: dir + .path() + .join("Desktop fixture.exe") + .canonicalize() + .unwrap(), + }; + // CDP 已断连,仍可从保存的同代身份捕获停止树。 + let tree = CodexInstanceProcessTree::capture(&identity).unwrap(); + assert_eq!(tree.processes.len(), 1); + assert!(codex_instance_needs_worker(&identity).await.unwrap()); + let mut wrong = identity.clone(); + wrong.user_data_dir = dir.path().canonicalize().unwrap(); + assert!(terminate_verified_codex_instance(&wrong).await.is_err()); + assert!(terminate_verified_codex_instance(&identity).await.unwrap()); + assert!(codex_instance_process_exited(&identity)); +} + +#[tokio::test] +async fn read_only_worker_probe_reports_conflicts_and_only_confirms_same_generation_exit() { + let _fixture_lock = FIXTURE_LOCK.lock().await; + let fixtures = FixtureGroup::start(unused_port()); + let root = fixtures.process("root"); + let options = fixtures.dir.path().join("worker-options.json"); + std::fs::write(&options, "{}").unwrap(); + assert!(verified_worker_is_running(root.process_id, &options, None).is_err()); + assert!( + verified_worker_is_running(root.process_id, &options, Some(root.birth_id + 1)).is_err() + ); + // fixture 的 argv 不是固定的三段 worker argv,存活查询必须报冲突且不结束它。 + assert!(verified_worker_is_running(root.process_id, &options, Some(root.birth_id)).is_err()); + assert!(root.is_alive().unwrap()); + root.terminate().unwrap(); + wait_for_exit(&root); + assert!(!verified_worker_is_running(root.process_id, &options, Some(root.birth_id)).unwrap()); +} diff --git a/crates/codex-plus-core/src/windows_integration.rs b/crates/codex-plus-core/src/windows_integration.rs index ffb32f8092..897b26a61f 100644 --- a/crates/codex-plus-core/src/windows_integration.rs +++ b/crates/codex-plus-core/src/windows_integration.rs @@ -88,6 +88,18 @@ pub fn close_process_tree(process_id: u32, force: bool) -> bool { #[cfg(windows)] pub const CREATE_NO_WINDOW: u32 = 0x08000000; +#[cfg(windows)] +pub(crate) fn prevent_socket_inheritance( + socket: std::os::windows::io::RawSocket, +) -> anyhow::Result<()> { + use windows::Win32::Foundation::{HANDLE_FLAG_INHERIT, HANDLE_FLAGS, SetHandleInformation}; + // SAFETY: 调用方在整个操作期间持有 socket;仅修改继承位,不关闭或转移句柄。 + unsafe { + SetHandleInformation(HANDLE(socket as *mut _), HANDLE_FLAG_INHERIT.0, HANDLE_FLAGS(0)) + } + .context("无法禁止 helper socket 被子进程继承") +} + /// 当前进程是否以提权(管理员 / 高完整性令牌)运行。 /// /// 启动器用它守卫 MSIX 打包应用激活:Windows 不允许提权进程激活打包应用, @@ -492,54 +504,74 @@ pub(crate) fn terminate_verified_multi_worker( options_path: &std::path::Path, expected_birth: Option, ) -> anyhow::Result { - use windows::Win32::Foundation::{HLOCAL, LocalFree}; - use windows::Win32::UI::Shell::CommandLineToArgvW; - if process_id == std::process::id() || expected_birth.is_none() { - return Ok(false); - } - let Ok(handle) = (unsafe { - OpenProcess( - PROCESS_TERMINATE | PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ, - false, - process_id, - ) - }) else { + let Some(process) = + capture_verified_multi_worker(process_id, options_path, expected_birth, true)? + else { return Ok(false); }; - if handle.is_invalid() { - return Ok(false); + process.terminate()?; + use windows::Win32::Foundation::WAIT_OBJECT_0; + anyhow::ensure!( + unsafe { WaitForSingleObject(process.native(), 5_000) } == WAIT_OBJECT_0, + "已验证的多开 worker 尚未确认退出" + ); + Ok(true) +} + +#[cfg(windows)] +pub(crate) fn verified_multi_worker_is_running( + process_id: u32, + options_path: &std::path::Path, + expected_birth: Option, +) -> anyhow::Result { + // 只授予 QUERY/VM_READ/SYNCHRONIZE,不为存活检查请求终止权限。 + Ok(capture_verified_multi_worker(process_id, options_path, expected_birth, false)?.is_some()) +} + +#[cfg(windows)] +fn capture_verified_multi_worker( + process_id: u32, + options_path: &std::path::Path, + expected_birth: Option, + terminate: bool, +) -> anyhow::Result> { + let expected = expected_birth + .filter(|birth| *birth != 0) + .ok_or_else(|| anyhow::anyhow!("缺少可信 worker 创建身份"))?; + let Some(process) = VerifiedStopProcess::capture(process_id, terminate)? else { + return Ok(None); + }; + anyhow::ensure!(process.birth_id == expected, "worker 创建身份冲突"); + if !process.is_alive()? { + return Ok(None); } - let _guard = HandleGuard(handle); - let actual_birth = process_birth_id_from_handle(handle); - if actual_birth != expected_birth { return Ok(false); } - let Some(command_line) = process_command_line_from_handle(handle) else { return Ok(false); }; - let wide = wide_null(&command_line); - let mut count = 0; - let argv = unsafe { CommandLineToArgvW(PCWSTR(wide.as_ptr()), &mut count) }; - if argv.is_null() { return Ok(false); } - let arguments = if count == 3 { - unsafe { std::slice::from_raw_parts(argv, count as usize) }.iter() - .map(|value| unsafe { value.to_string() }.ok()).collect::>>() - } else { None }; - // CommandLineToArgvW 分配的 argv 必须用 LocalFree 释放。 - unsafe { LocalFree(HLOCAL(argv.cast())) }; - let Some(arguments) = arguments else { - return Ok(false); + let arguments = match process.arguments() { + Ok(arguments) => arguments, + Err(_) if !process.is_alive()? => return Ok(None), + Err(error) => return Err(error).context("无法核验 worker 参数"), }; - if !crate::multi_worker::worker_identity_matches( - expected_birth, - actual_birth, - &arguments, - options_path, - ) { - return Ok(false); + anyhow::ensure!( + crate::multi_worker::worker_identity_matches( + expected_birth, + Some(process.birth_id), + &arguments, + options_path, + ), + "worker marker 或 options 身份冲突" + ); + let image = match process.image() { + Ok(image) => image, + Err(_) if !process.is_alive()? => return Ok(None), + Err(error) => return Err(error).context("无法核验 worker 映像"), + }; + anyhow::ensure!( + crate::multi_worker::worker_image_matches(&image, &arguments), + "worker 实际映像与自身 argv[0] 冲突" + ); + if !process.is_alive()? { + return Ok(None); } - let Some(image) = process_image_path_from_handle(handle) else { return Ok(false); }; - let Ok(expected_image) = std::env::current_exe().and_then(|path| path.canonicalize()) - else { return Ok(false); }; - if image.canonicalize().ok().as_ref() != Some(&expected_image) { return Ok(false); } - unsafe { TerminateProcess(handle, 0) }.context("结束已验证的多开 worker 失败")?; - Ok(true) + Ok(Some(process)) } #[cfg(windows)] @@ -565,61 +597,181 @@ fn command_line_arguments(command_line: &str) -> Option> { } #[cfg(windows)] -pub(crate) fn process_arguments(process_id: u32) -> Option> { +/// 仅解析原始 Windows argv;此 PID 查询本身不证明进程身份或代际。 +pub fn process_arguments(process_id: u32) -> Option> { command_line_arguments(&process_command_line(process_id)?) } -/// 命令行、创建时间、映像与终止共用同一句柄,拒绝按端口或 PID 猜测归属。 +/// 同一句柄读取 birth/image/argv/parent 并终止、等待;绝不重新按 PID 发送 kill。 #[cfg(windows)] -pub(crate) fn terminate_verified_codex_instance( - identity: &crate::multi_worker::CodexInstanceIdentity, -) -> anyhow::Result { - if identity.process_id == std::process::id() || identity.process_birth_id == 0 { - return Ok(false); +pub(crate) struct VerifiedStopProcess { + pub process_id: u32, + pub birth_id: u64, + handle: std::os::windows::io::OwnedHandle, +} + +#[cfg(windows)] +impl VerifiedStopProcess { + pub fn capture(process_id: u32, terminate: bool) -> anyhow::Result> { + use std::os::windows::io::FromRawHandle; + anyhow::ensure!( + process_id != 0 && process_id != std::process::id(), + "拒绝捕获无效进程或当前 worker" + ); + let rights = PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE | PROCESS_VM_READ; + let rights = if terminate { + rights | PROCESS_TERMINATE + } else { + rights + }; + let native = match unsafe { OpenProcess(rights, false, process_id) } { + Ok(handle) => handle, + // ERROR_INVALID_PARAMETER 是不存在的 PID;拒绝把 ACCESS_DENIED 当成退出。 + Err(error) if error.code().0 as u32 == 0x80070057 => return Ok(None), + Err(error) => return Err(error).context("无法持有多开进程句柄"), + }; + let handle = unsafe { std::os::windows::io::OwnedHandle::from_raw_handle(native.0) }; + let birth_id = process_birth_id_from_handle(native) + .ok_or_else(|| anyhow::anyhow!("无法读取多开进程创建身份"))?; + Ok(Some(Self { + process_id, + birth_id, + handle, + })) } - let Ok(handle) = (unsafe { + + fn native(&self) -> HANDLE { + use std::os::windows::io::AsRawHandle; + HANDLE(self.handle.as_raw_handle()) + } + + pub fn is_alive(&self) -> anyhow::Result { + use windows::Win32::Foundation::{WAIT_OBJECT_0, WAIT_TIMEOUT}; + match unsafe { WaitForSingleObject(self.native(), 0) } { + WAIT_OBJECT_0 => Ok(false), + WAIT_TIMEOUT => Ok(true), + _ => Err(std::io::Error::last_os_error()).context("无法确认多开进程退出"), + } + } + + pub fn image(&self) -> anyhow::Result { + process_image_path_from_handle(self.native()) + .ok_or_else(|| anyhow::anyhow!("无法读取多开进程映像")) + } + + pub fn arguments(&self) -> anyhow::Result> { + process_command_line_from_handle(self.native()) + .and_then(|line| command_line_arguments(&line)) + .ok_or_else(|| anyhow::anyhow!("无法读取多开进程参数")) + } + + pub fn parent_id(&self) -> anyhow::Result { + let mut info: ProcessBasicInformation = unsafe { std::mem::zeroed() }; + let mut returned = 0_u32; + let status = unsafe { + NtQueryInformationProcess( + self.native(), + PROCESS_BASIC_INFORMATION_CLASS, + (&mut info as *mut ProcessBasicInformation).cast(), + std::mem::size_of::() as u32, + &mut returned, + ) + }; + anyhow::ensure!(status >= 0, "无法读取多开进程父代身份"); + Ok(u32::try_from(info.inherited_from_unique_process_id)?) + } + + pub fn exit_time(&self) -> anyhow::Result { + let mut birth = FILETIME::default(); + let mut exit = FILETIME::default(); + let mut kernel = FILETIME::default(); + let mut user = FILETIME::default(); + unsafe { GetProcessTimes(self.native(), &mut birth, &mut exit, &mut kernel, &mut user) }?; + Ok(((exit.dwHighDateTime as u64) << 32) | exit.dwLowDateTime as u64) + } + + #[cfg(test)] + pub fn exit_code(&self) -> anyhow::Result> { + if self.is_alive()? { + return Ok(None); + } + let mut code = 0; + unsafe { windows::Win32::System::Threading::GetExitCodeProcess(self.native(), &mut code) }?; + Ok(Some(code)) + } + + pub fn terminate(&self) -> anyhow::Result<()> { + if self.is_alive()? { + if let Err(error) = unsafe { TerminateProcess(self.native(), 0) } { + use windows::Win32::Foundation::WAIT_OBJECT_0; + // 另一清理方已请求退出时,TerminateProcess 可先拒绝,句柄随后才变为 signaled。 + if unsafe { WaitForSingleObject(self.native(), 1_000) } != WAIT_OBJECT_0 { + return Err(error).with_context(|| { + format!("结束已验证多开进程 {} 失败", self.process_id) + }); + } + } + } + Ok(()) + } +} + +#[cfg(windows)] +pub(crate) fn process_generation_exited(pid: u32, expected_birth: u64) -> anyhow::Result { + use windows::Win32::Foundation::{WAIT_OBJECT_0, WAIT_TIMEOUT}; + anyhow::ensure!(pid != 0 && expected_birth != 0, "进程创建身份不完整"); + let native = match unsafe { OpenProcess( - PROCESS_TERMINATE | PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ, + PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE, false, - identity.process_id, + pid, ) - }) else { - return Ok(false); + } { + Ok(handle) => handle, + Err(error) if error.code().0 as u32 == 0x80070057 => return Ok(true), + Err(error) => return Err(error).context("无法确认原代进程是否退出"), }; - if handle.is_invalid() { - return Ok(false); + let _guard = HandleGuard(native); + let birth = process_birth_id_from_handle(native) + .ok_or_else(|| anyhow::anyhow!("无法读取原代进程创建身份"))?; + if birth != expected_birth { + return Ok(true); } - let _guard = HandleGuard(handle); - if process_birth_id_from_handle(handle) != Some(identity.process_birth_id) { - return Ok(false); + match unsafe { WaitForSingleObject(native, 0) } { + WAIT_OBJECT_0 => Ok(true), + WAIT_TIMEOUT => Ok(false), + _ => Err(std::io::Error::last_os_error()).context("无法查询原代进程句柄"), } - let Some(image) = process_image_path_from_handle(handle) else { - return Ok(false); - }; - let normalize = |path: &std::path::Path| { - path.to_string_lossy() - .trim_start_matches(r"\\?\") - .to_ascii_lowercase() - }; - if normalize(&image) != normalize(&identity.executable) { - return Ok(false); - } - let Some(arguments) = - process_command_line_from_handle(handle).and_then(|line| command_line_arguments(&line)) - else { - return Ok(false); +} + +/// 为安全树提供有界且可报告失败的快照;不为无关系统进程逐一打开句柄。 +#[cfg(windows)] +pub(crate) fn bounded_process_snapshot(limit: usize) -> anyhow::Result> { + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }?; + let _guard = HandleGuard(snapshot); + let mut entry = PROCESSENTRY32W { + dwSize: std::mem::size_of::() as u32, + ..Default::default() }; - if !crate::multi_worker::codex_arguments_match( - &arguments, - &identity.user_data_dir, - identity.debug_port, - ) || unique_listener_process_id(&loopback_listener_process_ids(identity.debug_port)?)? - != Some(identity.process_id) - { - return Ok(false); + let mut result = unsafe { Process32FirstW(snapshot, &mut entry) }; + let mut processes = Vec::new(); + loop { + if let Err(error) = result { + anyhow::ensure!( + error.code().0 as u32 == 0x80070012, + "无法完整读取多开进程快照:{error}" + ); + return Ok(processes); + } + anyhow::ensure!(processes.len() < limit, "系统进程快照超过安全上限"); + processes.push(WindowsProcessInfo { + process_id: entry.th32ProcessID, + parent_process_id: entry.th32ParentProcessID, + exe_file: nul_terminated_wide_to_string(&entry.szExeFile), + executable_path: None, + }); + result = unsafe { Process32NextW(snapshot, &mut entry) }; } - unsafe { TerminateProcess(handle, 0) }.context("结束已验证的多开 Codex 实例失败")?; - Ok(true) } /// `ProcessBasicInformation` / `ProcessWow64Information`。 From 19f8bc647f23ff7393cea8edd7f07418bd74f222 Mon Sep 17 00:00:00 2001 From: hcw <1416522360@qq.com> Date: Wed, 28 Oct 2026 20:18:53 +0800 Subject: [PATCH 2/8] =?UTF-8?q?=E8=A7=84=E8=8C=83=E5=8C=96=20Windows=20?= =?UTF-8?q?=E7=9F=AD=E9=95=BF=E6=98=A0=E5=83=8F=E8=B7=AF=E5=BE=84=E5=B9=B6?= =?UTF-8?q?=E4=BF=9D=E6=8C=81=E8=BA=AB=E4=BB=BD=E6=8B=92=E7=BB=9D=E8=BE=B9?= =?UTF-8?q?=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/multi_worker/windows_process_tree.rs | 115 +++++++++++++++--- .../windows_process_tree_tests.rs | 115 ++++++++++++++++-- 2 files changed, 206 insertions(+), 24 deletions(-) diff --git a/crates/codex-plus-core/src/multi_worker/windows_process_tree.rs b/crates/codex-plus-core/src/multi_worker/windows_process_tree.rs index 14ced82847..c3513a6013 100644 --- a/crates/codex-plus-core/src/multi_worker/windows_process_tree.rs +++ b/crates/codex-plus-core/src/multi_worker/windows_process_tree.rs @@ -13,7 +13,7 @@ enum Role { AppServer, } -fn same_image(actual: &Path, expected: &Path) -> bool { +fn same_resolved_path(actual: &Path, expected: &Path) -> bool { let normalize = |path: &Path| { path.to_string_lossy() .trim_start_matches(r"\\?\") @@ -23,6 +23,16 @@ fn same_image(actual: &Path, expected: &Path) -> bool { normalize(actual) == normalize(expected) } +fn same_image(actual: &Path, expected: &Path) -> bool { + if !actual.is_absolute() || !expected.is_absolute() { + return false; + } + let (Ok(actual), Ok(expected)) = (actual.canonicalize(), expected.canonicalize()) else { + return false; + }; + same_resolved_path(&actual, &expected) +} + fn flag_values<'a>(args: &'a [String], flag: &str) -> Option> { let mut values = Vec::new(); let mut tokens = args.iter().skip(1); @@ -76,14 +86,12 @@ fn child_role( } return Some(Role::Electron); } - let trusted = identity - .executable - .parent()? - .join("resources") - .join("codex.exe"); + // 先解析安装目录的短/长路径别名,再拼接可信相对路径。 + let desktop = identity.executable.canonicalize().ok()?; + let trusted = desktop.parent()?.join("resources").join("codex.exe"); // 不接受 resources 的目录联接/符号链接跳转到安装目录之外。 let canonical = trusted.canonicalize().ok()?; - if !same_image(&canonical, &trusted) || !same_image(image, &canonical) { + if !same_resolved_path(&canonical, &trusted) || !same_image(image, &canonical) { return None; } // 桌面内置 CLI 的角色必须是精确的第一个子命令。 @@ -189,7 +197,8 @@ impl CodexInstanceProcessTree { self.refresh()?; let mut members = self.lineage.clone(); for (process, role) in &self.processes { - if let Some(member) = members.iter_mut() + if let Some(member) = members + .iter_mut() .find(|member| member.pid == process.process_id && member.birth == process.birth_id) { member.exit = process.exit_time()?; @@ -203,8 +212,14 @@ impl CodexInstanceProcessTree { role: *role, }); } - anyhow::ensure!(members.len() <= Self::MAX_TRACKED, "进程树交接祖先记录超过安全上限"); - let checkpoint = TreeCheckpoint { identity: self.identity.clone(), members }; + anyhow::ensure!( + members.len() <= Self::MAX_TRACKED, + "进程树交接祖先记录超过安全上限" + ); + let checkpoint = TreeCheckpoint { + identity: self.identity.clone(), + members, + }; crate::settings::atomic_write(path, &serde_json::to_vec(&checkpoint)?) } @@ -232,14 +247,19 @@ impl CodexInstanceProcessTree { let mut seen = std::collections::HashSet::from([root.pid]); for (index, member) in checkpoint.members.iter().enumerate().skip(1) { anyhow::ensure!(seen.insert(member.pid), "进程树交接记录包含重复 PID"); - let parent = checkpoint.members[..index].iter() + let parent = checkpoint.members[..index] + .iter() .find(|parent| parent.pid == member.parent) .ok_or_else(|| anyhow::anyhow!("进程树交接记录祖先链断裂"))?; anyhow::ensure!( parent.role != Role::AppServer && member.role != Role::Desktop && child_lifetime_matches( - parent.pid, member.parent, parent.birth, parent.exit, member.birth + parent.pid, + member.parent, + parent.birth, + parent.exit, + member.birth ), "进程树交接记录包含无效后代" ); @@ -252,11 +272,19 @@ impl CodexInstanceProcessTree { } anyhow::ensure!( process.parent_id()? == member.parent - && child_role(&self.identity, parent.role, &process.image()?, &process.arguments()?) - == Some(member.role), + && child_role( + &self.identity, + parent.role, + &process.image()?, + &process.arguments()? + ) == Some(member.role), "进程树交接后代身份冲突" ); - if !self.processes.iter().any(|(known, _)| known.process_id == member.pid) { + if !self + .processes + .iter() + .any(|(known, _)| known.process_id == member.pid) + { restored.push((process, member.role)); } } @@ -389,6 +417,7 @@ mod tests { use super::*; fn identity(root: &Path) -> CodexInstanceIdentity { + std::fs::write(root.join("ChatGPT.exe"), "").unwrap(); CodexInstanceIdentity { instance_id: "own".into(), generation: 1, @@ -402,6 +431,62 @@ mod tests { } } + #[test] + fn unresolved_or_relative_images_never_match_even_identical_spellings() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("missing.exe"); + assert!(!same_image(&missing, &missing)); + let existing = dir.path().join("ChatGPT.exe"); + std::fs::write(&existing, "").unwrap(); + assert!(same_image(&existing, &existing.canonicalize().unwrap())); + assert!(!same_image(&existing, &missing)); + assert!(!same_image( + Path::new("ChatGPT.exe"), + Path::new("ChatGPT.exe") + )); + } + + #[cfg(windows)] + #[test] + fn resources_junction_outside_installation_is_not_a_trusted_app_server() { + use std::os::windows::process::CommandExt; + let dir = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let identity = identity(dir.path()); + std::fs::write(outside.path().join("codex.exe"), "").unwrap(); + let command = Path::new(&std::env::var_os("SystemRoot").unwrap()) + .join("System32/WindowsPowerShell/v1.0/powershell.exe"); + let link = dir + .path() + .join("resources") + .to_string_lossy() + .replace('\'', "''"); + let target = outside.path().to_string_lossy().replace('\'', "''"); + let result = std::process::Command::new(command) + .args(["-NoProfile", "-NonInteractive", "-Command"]) + .arg(format!("New-Item -ItemType Junction -Path '{link}' -Target '{target}' -ErrorAction Stop | Out-Null")) + .creation_flags(crate::windows_integration::CREATE_NO_WINDOW) + .output() + .unwrap(); + assert!( + result.status.success(), + "junction fixture: {}", + String::from_utf8_lossy(&result.stderr) + ); + let cli = dir.path().join("resources/codex.exe"); + // 映像指向同一真实文件仍不够:可信相对位置不能被重定向。 + assert!(same_image(&cli, &outside.path().join("codex.exe"))); + assert_eq!( + child_role( + &identity, + Role::Desktop, + &cli, + &["codex.exe".into(), "app-server".into()] + ), + None + ); + } + #[test] fn only_desktop_electron_roles_and_trusted_app_server_are_adopted() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/codex-plus-core/src/multi_worker/windows_process_tree_tests.rs b/crates/codex-plus-core/src/multi_worker/windows_process_tree_tests.rs index eab1f480cb..5dd2c44b8d 100644 --- a/crates/codex-plus-core/src/multi_worker/windows_process_tree_tests.rs +++ b/crates/codex-plus-core/src/multi_worker/windows_process_tree_tests.rs @@ -9,6 +9,22 @@ use std::os::windows::process::CommandExt; use std::path::PathBuf; use std::process::{Child, Command, Stdio}; +fn short_path(path: &Path) -> PathBuf { + use std::os::windows::ffi::{OsStrExt, OsStringExt}; + use windows::Win32::Storage::FileSystem::GetShortPathNameW; + let input: Vec = path.as_os_str().encode_wide().chain(Some(0)).collect(); + let mut output = vec![0_u16; 32768]; + let length = + unsafe { GetShortPathNameW(windows::core::PCWSTR(input.as_ptr()), Some(&mut output)) } + as usize; + assert!( + length > 0 && length < output.len(), + "short path: {}", + std::io::Error::last_os_error() + ); + PathBuf::from(std::ffi::OsString::from_wide(&output[..length])) +} + // 只串行化会拷贝并派生原生进程的测试;纯规则和 CDP 单元测试仍并行。 static FIXTURE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); const FIXTURE_NAME: &str = "multi_worker::windows_process_tree::native_tests::fixture_process"; @@ -266,8 +282,15 @@ struct FixtureGroup { impl FixtureGroup { fn start(port: u16) -> Self { + Self::start_with_paths(port, false) + } + + fn start_with_paths(port: u16, use_short_paths: bool) -> Self { let mut group = Self { - dir: tempfile::tempdir().unwrap(), + dir: tempfile::Builder::new() + .prefix("Codex process safety fixture ") + .tempdir() + .unwrap(), root: None, neighbor: None, }; @@ -277,8 +300,18 @@ impl FixtureGroup { let image = std::env::current_exe().unwrap(); std::fs::copy(&image, dir.join("Desktop fixture.exe")).unwrap(); std::fs::copy(&image, dir.join("resources/codex.exe")).unwrap(); - group.root = Some(spawn_fixture(dir, "root", port)); - group.neighbor = Some(spawn_fixture(dir, "neighbor", 0)); + let launch_dir = if use_short_paths { + let short = short_path(&dir.canonicalize().unwrap()); + assert!( + !same_resolved_path(&short, &dir.canonicalize().unwrap()), + "regression requires a real 8.3 alias" + ); + short + } else { + dir.to_path_buf() + }; + group.root = Some(spawn_fixture(&launch_dir, "root", port)); + group.neighbor = Some(spawn_fixture(&launch_dir, "neighbor", 0)); wait_for_fixtures(dir, MODES, Duration::from_secs(10)); group } @@ -364,6 +397,51 @@ fn unused_port() -> u16 { .port() } +#[tokio::test] +async fn short_path_launch_matches_long_identity_and_preserves_unowned_processes() { + let _guard = FIXTURE_LOCK.lock().await; + let port = unused_port(); + let fixtures = FixtureGroup::start_with_paths(port, true); + let mut identity = fixtures.identity(port); + identity.executable = fixtures + .dir + .path() + .join("Desktop fixture.exe") + .canonicalize() + .unwrap(); + let short_image = short_path(&identity.executable); + assert!(same_image(&short_image, &identity.executable)); + assert!(same_image(&identity.executable, &short_image)); + let cli = fixtures + .dir + .path() + .join("resources/codex.exe") + .canonicalize() + .unwrap(); + assert_eq!( + child_role( + &identity, + Role::Desktop, + &short_path(&cli), + &["codex.exe".into(), "app-server".into()] + ), + Some(Role::AppServer) + ); + let mut tree = CodexInstanceProcessTree::capture(&identity).unwrap(); + assert_eq!(tree.processes.len(), 4); + let owned: Vec<_> = ["root", "renderer", "gpu", "appserver"] + .iter() + .map(|mode| fixtures.process(mode)) + .collect(); + let preserved: Vec<_> = ["shell", "independent", "user-tool", "neighbor"] + .iter() + .map(|mode| fixtures.process(mode)) + .collect(); + tree.stop_and_wait().unwrap(); + assert!(owned.iter().all(|process| !process.is_alive().unwrap())); + assert!(preserved.iter().all(|process| process.is_alive().unwrap())); +} + #[tokio::test] async fn concurrent_stops_confirm_exit_of_the_same_owned_tree() { let _guard = FIXTURE_LOCK.lock().await; @@ -426,7 +504,9 @@ async fn handoff_checkpoint_preserves_orphans_across_two_worker_replacements() { let renderer = fixtures.process("renderer"); let gpu = fixtures.process("gpu"); let preserved: Vec<_> = ["shell", "independent", "user-tool", "neighbor"] - .iter().map(|mode| fixtures.process(mode)).collect(); + .iter() + .map(|mode| fixtures.process(mode)) + .collect(); renderer.terminate().unwrap(); wait_for_exit(&renderer); assert!(gpu.is_alive().unwrap()); @@ -435,17 +515,34 @@ async fn handoff_checkpoint_preserves_orphans_across_two_worker_replacements() { drop(original); for _ in 0..2 { let mut resumed = CodexInstanceProcessTree::capture(&identity).unwrap(); - assert!(!resumed.processes.iter().any(|(p, _)| p.process_id == gpu.process_id)); + assert!( + !resumed + .processes + .iter() + .any(|(p, _)| p.process_id == gpu.process_id) + ); resumed.restore_handoff_checkpoint(&checkpoint).unwrap(); - assert!(resumed.processes.iter().any(|(p, _)| p.process_id == gpu.process_id)); + assert!( + resumed + .processes + .iter() + .any(|(p, _)| p.process_id == gpu.process_id) + ); resumed.save_handoff_checkpoint(&checkpoint).unwrap(); } let mut final_worker = CodexInstanceProcessTree::capture(&identity).unwrap(); - final_worker.restore_handoff_checkpoint(&checkpoint).unwrap(); - let mut wrong: TreeCheckpoint = serde_json::from_slice(&std::fs::read(&checkpoint).unwrap()).unwrap(); + final_worker + .restore_handoff_checkpoint(&checkpoint) + .unwrap(); + let mut wrong: TreeCheckpoint = + serde_json::from_slice(&std::fs::read(&checkpoint).unwrap()).unwrap(); wrong.identity.generation += 1; std::fs::write(&checkpoint, serde_json::to_vec(&wrong).unwrap()).unwrap(); - assert!(final_worker.restore_handoff_checkpoint(&checkpoint).is_err()); + assert!( + final_worker + .restore_handoff_checkpoint(&checkpoint) + .is_err() + ); assert!(gpu.is_alive().unwrap()); final_worker.stop_and_wait().unwrap(); assert!(!gpu.is_alive().unwrap()); From 8e017d4cbe97433f2bdbcceaf30595d916940c7d Mon Sep 17 00:00:00 2001 From: hcw <1416522360@qq.com> Date: Thu, 29 Oct 2026 18:55:28 +0800 Subject: [PATCH 3/8] =?UTF-8?q?=E4=BF=9D=E7=95=99=E5=A4=9A=E5=BC=80?= =?UTF-8?q?=E8=BA=AB=E4=BB=BD=E6=A0=A1=E9=AA=8C=E5=A4=B1=E8=B4=A5=E8=AF=8A?= =?UTF-8?q?=E6=96=AD=E5=B9=B6=E6=94=B6=E7=AA=84=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src-tauri/src/commands.rs | 30 +++-- crates/codex-plus-core/src/lib.rs | 5 - crates/codex-plus-core/src/multi_worker.rs | 113 ++++++++++-------- .../src/multi_worker/windows_process_tree.rs | 17 ++- .../src/windows_integration.rs | 2 +- 5 files changed, 101 insertions(+), 66 deletions(-) diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index a29690d09a..d553e1517c 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -1864,16 +1864,32 @@ fn terminate_multi_worker( #[cfg(windows)] { // 创建身份、精确参数与 exe 均验证后,使用同一句柄终止,不再按 PID taskkill。 - let terminated = codex_plus_core::multi_worker::terminate_verified_worker( + let result = codex_plus_core::multi_worker::terminate_verified_worker( pid, options_path, worker_birth_id, - )?; - if !terminated { - let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( - "manager.multi_worker_stop_skipped", - json!({ "pid": pid, "reason": "identity_unverified" }), - ); + ); + match result { + Ok(true) => {} + Ok(false) => { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "manager.multi_worker_stop_skipped", + json!({ "pid": pid, "reason": "identity_unverified" }), + ); + } + Err(error) => { + // 多开停止调用方会故意忽略清理错误;这里先写诊断,避免身份冲突或 + // 查询失败只表现为“停掉了”,却没有留下跳过依据。 + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "manager.multi_worker_stop_skipped", + json!({ + "pid": pid, + "reason": "verification_error", + "message": format!("{error:#}"), + }), + ); + return Err(error); + } } } #[cfg(not(any(unix, windows)))] diff --git a/crates/codex-plus-core/src/lib.rs b/crates/codex-plus-core/src/lib.rs index c271480b58..ea0d3414c5 100644 --- a/crates/codex-plus-core/src/lib.rs +++ b/crates/codex-plus-core/src/lib.rs @@ -103,11 +103,6 @@ pub(crate) mod xai_responses; // 门控在模块级会导致 `if cfg!(windows)` 这类运行时分支在非 Windows 平台找不到符号。 mod windows_integration; #[cfg(windows)] -pub use windows_integration::{ - WindowsProcessInfo, process_arguments as windows_process_arguments, - process_birth_id as windows_process_birth_id, -}; -#[cfg(windows)] mod windows_taskbar; #[cfg(windows)] diff --git a/crates/codex-plus-core/src/multi_worker.rs b/crates/codex-plus-core/src/multi_worker.rs index c7d9293704..860dcc8d3a 100644 --- a/crates/codex-plus-core/src/multi_worker.rs +++ b/crates/codex-plus-core/src/multi_worker.rs @@ -7,36 +7,36 @@ use serde::{Deserialize, Serialize}; #[cfg(any(windows, test))] mod windows_process_tree; #[cfg(windows)] -pub use windows_process_tree::CodexInstanceProcessTree; +pub(crate) use windows_process_tree::CodexInstanceProcessTree; // 非 Windows 兼容接口仅确认原根退出,不提供 Windows 的自有后代清理能力。 #[cfg(not(windows))] -pub struct CodexInstanceProcessTree { +pub(crate) struct CodexInstanceProcessTree { identity: CodexInstanceIdentity, } #[cfg(not(windows))] impl CodexInstanceProcessTree { - pub fn capture(identity: &CodexInstanceIdentity) -> anyhow::Result { + pub(crate) fn capture(identity: &CodexInstanceIdentity) -> anyhow::Result { anyhow::ensure!(valid_stop_identity(identity), "多开实例身份字段不完整"); Ok(Self { identity: identity.clone(), }) } - pub fn refresh(&mut self) -> anyhow::Result<()> { + pub(crate) fn refresh(&mut self) -> anyhow::Result<()> { Ok(()) } - pub fn save_handoff_checkpoint(&mut self, _path: &std::path::Path) -> anyhow::Result<()> { + pub(crate) fn save_handoff_checkpoint(&mut self, _path: &std::path::Path) -> anyhow::Result<()> { Ok(()) } - pub fn restore_handoff_checkpoint(&mut self, _path: &std::path::Path) -> anyhow::Result<()> { + pub(crate) fn restore_handoff_checkpoint(&mut self, _path: &std::path::Path) -> anyhow::Result<()> { Ok(()) } - pub fn cleanup_after_exit(self) -> anyhow::Result<()> { + pub(crate) fn cleanup_after_exit(self) -> anyhow::Result<()> { anyhow::ensure!( codex_instance_process_exited(&self.identity), "原代 Codex 尚未确认退出" @@ -75,23 +75,30 @@ fn valid_stop_identity(identity: &CodexInstanceIdentity) -> bool { && identity.executable.is_absolute() } -/// 主实例可沿用 Electron 默认目录;None 保存“没有目录参数”,不推测默认路径。 -pub async fn capture_main_codex_instance_identity( - generation: u64, - debug_port: u16, - helper_port: u16, - user_data_dir: Option<&std::path::Path>, - executable: &std::path::Path, -) -> anyhow::Result { - capture_codex_instance_identity( - "main", - generation, - debug_port, - helper_port, - user_data_dir.unwrap_or_else(|| std::path::Path::new("")), - executable, - ) - .await +fn record_stop_verification_failure( + identity: &CodexInstanceIdentity, + result: &anyhow::Result, +) { + let detail = match result { + Ok(true) => return, + Ok(false) => serde_json::json!({ + "reason": "identity_changed", + }), + Err(error) => serde_json::json!({ + "reason": "verification_error", + "message": format!("{error:#}"), + }), + }; + let _ = crate::diagnostic_log::append_diagnostic_log( + "multi_worker.stop_verification_failed", + serde_json::json!({ + "instanceId": identity.instance_id, + "generation": identity.generation, + "processId": identity.process_id, + "debugPort": identity.debug_port, + "detail": detail, + }), + ); } #[cfg(any(windows, target_os = "macos"))] @@ -114,7 +121,7 @@ fn codex_identity_arguments_match(arguments: &[String], identity: &CodexInstance } /// 只确认原代退出;权限、身份字段或查询不确定时返回 false,调用方须保留 worker。 -pub fn codex_instance_process_exited(identity: &CodexInstanceIdentity) -> bool { +pub(crate) fn codex_instance_process_exited(identity: &CodexInstanceIdentity) -> bool { if !valid_stop_identity(identity) { return false; } @@ -145,7 +152,9 @@ pub fn codex_instance_process_exited(identity: &CodexInstanceIdentity) -> bool { } /// 仅确认原根退出才返回 false;读取失败、映像/参数/CDP 矛盾均为 Err。 -pub async fn codex_instance_needs_worker(identity: &CodexInstanceIdentity) -> anyhow::Result { +pub(crate) async fn codex_instance_needs_worker( + identity: &CodexInstanceIdentity, +) -> anyhow::Result { anyhow::ensure!(valid_stop_identity(identity), "多开实例身份字段不完整"); #[cfg(windows)] { @@ -494,21 +503,26 @@ pub async fn codex_instance_identity_matches( pub async fn terminate_verified_codex_instance( identity: &CodexInstanceIdentity, ) -> anyhow::Result { - if !codex_instance_identity_matches(identity).await? { - return Ok(false); - } - let browser = crate::cdp::browser_identity(identity.debug_port).await?; - if browser.browser_id()? != identity.browser_id { - return Ok(false); + let result = async { + if !codex_instance_identity_matches(identity).await? { + return Ok(false); + } + let browser = crate::cdp::browser_identity(identity.debug_port).await?; + if browser.browser_id()? != identity.browser_id { + return Ok(false); + } + // 发给这一代 Browser 的专属 WebSocket,不按 PID/端口发送 kill。 + crate::bridge::send_cdp_command( + &browser.web_socket_debugger_url, + "Browser.close", + serde_json::json!({}), + ) + .await?; + Ok(true) } - // 发给这一代 Browser 的专属 WebSocket,不按 PID/端口发送 kill。 - crate::bridge::send_cdp_command( - &browser.web_socket_debugger_url, - "Browser.close", - serde_json::json!({}), - ) - .await?; - Ok(true) + .await; + record_stop_verification_failure(identity, &result); + result } #[cfg(not(any(windows, target_os = "macos")))] @@ -621,14 +635,19 @@ pub async fn codex_instance_identity_matches( pub async fn terminate_verified_codex_instance( identity: &CodexInstanceIdentity, ) -> anyhow::Result { - if !valid_stop_identity(identity) { - return Ok(false); + let result = async { + if !valid_stop_identity(identity) { + return Ok(false); + } + // 捕获到退出确认全程持有句柄,CDP 只提供额外的否决证据。 + let mut tree = CodexInstanceProcessTree::capture(identity)?; + check_stop_browser_identity(identity).await?; + tree.stop_and_wait()?; + Ok(true) } - // 捕获到退出确认全程持有句柄,CDP 只提供额外的否决证据。 - let mut tree = CodexInstanceProcessTree::capture(identity)?; - check_stop_browser_identity(identity).await?; - tree.stop_and_wait()?; - Ok(true) + .await; + record_stop_verification_failure(identity, &result); + result } #[cfg(any(windows, test))] diff --git a/crates/codex-plus-core/src/multi_worker/windows_process_tree.rs b/crates/codex-plus-core/src/multi_worker/windows_process_tree.rs index c3513a6013..cdd5e9fa7d 100644 --- a/crates/codex-plus-core/src/multi_worker/windows_process_tree.rs +++ b/crates/codex-plus-core/src/multi_worker/windows_process_tree.rs @@ -145,8 +145,11 @@ pub(super) fn capture_root( /// worker 在 ready 时捕获并持续 refresh;保留所有已收养代的句柄直到清理确认。 /// 只收养桌面自身的 Electron 角色和安装目录内的 app-server,绝不遍历用户工具。 +/// +/// `crashpad-handler`、终端、MCP 和用户启动的工具不在角色白名单中,故不会被 +/// 自动结束;它们由各自的父程序或操作系统负责生命周期。 #[cfg(windows)] -pub struct CodexInstanceProcessTree { +pub(crate) struct CodexInstanceProcessTree { identity: CodexInstanceIdentity, processes: Vec<(VerifiedStopProcess, Role)>, lineage: Vec, @@ -177,7 +180,7 @@ impl CodexInstanceProcessTree { const STOP_TIMEOUT: Duration = Duration::from_secs(8); const STOP_PASSES: usize = 8; - pub fn capture(identity: &CodexInstanceIdentity) -> anyhow::Result { + pub(crate) fn capture(identity: &CodexInstanceIdentity) -> anyhow::Result { let root = capture_root(identity, true)?; let mut tree = Self { identity: identity.clone(), @@ -188,12 +191,12 @@ impl CodexInstanceProcessTree { Ok(tree) } - pub fn refresh(&mut self) -> anyhow::Result<()> { + pub(crate) fn refresh(&mut self) -> anyhow::Result<()> { self.refresh_until(Instant::now() + Self::MAX_REFRESH_TIME) } /// 交接保留已确认的祖先链,避免中间父进程退出后丢失仍活着的后代。 - pub fn save_handoff_checkpoint(&mut self, path: &Path) -> anyhow::Result<()> { + pub(crate) fn save_handoff_checkpoint(&mut self, path: &Path) -> anyhow::Result<()> { self.refresh()?; let mut members = self.lineage.clone(); for (process, role) in &self.processes { @@ -223,7 +226,7 @@ impl CodexInstanceProcessTree { crate::settings::atomic_write(path, &serde_json::to_vec(&checkpoint)?) } - pub fn restore_handoff_checkpoint(&mut self, path: &Path) -> anyhow::Result<()> { + pub(crate) fn restore_handoff_checkpoint(&mut self, path: &Path) -> anyhow::Result<()> { let metadata = std::fs::symlink_metadata(path)?; anyhow::ensure!( metadata.is_file() && !metadata.file_type().is_symlink() && metadata.len() <= 262144, @@ -367,7 +370,7 @@ impl CodexInstanceProcessTree { Ok(()) } - pub fn cleanup_after_exit(mut self) -> anyhow::Result<()> { + pub(crate) fn cleanup_after_exit(mut self) -> anyhow::Result<()> { anyhow::ensure!( !self.processes[0].0.is_alive()?, "原代 Codex 仍存活,拒绝残留清理" @@ -558,6 +561,8 @@ mod tests { for args in [ vec!["ChatGPT.exe".into()], vec!["ChatGPT.exe".into(), "--type=browser".into()], + // crashpad-handler 属于未收养进程,不能由多开停止流程强制结束。 + vec!["ChatGPT.exe".into(), "--type=crashpad-handler".into()], vec![ "ChatGPT.exe".into(), "--type=renderer".into(), diff --git a/crates/codex-plus-core/src/windows_integration.rs b/crates/codex-plus-core/src/windows_integration.rs index 897b26a61f..785936b353 100644 --- a/crates/codex-plus-core/src/windows_integration.rs +++ b/crates/codex-plus-core/src/windows_integration.rs @@ -598,7 +598,7 @@ fn command_line_arguments(command_line: &str) -> Option> { #[cfg(windows)] /// 仅解析原始 Windows argv;此 PID 查询本身不证明进程身份或代际。 -pub fn process_arguments(process_id: u32) -> Option> { +pub(crate) fn process_arguments(process_id: u32) -> Option> { command_line_arguments(&process_command_line(process_id)?) } From 7f2d0adfb39bb8f68a4e26b58e2e6f6b51b950bd Mon Sep 17 00:00:00 2001 From: hcw <1416522360@qq.com> Date: Thu, 29 Oct 2026 20:26:46 +0800 Subject: [PATCH 4/8] =?UTF-8?q?=E7=B2=BE=E7=AE=80=E6=9C=AA=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E7=9A=84=E5=A4=9A=E5=BC=80=E7=94=9F=E5=91=BD=E5=91=A8?= =?UTF-8?q?=E6=9C=9F=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/codex-plus-core/src/multi_worker.rs | 134 +--------------- .../src/multi_worker/windows_process_tree.rs | 149 +----------------- .../windows_process_tree_tests.rs | 96 +---------- .../src/windows_integration.rs | 56 +------ 4 files changed, 14 insertions(+), 421 deletions(-) diff --git a/crates/codex-plus-core/src/multi_worker.rs b/crates/codex-plus-core/src/multi_worker.rs index 860dcc8d3a..ccf9b156e6 100644 --- a/crates/codex-plus-core/src/multi_worker.rs +++ b/crates/codex-plus-core/src/multi_worker.rs @@ -7,43 +7,7 @@ use serde::{Deserialize, Serialize}; #[cfg(any(windows, test))] mod windows_process_tree; #[cfg(windows)] -pub(crate) use windows_process_tree::CodexInstanceProcessTree; - -// 非 Windows 兼容接口仅确认原根退出,不提供 Windows 的自有后代清理能力。 -#[cfg(not(windows))] -pub(crate) struct CodexInstanceProcessTree { - identity: CodexInstanceIdentity, -} - -#[cfg(not(windows))] -impl CodexInstanceProcessTree { - pub(crate) fn capture(identity: &CodexInstanceIdentity) -> anyhow::Result { - anyhow::ensure!(valid_stop_identity(identity), "多开实例身份字段不完整"); - Ok(Self { - identity: identity.clone(), - }) - } - - pub(crate) fn refresh(&mut self) -> anyhow::Result<()> { - Ok(()) - } - - pub(crate) fn save_handoff_checkpoint(&mut self, _path: &std::path::Path) -> anyhow::Result<()> { - Ok(()) - } - - pub(crate) fn restore_handoff_checkpoint(&mut self, _path: &std::path::Path) -> anyhow::Result<()> { - Ok(()) - } - - pub(crate) fn cleanup_after_exit(self) -> anyhow::Result<()> { - anyhow::ensure!( - codex_instance_process_exited(&self.identity), - "原代 Codex 尚未确认退出" - ); - Ok(()) - } -} +use windows_process_tree::CodexInstanceProcessTree; /// 端口只是连接地址;停止实例必须核验保存的进程身份,不能依赖 CDP 健康。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -120,83 +84,6 @@ fn codex_identity_arguments_match(arguments: &[String], identity: &CodexInstance } } -/// 只确认原代退出;权限、身份字段或查询不确定时返回 false,调用方须保留 worker。 -pub(crate) fn codex_instance_process_exited(identity: &CodexInstanceIdentity) -> bool { - if !valid_stop_identity(identity) { - return false; - } - #[cfg(windows)] - { - crate::windows_integration::process_generation_exited( - identity.process_id, - identity.process_birth_id, - ) - .unwrap_or(false) - } - #[cfg(target_os = "macos")] - { - match crate::watcher::inspect_process_instance(identity.process_id) { - crate::watcher::ProcessInstanceState::NotRunning => true, - crate::watcher::ProcessInstanceState::Running { - birth_id: Some(birth), - .. - } => macos_birth_hash(&birth) != identity.process_birth_id, - _ => false, - } - } - #[cfg(not(any(windows, target_os = "macos")))] - { - // 非 Windows 暂不根据一次 PID 查询推断创建代;保持保守行为。 - false - } -} - -/// 仅确认原根退出才返回 false;读取失败、映像/参数/CDP 矛盾均为 Err。 -pub(crate) async fn codex_instance_needs_worker( - identity: &CodexInstanceIdentity, -) -> anyhow::Result { - anyhow::ensure!(valid_stop_identity(identity), "多开实例身份字段不完整"); - #[cfg(windows)] - { - if crate::windows_integration::process_generation_exited( - identity.process_id, - identity.process_birth_id, - )? { - return Ok(false); - } - let process = windows_process_tree::capture_root(identity, false)?; - // CDP 断连不结束 worker;可读的不同 Browser 则必须报身份冲突。 - check_stop_browser_identity(identity).await?; - return Ok(process.is_alive()?); - } - #[cfg(target_os = "macos")] - { - if codex_instance_process_exited(identity) { - return Ok(false); - } - anyhow::ensure!( - macos_birth_token(identity.process_id) == Some(identity.process_birth_id), - "无法核验原代多开进程创建身份" - ); - let args = macos_process_arguments(identity.process_id)?; - anyhow::ensure!( - codex_identity_arguments_match(&args, identity) - && args - .first() - .and_then(|path| Path::new(path).canonicalize().ok()) - .as_ref() - == Some(&identity.executable), - "多开进程映像或参数身份冲突" - ); - check_stop_browser_identity(identity).await?; - return Ok(!codex_instance_process_exited(identity)); - } - #[cfg(not(any(windows, target_os = "macos")))] - { - anyhow::bail!("当前系统尚不支持可靠确认多开进程退出") - } -} - /// 同时检查两个 loopback 地址族,不能用页面列表不可读掩盖 Browser 冲突。 #[cfg(any(windows, target_os = "macos", test))] async fn check_stop_browser_identity(identity: &CodexInstanceIdentity) -> anyhow::Result<()> { @@ -422,14 +309,9 @@ fn macos_birth_token(pid: u32) -> Option { else { return None; }; - Some(macos_birth_hash(&birth)) -} - -#[cfg(target_os = "macos")] -fn macos_birth_hash(birth: &str) -> u64 { - birth.bytes().fold(0xcbf29ce484222325_u64, |hash, byte| { + Some(birth.bytes().fold(0xcbf29ce484222325_u64, |hash, byte| { (hash ^ byte as u64).wrapping_mul(0x100000001b3) - }) + })) } #[cfg(target_os = "macos")] @@ -720,16 +602,6 @@ pub fn terminate_verified_worker( crate::windows_integration::terminate_verified_multi_worker(pid, options_path, expected_birth) } -/// 只读确认同代 worker;仅原代已退出返回 false,身份冲突或查询不确定均为 Err。 -#[cfg(windows)] -pub fn verified_worker_is_running( - pid: u32, - options_path: &Path, - expected_birth: Option, -) -> anyhow::Result { - crate::windows_integration::verified_multi_worker_is_running(pid, options_path, expected_birth) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/codex-plus-core/src/multi_worker/windows_process_tree.rs b/crates/codex-plus-core/src/multi_worker/windows_process_tree.rs index cdd5e9fa7d..da7a33ebe4 100644 --- a/crates/codex-plus-core/src/multi_worker/windows_process_tree.rs +++ b/crates/codex-plus-core/src/multi_worker/windows_process_tree.rs @@ -6,7 +6,7 @@ use crate::windows_integration::{VerifiedStopProcess, bounded_process_snapshot}; #[cfg(windows)] use std::time::{Duration, Instant}; -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Role { Desktop, Electron, @@ -115,15 +115,12 @@ fn child_lifetime_matches( } #[cfg(windows)] -pub(super) fn capture_root( - identity: &CodexInstanceIdentity, - terminate: bool, -) -> anyhow::Result { +pub(super) fn capture_root(identity: &CodexInstanceIdentity) -> anyhow::Result { anyhow::ensure!( super::valid_stop_identity(identity), "多开实例身份字段不完整" ); - let process = VerifiedStopProcess::capture(identity.process_id, terminate)? + let process = VerifiedStopProcess::capture(identity.process_id, true)? .ok_or_else(|| anyhow::anyhow!("原代多开进程已退出"))?; anyhow::ensure!( process.birth_id == identity.process_birth_id, @@ -149,27 +146,9 @@ pub(super) fn capture_root( /// `crashpad-handler`、终端、MCP 和用户启动的工具不在角色白名单中,故不会被 /// 自动结束;它们由各自的父程序或操作系统负责生命周期。 #[cfg(windows)] -pub(crate) struct CodexInstanceProcessTree { +pub(super) struct CodexInstanceProcessTree { identity: CodexInstanceIdentity, processes: Vec<(VerifiedStopProcess, Role)>, - lineage: Vec, -} - -#[cfg(windows)] -#[derive(Clone, serde::Serialize, serde::Deserialize)] -struct TreeMember { - pid: u32, - birth: u64, - parent: u32, - exit: u64, - role: Role, -} - -#[cfg(windows)] -#[derive(serde::Serialize, serde::Deserialize)] -struct TreeCheckpoint { - identity: CodexInstanceIdentity, - members: Vec, } #[cfg(windows)] @@ -180,126 +159,20 @@ impl CodexInstanceProcessTree { const STOP_TIMEOUT: Duration = Duration::from_secs(8); const STOP_PASSES: usize = 8; - pub(crate) fn capture(identity: &CodexInstanceIdentity) -> anyhow::Result { - let root = capture_root(identity, true)?; + pub(super) fn capture(identity: &CodexInstanceIdentity) -> anyhow::Result { + let root = capture_root(identity)?; let mut tree = Self { identity: identity.clone(), processes: vec![(root, Role::Desktop)], - lineage: Vec::new(), }; tree.refresh()?; Ok(tree) } - pub(crate) fn refresh(&mut self) -> anyhow::Result<()> { + fn refresh(&mut self) -> anyhow::Result<()> { self.refresh_until(Instant::now() + Self::MAX_REFRESH_TIME) } - /// 交接保留已确认的祖先链,避免中间父进程退出后丢失仍活着的后代。 - pub(crate) fn save_handoff_checkpoint(&mut self, path: &Path) -> anyhow::Result<()> { - self.refresh()?; - let mut members = self.lineage.clone(); - for (process, role) in &self.processes { - if let Some(member) = members - .iter_mut() - .find(|member| member.pid == process.process_id && member.birth == process.birth_id) - { - member.exit = process.exit_time()?; - continue; - } - members.push(TreeMember { - pid: process.process_id, - birth: process.birth_id, - parent: process.parent_id()?, - exit: process.exit_time()?, - role: *role, - }); - } - anyhow::ensure!( - members.len() <= Self::MAX_TRACKED, - "进程树交接祖先记录超过安全上限" - ); - let checkpoint = TreeCheckpoint { - identity: self.identity.clone(), - members, - }; - crate::settings::atomic_write(path, &serde_json::to_vec(&checkpoint)?) - } - - pub(crate) fn restore_handoff_checkpoint(&mut self, path: &Path) -> anyhow::Result<()> { - let metadata = std::fs::symlink_metadata(path)?; - anyhow::ensure!( - metadata.is_file() && !metadata.file_type().is_symlink() && metadata.len() <= 262144, - "进程树交接记录类型或大小无效" - ); - let checkpoint: TreeCheckpoint = serde_json::from_slice(&std::fs::read(path)?)?; - anyhow::ensure!( - checkpoint.identity == self.identity - && !checkpoint.members.is_empty() - && checkpoint.members.len() <= Self::MAX_TRACKED, - "进程树交接记录不属于当前实例" - ); - let root = &checkpoint.members[0]; - anyhow::ensure!( - root.pid == self.identity.process_id - && root.birth == self.identity.process_birth_id - && root.role == Role::Desktop, - "进程树交接记录缺少原代根" - ); - let mut restored = Vec::new(); - let mut seen = std::collections::HashSet::from([root.pid]); - for (index, member) in checkpoint.members.iter().enumerate().skip(1) { - anyhow::ensure!(seen.insert(member.pid), "进程树交接记录包含重复 PID"); - let parent = checkpoint.members[..index] - .iter() - .find(|parent| parent.pid == member.parent) - .ok_or_else(|| anyhow::anyhow!("进程树交接记录祖先链断裂"))?; - anyhow::ensure!( - parent.role != Role::AppServer - && member.role != Role::Desktop - && child_lifetime_matches( - parent.pid, - member.parent, - parent.birth, - parent.exit, - member.birth - ), - "进程树交接记录包含无效后代" - ); - let Some(process) = VerifiedStopProcess::capture(member.pid, true)? else { - continue; - }; - // PID 已复用表示原代退出;不收养或结束复用 PID 的新进程。 - if process.birth_id != member.birth || !process.is_alive()? { - continue; - } - anyhow::ensure!( - process.parent_id()? == member.parent - && child_role( - &self.identity, - parent.role, - &process.image()?, - &process.arguments()? - ) == Some(member.role), - "进程树交接后代身份冲突" - ); - if !self - .processes - .iter() - .any(|(known, _)| known.process_id == member.pid) - { - restored.push((process, member.role)); - } - } - anyhow::ensure!( - self.processes.len() + restored.len() <= Self::MAX_TRACKED, - "续接后的自有进程数量超过安全上限" - ); - self.processes.extend(restored); - self.lineage = checkpoint.members; - self.refresh() - } - fn refresh_until(&mut self, deadline: Instant) -> anyhow::Result<()> { use std::collections::HashSet; let snapshot = bounded_process_snapshot(Self::MAX_SNAPSHOT)?; @@ -370,14 +243,6 @@ impl CodexInstanceProcessTree { Ok(()) } - pub(crate) fn cleanup_after_exit(mut self) -> anyhow::Result<()> { - anyhow::ensure!( - !self.processes[0].0.is_alive()?, - "原代 Codex 仍存活,拒绝残留清理" - ); - self.stop_and_wait() - } - pub(super) fn stop_and_wait(&mut self) -> anyhow::Result<()> { // 从第一轮捕获至最后一次退出确认均持有原句柄。共享总时间,避免 N * timeout。 let deadline = Instant::now() + Self::STOP_TIMEOUT; diff --git a/crates/codex-plus-core/src/multi_worker/windows_process_tree_tests.rs b/crates/codex-plus-core/src/multi_worker/windows_process_tree_tests.rs index 5dd2c44b8d..91a4032b6c 100644 --- a/crates/codex-plus-core/src/multi_worker/windows_process_tree_tests.rs +++ b/crates/codex-plus-core/src/multi_worker/windows_process_tree_tests.rs @@ -1,8 +1,5 @@ use super::*; -use crate::multi_worker::{ - codex_instance_needs_worker, codex_instance_process_exited, terminate_verified_codex_instance, - verified_worker_is_running, -}; +use crate::multi_worker::terminate_verified_codex_instance; use crate::windows_integration::{CREATE_NO_WINDOW, child_process_birth_id}; use serde::{Deserialize, Serialize}; use std::os::windows::process::CommandExt; @@ -460,7 +457,6 @@ async fn concurrent_stops_confirm_exit_of_the_same_owned_tree() { one.join().unwrap().unwrap(); two.join().unwrap().unwrap(); }); - assert!(codex_instance_process_exited(&identity)); assert!(preserved.iter().all(|process| process.is_alive().unwrap())); } @@ -472,8 +468,6 @@ async fn ready_tracking_cleans_owned_orphans_and_preserves_shell_tools_and_neigh let identity = fixtures.identity(port); let mut tree = CodexInstanceProcessTree::capture(&identity).unwrap(); assert_eq!(tree.processes.len(), 4); - assert!(tree.cleanup_after_exit().is_err()); - tree = CodexInstanceProcessTree::capture(&identity).unwrap(); let owned: Vec<_> = ["root", "renderer", "gpu", "appserver"] .iter() .map(|mode| fixtures.process(mode)) @@ -482,73 +476,14 @@ async fn ready_tracking_cleans_owned_orphans_and_preserves_shell_tools_and_neigh .iter() .map(|mode| fixtures.process(mode)) .collect(); - assert!(codex_instance_needs_worker(&identity).await.unwrap()); owned[0].terminate().unwrap(); wait_for_exit(&owned[0]); - assert!(codex_instance_process_exited(&identity)); - assert!(!codex_instance_needs_worker(&identity).await.unwrap()); assert!(owned[1].is_alive().unwrap()); - tree.refresh().unwrap(); - tree.cleanup_after_exit().unwrap(); + tree.stop_and_wait().unwrap(); assert!(owned.iter().all(|p| !p.is_alive().unwrap())); assert!(preserved.iter().all(|p| p.is_alive().unwrap())); } -#[tokio::test] -async fn handoff_checkpoint_preserves_orphans_across_two_worker_replacements() { - let _fixture_lock = FIXTURE_LOCK.lock().await; - let port = unused_port(); - let fixtures = FixtureGroup::start(port); - let identity = fixtures.identity(port); - let mut original = CodexInstanceProcessTree::capture(&identity).unwrap(); - let renderer = fixtures.process("renderer"); - let gpu = fixtures.process("gpu"); - let preserved: Vec<_> = ["shell", "independent", "user-tool", "neighbor"] - .iter() - .map(|mode| fixtures.process(mode)) - .collect(); - renderer.terminate().unwrap(); - wait_for_exit(&renderer); - assert!(gpu.is_alive().unwrap()); - let checkpoint = fixtures.dir.path().join("handoff.json"); - original.save_handoff_checkpoint(&checkpoint).unwrap(); - drop(original); - for _ in 0..2 { - let mut resumed = CodexInstanceProcessTree::capture(&identity).unwrap(); - assert!( - !resumed - .processes - .iter() - .any(|(p, _)| p.process_id == gpu.process_id) - ); - resumed.restore_handoff_checkpoint(&checkpoint).unwrap(); - assert!( - resumed - .processes - .iter() - .any(|(p, _)| p.process_id == gpu.process_id) - ); - resumed.save_handoff_checkpoint(&checkpoint).unwrap(); - } - let mut final_worker = CodexInstanceProcessTree::capture(&identity).unwrap(); - final_worker - .restore_handoff_checkpoint(&checkpoint) - .unwrap(); - let mut wrong: TreeCheckpoint = - serde_json::from_slice(&std::fs::read(&checkpoint).unwrap()).unwrap(); - wrong.identity.generation += 1; - std::fs::write(&checkpoint, serde_json::to_vec(&wrong).unwrap()).unwrap(); - assert!( - final_worker - .restore_handoff_checkpoint(&checkpoint) - .is_err() - ); - assert!(gpu.is_alive().unwrap()); - final_worker.stop_and_wait().unwrap(); - assert!(!gpu.is_alive().unwrap()); - assert!(preserved.iter().all(|p| p.is_alive().unwrap())); -} - #[tokio::test] async fn wrong_birth_image_arguments_and_browser_conflict_never_stop_the_root() { let _fixture_lock = FIXTURE_LOCK.lock().await; @@ -566,12 +501,10 @@ async fn wrong_birth_image_arguments_and_browser_conflict_never_stop_the_root() wrong = identity.clone(); wrong.executable = fixtures.dir.path().join("resources/codex.exe"); assert!(terminate_verified_codex_instance(&wrong).await.is_err()); - assert!(codex_instance_needs_worker(&wrong).await.is_err()); assert!(root.is_alive().unwrap()); wrong = identity.clone(); wrong.user_data_dir = fixtures.dir.path().canonicalize().unwrap(); assert!(terminate_verified_codex_instance(&wrong).await.is_err()); - assert!(codex_instance_needs_worker(&wrong).await.is_err()); assert!(root.is_alive().unwrap()); wrong = identity.clone(); wrong.debug_port = if port == 65535 { port - 1 } else { port + 1 }; @@ -585,8 +518,6 @@ async fn wrong_birth_image_arguments_and_browser_conflict_never_stop_the_root() .mount(&server) .await; assert!(terminate_verified_codex_instance(&identity).await.is_err()); - assert!(codex_instance_needs_worker(&identity).await.is_err()); - assert!(!codex_instance_process_exited(&identity)); assert!(root.is_alive().unwrap()); drop(server); let deadline = Instant::now() + Duration::from_secs(2); @@ -602,13 +533,11 @@ async fn wrong_birth_image_arguments_and_browser_conflict_never_stop_the_root() ); tokio::time::sleep(Duration::from_millis(10)).await; } - assert!(codex_instance_needs_worker(&identity).await.unwrap()); let preserved: Vec<_> = ["shell", "independent", "user-tool", "neighbor"] .iter() .map(|mode| fixtures.process(mode)) .collect(); assert!(terminate_verified_codex_instance(&identity).await.unwrap()); - assert!(codex_instance_process_exited(&identity)); assert!(preserved.iter().all(|p| p.is_alive().unwrap())); } @@ -649,29 +578,8 @@ async fn default_main_without_user_data_can_be_captured_and_stopped_by_saved_ide // CDP 已断连,仍可从保存的同代身份捕获停止树。 let tree = CodexInstanceProcessTree::capture(&identity).unwrap(); assert_eq!(tree.processes.len(), 1); - assert!(codex_instance_needs_worker(&identity).await.unwrap()); let mut wrong = identity.clone(); wrong.user_data_dir = dir.path().canonicalize().unwrap(); assert!(terminate_verified_codex_instance(&wrong).await.is_err()); assert!(terminate_verified_codex_instance(&identity).await.unwrap()); - assert!(codex_instance_process_exited(&identity)); -} - -#[tokio::test] -async fn read_only_worker_probe_reports_conflicts_and_only_confirms_same_generation_exit() { - let _fixture_lock = FIXTURE_LOCK.lock().await; - let fixtures = FixtureGroup::start(unused_port()); - let root = fixtures.process("root"); - let options = fixtures.dir.path().join("worker-options.json"); - std::fs::write(&options, "{}").unwrap(); - assert!(verified_worker_is_running(root.process_id, &options, None).is_err()); - assert!( - verified_worker_is_running(root.process_id, &options, Some(root.birth_id + 1)).is_err() - ); - // fixture 的 argv 不是固定的三段 worker argv,存活查询必须报冲突且不结束它。 - assert!(verified_worker_is_running(root.process_id, &options, Some(root.birth_id)).is_err()); - assert!(root.is_alive().unwrap()); - root.terminate().unwrap(); - wait_for_exit(&root); - assert!(!verified_worker_is_running(root.process_id, &options, Some(root.birth_id)).unwrap()); } diff --git a/crates/codex-plus-core/src/windows_integration.rs b/crates/codex-plus-core/src/windows_integration.rs index 785936b353..91ada49813 100644 --- a/crates/codex-plus-core/src/windows_integration.rs +++ b/crates/codex-plus-core/src/windows_integration.rs @@ -88,18 +88,6 @@ pub fn close_process_tree(process_id: u32, force: bool) -> bool { #[cfg(windows)] pub const CREATE_NO_WINDOW: u32 = 0x08000000; -#[cfg(windows)] -pub(crate) fn prevent_socket_inheritance( - socket: std::os::windows::io::RawSocket, -) -> anyhow::Result<()> { - use windows::Win32::Foundation::{HANDLE_FLAG_INHERIT, HANDLE_FLAGS, SetHandleInformation}; - // SAFETY: 调用方在整个操作期间持有 socket;仅修改继承位,不关闭或转移句柄。 - unsafe { - SetHandleInformation(HANDLE(socket as *mut _), HANDLE_FLAG_INHERIT.0, HANDLE_FLAGS(0)) - } - .context("无法禁止 helper socket 被子进程继承") -} - /// 当前进程是否以提权(管理员 / 高完整性令牌)运行。 /// /// 启动器用它守卫 MSIX 打包应用激活:Windows 不允许提权进程激活打包应用, @@ -504,8 +492,7 @@ pub(crate) fn terminate_verified_multi_worker( options_path: &std::path::Path, expected_birth: Option, ) -> anyhow::Result { - let Some(process) = - capture_verified_multi_worker(process_id, options_path, expected_birth, true)? + let Some(process) = capture_verified_multi_worker(process_id, options_path, expected_birth)? else { return Ok(false); }; @@ -518,27 +505,16 @@ pub(crate) fn terminate_verified_multi_worker( Ok(true) } -#[cfg(windows)] -pub(crate) fn verified_multi_worker_is_running( - process_id: u32, - options_path: &std::path::Path, - expected_birth: Option, -) -> anyhow::Result { - // 只授予 QUERY/VM_READ/SYNCHRONIZE,不为存活检查请求终止权限。 - Ok(capture_verified_multi_worker(process_id, options_path, expected_birth, false)?.is_some()) -} - #[cfg(windows)] fn capture_verified_multi_worker( process_id: u32, options_path: &std::path::Path, expected_birth: Option, - terminate: bool, ) -> anyhow::Result> { let expected = expected_birth .filter(|birth| *birth != 0) .ok_or_else(|| anyhow::anyhow!("缺少可信 worker 创建身份"))?; - let Some(process) = VerifiedStopProcess::capture(process_id, terminate)? else { + let Some(process) = VerifiedStopProcess::capture(process_id, true)? else { return Ok(None); }; anyhow::ensure!(process.birth_id == expected, "worker 创建身份冲突"); @@ -716,34 +692,6 @@ impl VerifiedStopProcess { } } -#[cfg(windows)] -pub(crate) fn process_generation_exited(pid: u32, expected_birth: u64) -> anyhow::Result { - use windows::Win32::Foundation::{WAIT_OBJECT_0, WAIT_TIMEOUT}; - anyhow::ensure!(pid != 0 && expected_birth != 0, "进程创建身份不完整"); - let native = match unsafe { - OpenProcess( - PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE, - false, - pid, - ) - } { - Ok(handle) => handle, - Err(error) if error.code().0 as u32 == 0x80070057 => return Ok(true), - Err(error) => return Err(error).context("无法确认原代进程是否退出"), - }; - let _guard = HandleGuard(native); - let birth = process_birth_id_from_handle(native) - .ok_or_else(|| anyhow::anyhow!("无法读取原代进程创建身份"))?; - if birth != expected_birth { - return Ok(true); - } - match unsafe { WaitForSingleObject(native, 0) } { - WAIT_OBJECT_0 => Ok(true), - WAIT_TIMEOUT => Ok(false), - _ => Err(std::io::Error::last_os_error()).context("无法查询原代进程句柄"), - } -} - /// 为安全树提供有界且可报告失败的快照;不为无关系统进程逐一打开句柄。 #[cfg(windows)] pub(crate) fn bounded_process_snapshot(limit: usize) -> anyhow::Result> { From 835fbdd1b3b42f35d1aa39008061d533819c20f7 Mon Sep 17 00:00:00 2001 From: hcw <1416522360@qq.com> Date: Thu, 29 Oct 2026 21:42:27 +0800 Subject: [PATCH 5/8] =?UTF-8?q?=E6=A0=B8=E9=AA=8C=E5=81=9C=E6=AD=A2?= =?UTF-8?q?=E5=9B=9E=E6=89=A7=E5=B9=B6=E6=8C=81=E4=B9=85=E5=8C=96=E6=97=A7?= =?UTF-8?q?=E5=A4=9A=E5=BC=80=E5=AE=9E=E4=BE=8B=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src-tauri/src/commands.rs | 382 +++++++++++++++++- apps/codex-plus-manager/src/App.tsx | 9 +- .../src/multi-launch-model.test.ts | 9 +- .../src/multi-launch-model.ts | 4 +- crates/codex-plus-core/src/multi_worker.rs | 97 +++++ .../windows_process_tree_tests.rs | 69 +++- 6 files changed, 550 insertions(+), 20 deletions(-) diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index d553e1517c..aece319eb7 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -1560,8 +1560,7 @@ fn list_codex_multi_instances_blocking() -> CommandResult anyhow::Result { - let mut instance: CodexMultiInstance = - serde_json::from_slice(&std::fs::read(multi_instance_manifest_path(root))?)?; - if let Some(result) = load_multi_launch_result(root).filter(|result| { - result.generation != 0 - && result.generation == instance.generation - && result.instance_id == instance.instance_id - }) { + load_multi_instance_manifest_with_absence(root, stale_multi_instance_is_absent) +} + +fn load_multi_instance_manifest_with_absence( + root: &Path, + absent: impl Fn(&CodexMultiInstance) -> anyhow::Result, +) -> anyhow::Result { + let manifest_bytes = std::fs::read(multi_instance_manifest_path(root))?; + let mut instance: CodexMultiInstance = serde_json::from_slice(&manifest_bytes)?; + let result_bytes = std::fs::read(root.join(MULTI_LAUNCH_RESULT_FILE)).ok(); + let mut manifest_changed = false; + let mut stopped_receipt_confirmed = false; + if let Some(result) = result_bytes + .as_deref() + .and_then(|bytes| serde_json::from_slice::(bytes).ok()) + .filter(|result| { + result.generation != 0 + && result.generation == instance.generation + && result.instance_id == instance.instance_id + }) + { if let Some(identity) = &result.identity { let mut updated = instance.clone(); updated.debug_port = result.debug_port.unwrap_or(0); updated.helper_port = result.helper_port.unwrap_or(0); - if multi_manifest_identity_matches(&updated, identity) { + if multi_manifest_identity_matches(&updated, identity) + && instance + .identity + .as_ref() + .is_none_or(|saved| saved == identity) + { updated.identity = Some(identity.clone()); updated.launch_result = Some(result.clone()); // 同一代启动回执才可更新清单;读列表不会把旧结果提升为新实例。 @@ -1808,16 +1826,114 @@ fn load_multi_instance_manifest(root: &Path) -> anyhow::Result bool { + instance.generation != 0 + && !instance.is_main + && result.instance_id == instance.instance_id + && result.generation == instance.generation + && result.status == "stopped" + && result.debug_port == Some(instance.debug_port) + && result.helper_port == Some(instance.helper_port) + && match instance.identity.as_ref() { + Some(identity) => { + identity.process_id != 0 + && identity.process_birth_id != 0 + && !identity.browser_id.is_empty() + && multi_manifest_identity_matches(instance, identity) + && result.identity.as_ref() == Some(identity) + && result.pid == Some(identity.process_id) + } + None => false, + } +} + +fn stale_multi_instance_is_absent(instance: &CodexMultiInstance) -> anyhow::Result { + #[cfg(windows)] + { + use codex_plus_core::multi_worker::{ + codex_process_generation_exited, codex_user_data_is_absent, + }; + anyhow::ensure!( + !instance.is_main && instance.instance_id != "main" && instance.generation != 0, + "多开身份无效" + ); + let Some(pid) = instance.pid else { + return Ok(instance.status == "stopped"); + }; + if !codex_process_generation_exited(pid, instance.worker_birth_id)? { + return Ok(false); + } + if let Some(identity) = instance.identity.as_ref() { + anyhow::ensure!( + multi_manifest_identity_matches(instance, identity), + "多开清单身份不一致" + ); + if !codex_process_generation_exited( + identity.process_id, + Some(identity.process_birth_id), + )? { + return Ok(false); + } + } + let app = instance + .app_path + .as_deref() + .ok_or_else(|| anyhow::anyhow!("多开清单缺少程序路径"))?; + let executable = codex_plus_core::app_paths::build_codex_executable(Path::new(app)); + let user_data = instance + .user_data_dir + .as_deref() + .ok_or_else(|| anyhow::anyhow!("多开清单缺少数据目录"))?; + codex_user_data_is_absent( + &executable, + Path::new(user_data), + instance.debug_port, + instance.helper_port, + ) + } + #[cfg(not(windows))] + { + let _ = instance; + Ok(false) + } +} + fn multi_instance_is_running(instance: &CodexMultiInstance) -> bool { let Some(identity) = instance .identity @@ -1832,6 +1948,47 @@ fn multi_instance_is_running(instance: &CodexMultiInstance) -> bool { .unwrap_or(false) } +fn multi_instance_snapshot_status(instance: &CodexMultiInstance, running: bool) -> &'static str { + if running { + return multi_instance_status(true, instance.launch_result.as_ref()); + } + if instance + .launch_result + .as_ref() + .is_some_and(|result| result.status == "cleanup_failed") + { + return "unknown"; + } + if instance.status != "stopped" + && instance + .launch_result + .as_ref() + .is_some_and(|result| result.status == "stopped") + { + return "unknown"; + } + if let Some(identity) = instance.identity.as_ref() { + if !multi_manifest_identity_matches(instance, identity) + || !instance + .launch_result + .as_ref() + .is_some_and(|result| multi_instance_stopped_receipt_matches(instance, result)) + { + return "unknown"; + } + #[cfg(windows)] + if !codex_plus_core::multi_worker::codex_process_generation_exited( + identity.process_id, + Some(identity.process_birth_id), + ) + .unwrap_or(false) + { + return "unknown"; + } + } + multi_instance_status(false, instance.launch_result.as_ref()) +} + /// 列表里显示的状态:能跑就是运行中;跑不起来且 worker 明确报了失败才是失败, /// 否则只是没在运行(例如用户自己关掉了窗口)。 fn multi_instance_status(running: bool, result: Option<&CodexMultiLaunchResult>) -> &'static str { @@ -12198,6 +12355,209 @@ model_reasoning_effort = "high" assert!(remained_alive); } + fn stopped_multi_instance_fixture(root: &Path) -> CodexMultiInstance { + let data = root.join("user-data"); + let app = root.join("Codex.app"); + std::fs::create_dir_all(&data).unwrap(); + std::fs::create_dir_all(app.join("Contents/MacOS")).unwrap(); + std::fs::write(app.join("Contents/MacOS/Codex"), "fixture").unwrap(); + std::fs::write(app.join("Codex.exe"), "fixture").unwrap(); + let identity = codex_plus_core::multi_worker::CodexInstanceIdentity { + instance_id: "fixture-stopped".into(), + generation: 7, + debug_port: 19401, + helper_port: 19402, + user_data_dir: data.canonicalize().unwrap(), + browser_id: "browser-stopped".into(), + process_id: 123, + process_birth_id: 456, + executable: codex_plus_core::app_paths::build_codex_executable(&app) + .canonicalize() + .unwrap(), + }; + let result = CodexMultiLaunchResult { + instance_id: identity.instance_id.clone(), + generation: identity.generation, + helper_port: Some(identity.helper_port), + identity: Some(identity.clone()), + status: "stopped".into(), + message: "fixture stopped".into(), + finished_at_ms: 8, + debug_port: Some(identity.debug_port), + pid: Some(identity.process_id), + }; + let instance = CodexMultiInstance { + instance_id: identity.instance_id.clone(), + generation: identity.generation, + identity: Some(identity), + profile_id: "fixture".into(), + profile_name: "Fixture".into(), + pid: Some(124), + worker_birth_id: Some(457), + debug_port: 19401, + helper_port: 19402, + status: "starting".into(), + running: true, + started_at_ms: 7, + is_main: false, + app_path: Some(app.to_string_lossy().into_owned()), + codex_home: None, + sqlite_home: None, + user_data_dir: Some(data.to_string_lossy().into_owned()), + launch_result: Some(result.clone()), + }; + save_multi_instance_manifest(root, &instance).unwrap(); + write_multi_launch_result(&root.join(MULTI_LAUNCH_RESULT_FILE), &result).unwrap(); + instance + } + + #[test] + fn stopped_receipt_reconciliation_persists_and_is_idempotent() { + let temp = tempfile::tempdir().unwrap(); + let original = stopped_multi_instance_fixture(temp.path()); + let probes = std::cell::Cell::new(0); + let current = load_multi_instance_manifest_with_absence(temp.path(), |instance| { + assert_eq!(instance.pid, original.pid); + probes.set(probes.get() + 1); + Ok(true) + }) + .unwrap(); + assert_eq!(probes.get(), 2); + assert_eq!(current.status, "stopped"); + assert!(!current.running); + assert_eq!(current.pid, None); + assert_eq!(current.worker_birth_id, None); + assert_eq!(current.identity, original.identity); + assert_eq!( + current.launch_result.as_ref().unwrap().message, + "fixture stopped" + ); + let persisted: CodexMultiInstance = + serde_json::from_slice(&std::fs::read(multi_instance_manifest_path(temp.path())).unwrap()) + .unwrap(); + assert_eq!(persisted.status, "stopped"); + assert_eq!(persisted.pid, None); + let again = + load_multi_instance_manifest_with_absence(temp.path(), |_| panic!("already synchronized")) + .unwrap(); + assert_eq!(again.status, "stopped"); + assert_eq!(multi_instance_snapshot_status(&again, false), "stopped"); + } + + #[test] + fn stopped_receipt_reconciliation_keeps_uncertain_or_changed_instances() { + for failed_probe in [0, 1, 2] { + let temp = tempfile::tempdir().unwrap(); + stopped_multi_instance_fixture(temp.path()); + let original = std::fs::read(multi_instance_manifest_path(temp.path())).unwrap(); + let probes = std::cell::Cell::new(0); + let current = load_multi_instance_manifest_with_absence(temp.path(), |_| { + probes.set(probes.get() + 1); + if failed_probe == 0 { + anyhow::bail!("query unavailable"); + } + Ok(probes.get() != failed_probe) + }) + .unwrap(); + assert_eq!(current.status, "starting"); + assert_eq!(current.pid, Some(124)); + assert_eq!(multi_instance_snapshot_status(¤t, false), "unknown"); + assert_eq!( + std::fs::read(multi_instance_manifest_path(temp.path())).unwrap(), + original + ); + } + } + + #[test] + fn stopped_receipt_reconciliation_rejects_mismatched_or_incomplete_evidence() { + let temp = tempfile::tempdir().unwrap(); + let original = stopped_multi_instance_fixture(temp.path()); + let receipt = original.launch_result.clone().unwrap(); + assert!(multi_instance_stopped_receipt_matches(&original, &receipt)); + let mut variants = Vec::new(); + let mut result = receipt.clone(); + result.generation += 1; + variants.push(result); + let mut result = receipt.clone(); + result.instance_id = "neighbor".into(); + variants.push(result); + let mut result = receipt.clone(); + result.status = "cleanup_failed".into(); + variants.push(result); + let mut result = receipt.clone(); + result.debug_port = Some(19403); + variants.push(result); + let mut result = receipt.clone(); + result.helper_port = None; + variants.push(result); + let mut result = receipt.clone(); + result.pid = None; + variants.push(result); + let mut result = receipt.clone(); + result.identity = None; + variants.push(result); + let mut result = receipt.clone(); + result.identity.as_mut().unwrap().process_birth_id += 1; + variants.push(result); + let mut result = receipt; + result.identity.as_mut().unwrap().user_data_dir = temp.path().into(); + variants.push(result); + for result in variants { + assert!(!multi_instance_stopped_receipt_matches(&original, &result)); + write_multi_launch_result(&temp.path().join(MULTI_LAUNCH_RESULT_FILE), &result).unwrap(); + let current = load_multi_instance_manifest_with_absence(temp.path(), |_| { + panic!("must not probe mismatched receipt") + }) + .unwrap(); + assert_eq!(current.status, "starting"); + assert_eq!(current.pid, original.pid); + } + } + + #[test] + fn stopped_receipt_reconciliation_does_not_overwrite_replaced_files() { + for replace_receipt in [false, true] { + let temp = tempfile::tempdir().unwrap(); + let original = stopped_multi_instance_fixture(temp.path()); + let probes = std::cell::Cell::new(0); + let loaded = load_multi_instance_manifest_with_absence(temp.path(), |_| { + probes.set(probes.get() + 1); + if probes.get() == 2 { + if replace_receipt { + let mut result = original.launch_result.clone().unwrap(); + result.generation += 1; + write_multi_launch_result( + &temp.path().join(MULTI_LAUNCH_RESULT_FILE), + &result, + )?; + } else { + let mut next = original.clone(); + next.generation += 1; + save_multi_instance_manifest(temp.path(), &next)?; + } + } + Ok(true) + }); + assert!(loaded.is_err()); + let saved: CodexMultiInstance = serde_json::from_slice( + &std::fs::read(multi_instance_manifest_path(temp.path())).unwrap(), + ) + .unwrap(); + assert_eq!(saved.status, "starting"); + assert_eq!(saved.pid, original.pid); + assert_eq!( + saved.generation, + original.generation + u64::from(!replace_receipt) + ); + let result = load_multi_launch_result(temp.path()).unwrap(); + assert_eq!( + result.generation, + original.generation + u64::from(replace_receipt) + ); + } + } + // 多开在 Windows 上曾经「窗口没出现、列表却显示运行中」:现在状态由 // worker 落盘的启动结果 + 端口判活共同决定,两者都要有测试守住。 #[test] diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index 84d4ef258f..449cc51f36 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -2771,7 +2771,7 @@ export function App() { // 界面要给出「启动失败 + 原因」,只留运行中的会让它彻底消失。 setMultiLaunchInstances( Array.isArray(result.instances) - ? result.instances.filter((instance) => multiLaunchInstanceRunning(instance) || Boolean(multiLaunchInstanceFailure(instance))) + ? result.instances.filter((instance) => multiLaunchInstanceStatus(instance) !== "stopped") : [], ); if (!silent && !isSuccessStatus(result.status)) showNotice(t("当前运行实例"), result.message, result.status); @@ -7446,6 +7446,7 @@ function CodexMultiLaunchScreen({ {actions.multiLaunchInstances.map((instance) => { const status = multiLaunchInstanceStatus(instance); const running = multiLaunchInstanceRunning(instance); + const canStop = running || status === "unknown" || status === "failed"; const pending = actions.multiLaunchActionPending === instance.instanceId; const label = formatMultiLaunchInstanceLabel(instance, { main: t("主 Codex"), fallback: t("Codex 实例") }); // 启动失败的原因必须让用户看得见:Windows 上多开曾经完全不吭声。 @@ -7455,7 +7456,7 @@ function CodexMultiLaunchScreen({ typeof instance.pid === "number" ? `PID ${instance.pid}` : "", typeof instance.debugPort === "number" ? `CDP ${instance.debugPort}` : "", typeof instance.helperPort === "number" ? `API ${instance.helperPort}` : "", - failure ? `${t("启动失败")}:${failure}` : "", + failure ? `${instance.launchResult?.status === "cleanup_failed" ? t("失败") : t("启动失败")}:${failure}` : "", ].filter(Boolean).join(" · "); return (
@@ -7476,10 +7477,10 @@ function CodexMultiLaunchScreen({ {pending ? t("处理中") : t("重启")}