From c2d81c3c1d597a08dc441ba3c9509897d507a5f5 Mon Sep 17 00:00:00 2001 From: hcw <1416522360@qq.com> Date: Wed, 28 Oct 2026 19:43:03 +0800 Subject: [PATCH 1/3] =?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 a17e3ce7e0..b3e8898b32 100644 --- a/crates/codex-plus-core/src/lib.rs +++ b/crates/codex-plus-core/src/lib.rs @@ -98,6 +98,11 @@ pub mod work_view; // 门控在模块级会导致 `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 f5fd08e61fd67dd942c80bba72d3764f06fd751a Mon Sep 17 00:00:00 2001 From: hcw <1416522360@qq.com> Date: Wed, 28 Oct 2026 20:18:53 +0800 Subject: [PATCH 2/3] =?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 a82153baae293a66f1f80d84ca18dd31b9afe08e Mon Sep 17 00:00:00 2001 From: hcw <1416522360@qq.com> Date: Wed, 28 Oct 2026 19:44:06 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E6=94=AF=E6=8C=81=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=99=A8=E6=9B=B4=E6=96=B0=E7=BB=AD=E6=8E=A5=E4=B8=8E=E5=8F=97?= =?UTF-8?q?=E4=BF=9D=E6=8A=A4=E7=9A=84=E6=A1=8C=E9=9D=A2=E5=90=AF=E5=81=9C?= =?UTF-8?q?=E9=87=8D=E5=90=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/codex-plus-launcher/src/main.rs | 87 +- .../src-tauri/src/commands.rs | 1502 +++++++++++++-- .../src/commands/multi_instance_resume.rs | 1679 +++++++++++++++++ apps/codex-plus-manager/src-tauri/src/lib.rs | 7 +- .../src-tauri/src/runtime_health.rs | 12 +- apps/codex-plus-manager/src/App.tsx | 20 +- .../src/auto-takeover.test.ts | 8 +- .../src/multi-launch-model.test.ts | 15 +- .../src/multi-launch-model.ts | 5 +- crates/codex-plus-core/src/http_client.rs | 8 + crates/codex-plus-core/src/launcher.rs | 186 +- crates/codex-plus-core/src/packaged_launch.rs | 71 +- crates/codex-plus-core/tests/launcher.rs | 23 +- 13 files changed, 3387 insertions(+), 236 deletions(-) create mode 100644 apps/codex-plus-manager/src-tauri/src/commands/multi_instance_resume.rs diff --git a/apps/codex-plus-launcher/src/main.rs b/apps/codex-plus-launcher/src/main.rs index 8d6eb6afab..55a0a353a8 100644 --- a/apps/codex-plus-launcher/src/main.rs +++ b/apps/codex-plus-launcher/src/main.rs @@ -350,6 +350,10 @@ pub struct LaunchReceipt { /// 与 start 相同,但只从已就绪的本进程会话返回实际端口,不读取旧状态文件猜测。 pub fn start_with_receipt(options: LaunchOptions) -> Result { start(options)?; + ready_session_receipt() +} + +fn ready_session_receipt() -> Result { let probe = session_probe_slot() .lock() .unwrap_or_else(|error| error.into_inner()); @@ -372,6 +376,14 @@ pub fn resume_if_running(options: LaunchOptions) -> Result { Ok(true) } +/// 仅 attach 已有桌面,并返回本进程已就绪会话的实际端口。 +pub fn resume_with_receipt(options: LaunchOptions) -> Result> { + if !resume_if_running(options)? { + return Ok(None); + } + ready_session_receipt().map(Some) +} + /// 释放当前 session 的 helper、watchdog 和浏览器兼容状态,保留官方 Codex。 pub fn stop() -> Result<()> { send_runtime_command(RuntimeCommand::Stop) @@ -761,10 +773,38 @@ async fn activate_and_write_running_status( options: &LaunchOptions, started_at_ms: u64, ) -> Result<()> { + let latest = options.status_store.load_latest()?; activate_existing_codex_app(options).await?; - options.status_store.save_latest(&LaunchStatus { - status: "running".to_string(), - message: "Existing Codex instance activated".to_string(), + options.status_store.save_latest(&activated_launch_status( + options, + started_at_ms, + latest.as_ref(), + )) +} + +fn activated_launch_status( + options: &LaunchOptions, + started_at_ms: u64, + latest: Option<&LaunchStatus>, +) -> LaunchStatus { + // 激活窗口没有重新验证增强,不能把同代会话的降级状态升级为成功。 + let degraded = latest.is_some_and(|latest| { + latest.started_at_ms == started_at_ms + && latest.debug_port == Some(options.debug_port) + && latest.status == "running_degraded" + }); + LaunchStatus { + status: if degraded { + "running_degraded" + } else { + "running" + } + .to_string(), + message: if degraded { + latest.unwrap().message.clone() + } else { + "Existing Codex instance activated".to_string() + }, started_at_ms, debug_port: Some(options.debug_port), helper_port: Some(options.helper_port), @@ -774,7 +814,7 @@ async fn activate_and_write_running_status( .map(|path| path.to_string_lossy().to_string()), aumid: None, ..LaunchStatus::default() - }) + } } async fn wait_for_session_exit_or_stop( @@ -988,7 +1028,13 @@ async fn activate_existing_codex_app(options: &LaunchOptions) -> anyhow::Result< json!({"message": error.to_string(), "phase": "activate_existing"}), ); } - let process_ids = codex_plus_core::watcher::find_codex_processes(); + // 仅激活本次启动返回的目标,不按全局同名进程选择其他窗口。 + let process_ids: Vec = launch_result + .as_ref() + .ok() + .and_then(|launch| launch.process_id()) + .into_iter() + .collect(); #[cfg(windows)] let activated = process_ids .iter() @@ -1303,6 +1349,9 @@ impl LaunchHooks for LauncherHooks { &self, settings: &codex_plus_core::settings::BackendSettings, ) -> anyhow::Result<()> { + if self.attach_only { + return Ok(()); + } self.core .ensure_active_protocol_proxy_config(settings) .await @@ -2137,11 +2186,10 @@ mod tests { + start; let body = &source[start..end]; let multi_guard = body.find("if multi_instance_mode()").unwrap(); - let activate = body.find("activate_and_write_running_status(&options").unwrap(); - assert!( - multi_guard < activate, - "多开判断必须排在激活已有实例之前" - ); + let activate = body + .find("activate_and_write_running_status(&options") + .unwrap(); + assert!(multi_guard < activate, "多开判断必须排在激活已有实例之前"); assert!(body[multi_guard..activate].contains("anyhow::bail!")); } @@ -2775,6 +2823,25 @@ mod tests { hooks.apply_native_fast_unlock_on_launch(0).await.unwrap(); } + #[test] + fn activating_same_generation_keeps_degraded_status() { + let options = LaunchOptions::default(); + let previous = LaunchStatus { + status: "running_degraded".into(), + message: "injection unavailable".into(), + started_at_ms: 7, + debug_port: Some(options.debug_port), + ..LaunchStatus::default() + }; + let activated = activated_launch_status(&options, 7, Some(&previous)); + assert_eq!(activated.status, "running_degraded"); + assert_eq!(activated.message, previous.message); + assert_eq!( + activated_launch_status(&options, 8, Some(&previous)).status, + "running" + ); + } + #[tokio::test] async fn watchdog_reuses_bridge_context_with_data_service() { let test_dir = std::env::temp_dir().join(format!( diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index 9d04c5632f..afb61568df 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -20,6 +20,8 @@ use tauri::Emitter; use crate::install::{self, InstallActionResult}; +pub(crate) mod multi_instance_resume; + // 扫描快照仅驻留后端内存,不接受前端指定磁盘路径,不开放给扩展路由。 static AGENT_CACHE_PLAN: OnceLock>> = OnceLock::new(); @@ -678,6 +680,10 @@ pub struct CodexMultiWorkerOptions { /// 显式路径为准(同时仍设置 `CODEX_PLUS_STATE_DIR` 覆盖子进程)。 #[serde(default)] pub state_dir: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resume_identity: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resume_token: Option, } /// 一个已经由「Codex 多开」创建过的实例。记录文件放在实例自己的隔离目录, @@ -749,6 +755,8 @@ pub struct CodexMultiInstancesPayload { #[serde(rename_all = "camelCase")] pub struct CodexMultiInstanceRequest { pub instance_id: String, + #[serde(default)] + pub generation: Option, } #[derive(Debug, Clone, serde::Deserialize)] @@ -1001,6 +1009,12 @@ pub async fn load_overview() -> CommandResult { #[tauri::command] pub async fn launch_codex_plus(request: LaunchRequest) -> CommandResult { match tauri::async_runtime::spawn_blocking(move || { + if let Err(error) = prepare_main_launch_options(Default::default()) { + return failed( + &format!("显式启动目录无效,未执行启动:{error:#}"), + json!({}), + ); + } let _guard = match try_acquire_launch_guard() { Ok(guard) => guard, Err(message) => return failed(message, json!({})), @@ -1099,6 +1113,17 @@ fn launch_codex_multi_instance_blocking(request: CodexMultiLaunchRequest) -> Com json!({"instanceId": instance_id}), ); } + if previous.as_ref().is_some_and(|instance| { + instance.identity.as_ref().is_some_and(|identity| { + multi_manifest_identity_matches(instance, identity) + && !codex_plus_core::multi_worker::codex_instance_process_exited(identity) + }) && !multi_instance_is_running(instance) + }) { + return failed( + "原 Codex 进程尚未确认退出,但连接暂不可用。请先结束或重启该实例,未创建重复窗口。", + json!({"instanceId": instance_id}), + ); + } if previous.as_ref().is_some_and(|previous| { !multi_instance_is_running(previous) && previous.pid.is_some_and(|pid| { @@ -1331,6 +1356,8 @@ fn launch_codex_multi_instance_blocking(request: CodexMultiLaunchRequest) -> Com debug_port, helper_port, state_dir: worker_state_dir.to_string_lossy().to_string(), + resume_identity: None, + resume_token: None, }; let worker_options_bytes = match serde_json::to_vec_pretty(&worker_options) { Ok(bytes) => bytes, @@ -1483,10 +1510,9 @@ fn list_codex_multi_instances_blocking() -> CommandResult CommandResult CommandResult CommandResult { match tauri::async_runtime::spawn_blocking(move || { - stop_codex_multi_instance_blocking(&request.instance_id) + stop_codex_multi_instance_blocking(&request.instance_id, request.generation) }) .await { @@ -1571,6 +1602,7 @@ pub async fn stop_codex_multi_instance( fn stop_codex_multi_instance_blocking( instance_id: &str, + expected_generation: Option, ) -> CommandResult { let operation = multi_instance_mutation_lock() .lock() @@ -1600,6 +1632,14 @@ fn stop_codex_multi_instance_blocking( }, ); } + if expected_generation.is_some_and(|generation| generation != instance.generation) { + return failed( + "实例已重新启动,请刷新列表后操作;未结束新一代进程。", + CodexMultiInstancesPayload { + instances: Vec::new(), + }, + ); + } let Some(identity) = instance .identity .as_ref() @@ -1612,24 +1652,12 @@ fn stop_codex_multi_instance_blocking( }, ); }; - if !multi_instance_is_running(&instance) - && !matches!( - codex_plus_core::watcher::inspect_process_instance(identity.process_id), - codex_plus_core::watcher::ProcessInstanceState::NotRunning - ) - { - return failed( - "无法重新确认该实例的完整身份,已拒绝结束进程。", - CodexMultiInstancesPayload { - instances: Vec::new(), - }, - ); - } - if multi_instance_is_running(&instance) { + let mut cleaned_by_manager = false; + if !codex_plus_core::multi_worker::codex_instance_process_exited(identity) { match tauri::async_runtime::block_on( codex_plus_core::multi_worker::terminate_verified_codex_instance(identity), ) { - Ok(true) => {} + Ok(true) => cleaned_by_manager = true, Ok(false) => { return failed( "实例身份在停止前已改变,未结束任何进程。", @@ -1640,7 +1668,7 @@ fn stop_codex_multi_instance_blocking( } Err(error) => { return failed( - &format!("安全停止实例失败:{error}"), + &format!("安全停止实例失败:{error:#}"), CodexMultiInstancesPayload { instances: Vec::new(), }, @@ -1648,10 +1676,12 @@ fn stop_codex_multi_instance_blocking( } } let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); - while multi_instance_is_running(&instance) && std::time::Instant::now() < deadline { + while !codex_plus_core::multi_worker::codex_instance_process_exited(identity) + && std::time::Instant::now() < deadline + { std::thread::sleep(std::time::Duration::from_millis(100)); } - if multi_instance_is_running(&instance) { + if !codex_plus_core::multi_worker::codex_instance_process_exited(identity) { return failed( "实例仍未退出,请稍后重试。", CodexMultiInstancesPayload { @@ -1660,10 +1690,63 @@ fn stop_codex_multi_instance_blocking( ); } } - // 保留该代结果作为身份记录,下次启动用新 generation 排除旧结果。 - if let Some(pid) = instance.pid { - let options_path = path.join("worker-options.json"); - let _ = terminate_multi_worker(pid, &options_path, instance.worker_birth_id); + // 根退出后 worker 仍可能持有残留子进程;先等待该代清理,不能抢先结束它。 + let cleanup_deadline = std::time::Instant::now() + std::time::Duration::from_secs(12); + loop { + let confirmed = load_multi_launch_result(&path).is_some_and(|result| { + result.instance_id == instance.instance_id + && result.generation == instance.generation + && result.identity.as_ref() == Some(identity) + && result.status == "stopped" + }); + if confirmed || std::time::Instant::now() >= cleanup_deadline { + if !confirmed && !cleaned_by_manager { + return failed( + "Codex 已退出,但该代后台尚未确认子进程清理完成;请稍后重试。", + CodexMultiInstancesPayload { + instances: Vec::new(), + }, + ); + } + break; + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + if let Some(pid) = instance.pid + && let Err(error) = terminate_multi_worker( + pid, + &path.join("worker-options.json"), + instance.worker_birth_id, + ) + { + return failed( + &format!("Codex 已退出,但后台尚未确认停止:{error}"), + CodexMultiInstancesPayload { + instances: Vec::new(), + }, + ); + } + if cleaned_by_manager { + let result = CodexMultiLaunchResult { + instance_id: instance.instance_id.clone(), + generation: instance.generation, + helper_port: Some(identity.helper_port), + identity: Some(identity.clone()), + status: "stopped".into(), + message: "Codex 及其自有后台已停止。".into(), + finished_at_ms: current_timestamp_ms(), + debug_port: Some(identity.debug_port), + pid: Some(identity.process_id), + }; + if let Err(error) = write_multi_launch_result(&path.join(MULTI_LAUNCH_RESULT_FILE), &result) + { + return failed( + &format!("实例已停止,但记录清理结果失败:{error}"), + CodexMultiInstancesPayload { + instances: Vec::new(), + }, + ); + } } let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( "manager.multi_instance_stopped", @@ -1678,7 +1761,7 @@ pub async fn restart_codex_multi_instance( request: CodexMultiInstanceRequest, ) -> CommandResult { match tauri::async_runtime::spawn_blocking(move || { - restart_codex_multi_instance_blocking(&request.instance_id) + restart_codex_multi_instance_blocking(&request.instance_id, request.generation) }) .await { @@ -1687,7 +1770,10 @@ pub async fn restart_codex_multi_instance( } } -fn restart_codex_multi_instance_blocking(instance_id: &str) -> CommandResult { +fn restart_codex_multi_instance_blocking( + instance_id: &str, + expected_generation: Option, +) -> CommandResult { if instance_id.trim() == "main" { return failed( "主进程请使用顶部的重启 Codex。", @@ -1701,7 +1787,13 @@ fn restart_codex_multi_instance_blocking(instance_id: &str) -> CommandResult 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 let Some(identity) = instance.identity.as_ref() { + if !multi_manifest_identity_matches(instance, identity) + || !codex_plus_core::multi_worker::codex_instance_process_exited(identity) + { + return "unknown"; + } + if !instance.launch_result.as_ref().is_some_and(|result| { + result.instance_id == instance.instance_id + && result.generation == instance.generation + && result.identity.as_ref() == Some(identity) + && result.status == "stopped" + }) { + return "unknown"; + } + } else if instance.pid.is_some_and(|pid| { + !matches!( + codex_plus_core::watcher::inspect_process_instance(pid), + codex_plus_core::watcher::ProcessInstanceState::NotRunning + ) + }) { + return if instance.launch_result.is_none() { + "starting" + } else { + "unknown" + }; + } + multi_instance_status(false, instance.launch_result.as_ref()) +} + /// 列表里显示的状态:能跑就是运行中;跑不起来且 worker 明确报了失败才是失败, /// 否则只是没在运行(例如用户自己关掉了窗口)。 fn multi_instance_status(running: bool, result: Option<&CodexMultiLaunchResult>) -> &'static str { @@ -1823,7 +1955,7 @@ fn terminate_multi_worker( worker_birth_id: Option, ) -> anyhow::Result<()> { if pid == std::process::id() { - return Ok(()); + anyhow::bail!("拒绝结束管理器自身"); } #[cfg(not(windows))] { @@ -1840,9 +1972,21 @@ fn terminate_multi_worker( 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" }), + let original_exited = match codex_plus_core::watcher::inspect_process_instance(pid) { + codex_plus_core::watcher::ProcessInstanceState::NotRunning => true, + codex_plus_core::watcher::ProcessInstanceState::Running { + birth_id: Some(actual), + .. + } => actual + .parse::() + .ok() + .zip(worker_birth_id) + .is_some_and(|(actual, expected)| actual != expected), + _ => false, + }; + anyhow::ensure!( + original_exited, + "无法核验原代 worker 已退出,未结束未验证的进程" ); } } @@ -1863,6 +2007,9 @@ fn terminate_multi_worker( /// 让界面显示「启动失败」而不是「运行中」。 pub fn run_codex_multi_worker(options_path: &Path) -> anyhow::Result<()> { let options: CodexMultiWorkerOptions = serde_json::from_slice(&std::fs::read(options_path)?)?; + // 更新续接必须在任何 launcher 副作用之前核验原桌面;失败绝不退回 start。 + multi_instance_resume::validate_worker_resume(&options)?; + let worker_binary = multi_instance_resume::BinaryStamp::current()?; // 状态目录以 worker 自己的 options 为准,并且**不用** `set_var` 改进程环境: // 多开 worker 复用管理器进程,启动时已是多线程,edition 2024 下 `set_var` 属未定义 @@ -1892,7 +2039,14 @@ pub fn run_codex_multi_worker(options_path: &Path) -> anyhow::Result<()> { codex_home: std::env::var_os("CODEX_HOME").map(PathBuf::from), sqlite_home: std::env::var_os("CODEX_SQLITE_HOME").map(PathBuf::from), }; - let receipt = match codex_plus_launcher::start_with_receipt(launch_options) { + let started = if options.resume_identity.is_some() { + codex_plus_launcher::resume_with_receipt(launch_options).and_then(|receipt| { + receipt.ok_or_else(|| anyhow::anyhow!("原 Codex 已不可续接,未启动新窗口")) + }) + } else { + codex_plus_launcher::start_with_receipt(launch_options) + }; + let receipt = match started { Ok(receipt) => receipt, Err(error) => { let _ = write_multi_launch_result( @@ -1912,9 +2066,12 @@ pub fn run_codex_multi_worker(options_path: &Path) -> anyhow::Result<()> { return Ok(()); } }; - let endpoint_ready = - wait_for_multi_instance_ready(receipt.debug_port, MULTI_LAUNCH_READY_TIMEOUT); - let captured = if endpoint_ready { + let endpoint_ready = options.resume_identity.is_some() + || wait_for_multi_instance_ready(receipt.debug_port, MULTI_LAUNCH_READY_TIMEOUT); + let captured = if let Some(identity) = &options.resume_identity { + multi_instance_resume::confirm_resumed_identity(identity, &receipt) + .map(|()| identity.clone()) + } else if endpoint_ready { tauri::async_runtime::block_on( codex_plus_core::multi_worker::capture_codex_instance_identity( &options.instance_id, @@ -1963,14 +2120,81 @@ pub fn run_codex_multi_worker(options_path: &Path) -> anyhow::Result<()> { let _ = codex_plus_launcher::shutdown(); return Ok(()); }; + let process_checkpoint = options_path.with_file_name("worker-process-tree.json"); + let mut process_tree = + codex_plus_core::multi_worker::CodexInstanceProcessTree::capture(&identity).ok(); + if options.resume_identity.is_some() && process_checkpoint.exists() { + if let Some(tree) = process_tree.as_mut() { + if let Err(error) = tree.restore_handoff_checkpoint(&process_checkpoint) { + let _ = codex_plus_launcher::shutdown(); + return Err(error.context("恢复原代进程树归属失败,未发布 worker 就绪")); + } + } + } + let handoff = match multi_instance_resume::publish_worker_ready( + options_path, + &options, + &identity, + worker_binary, + ) { + Ok(ready) => ready, + Err(error) => { + let shutdown = codex_plus_launcher::shutdown(); + shutdown?; + return Err(error); + } + }; while tauri::async_runtime::block_on( - codex_plus_core::multi_worker::codex_instance_identity_matches(&identity), + codex_plus_core::multi_worker::codex_instance_needs_worker(&identity), ) - .unwrap_or(false) + .unwrap_or(true) { + if multi_instance_resume::handoff_requested(options_path, &handoff, || { + process_tree.as_mut() + .ok_or_else(|| anyhow::anyhow!("未捕获完整进程树,拒绝丢弃原代归属")) + .and_then(|tree| tree.save_handoff_checkpoint(&process_checkpoint)) + }) { + // 新 worker 已接管服务和进程树归属,旧 worker 才能退出。 + return codex_plus_launcher::shutdown(); + } + if let Some(tree) = process_tree.as_mut() + && let Err(error) = tree.refresh() + { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "manager.multi_instance_tree_refresh_failed", + json!({ "instance_id": options.instance_id, "error": error.to_string() }), + ); + } std::thread::sleep(std::time::Duration::from_secs(1)); } - codex_plus_launcher::shutdown() + let cleanup = process_tree + .ok_or_else(|| anyhow::anyhow!("未捕获完整的自有桌面进程,无法确认清理完成")) + .and_then(|tree| tree.cleanup_after_exit()); + let shutdown = codex_plus_launcher::shutdown(); + let cleanup = cleanup.and(shutdown); + write_multi_launch_result( + &result_path, + &CodexMultiLaunchResult { + instance_id: options.instance_id, + generation: options.generation, + helper_port: Some(receipt.helper_port), + identity: Some(identity.clone()), + status: if cleanup.is_ok() { + "stopped" + } else { + "cleanup_failed" + } + .into(), + message: cleanup.as_ref().err().map_or_else( + || "Codex 多开实例已退出。".to_string(), + |error| format!("Codex 已退出,但清理未完成:{error:#}"), + ), + finished_at_ms: current_timestamp_ms(), + debug_port: Some(receipt.debug_port), + pid: Some(identity.process_id), + }, + )?; + cleanup } /// 多开实例启动结果文件名(与 `worker-options.json` 同目录)。 @@ -2230,8 +2454,7 @@ pub fn run_auto_takeover_launch(request: LaunchRequest) -> Result<(), String> { ..Default::default() }; // 自动接管也走常驻 helper:Codex 之后退出时本地代理仍可被外部客户端使用。 - sync_resident_helper(); - codex_plus_launcher::start(launch_options).map_err(|error| error.to_string()) + start_main_launcher(launch_options).map_err(|error| error.to_string()) } #[tauri::command] @@ -2259,34 +2482,392 @@ pub fn pause_auto_takeover_for_instance() -> CommandResult, + identity: codex_plus_core::multi_worker::CodexInstanceIdentity, +} + +fn main_identity_path() -> PathBuf { + codex_plus_core::paths::default_app_state_dir().join("main-instance-identity.json") } -fn restart_disposition(sync_active_relay: bool, app_running: bool) -> RestartDisposition { - if !sync_active_relay && !app_running { - RestartDisposition::LaunchOnly - } else { - RestartDisposition::StopAndRestart +fn main_identity_in_scope( + receipt: &MainCodexIdentity, + state_dir: &Path, + codex_home: &Path, +) -> bool { + receipt.version == 1 + && receipt.identity.instance_id == "main" + && receipt.identity.generation != 0 + && receipt.identity.process_id != 0 + && receipt.identity.process_id != std::process::id() + && receipt.identity.process_birth_id != 0 + && receipt.identity.debug_port != 0 + && receipt.identity.helper_port != 0 + && !receipt.identity.browser_id.is_empty() + && receipt.identity.executable.is_absolute() + && (receipt.identity.user_data_dir.is_absolute() + || receipt.identity.user_data_dir.as_os_str().is_empty()) + && state_dir.canonicalize().ok().as_ref() == Some(&receipt.state_dir) + && codex_home.canonicalize().ok().as_ref() == Some(&receipt.codex_home) +} + +fn load_main_identity() -> anyhow::Result> { + let bytes = match fs::read(main_identity_path()) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + decode_main_identity( + &bytes, + &codex_plus_core::paths::default_app_state_dir(), + &codex_plus_core::relay_config::default_codex_home_dir(), + ) +} + +fn decode_main_identity( + bytes: &[u8], + state_dir: &Path, + codex_home: &Path, +) -> anyhow::Result> { + let Some(receipt) = serde_json::from_slice::>(bytes)? else { + return Ok(None); + }; + anyhow::ensure!( + main_identity_in_scope(&receipt, state_dir, codex_home), + "主实例身份记录不属于当前管理器目录" + ); + Ok(Some(receipt)) +} + +/// 窗口连通性与进程存活分开判断;绝不借任意同名进程补足主实例身份。 +pub(crate) fn main_codex_running(latest: Option<&LaunchStatus>) -> Option { + let session = codex_plus_launcher::session_snapshot(); + let receipt = load_main_identity().ok()?; + main_running_with( + &session, + receipt.as_ref().map(|receipt| &receipt.identity), + |identity| { + if codex_plus_core::multi_worker::codex_instance_process_exited(identity) { + return Some(false); + } + tauri::async_runtime::block_on( + codex_plus_core::multi_worker::codex_instance_needs_worker(identity), + ) + .ok() + }, + || { + let debug_port = latest + .and_then(|status| status.debug_port) + .unwrap_or(default_debug_port()); + main_target_absent(debug_port).ok() + }, + ) +} + +fn main_running_with( + session: &codex_plus_launcher::SessionSnapshot, + identity: Option<&codex_plus_core::multi_worker::CodexInstanceIdentity>, + process_running: impl FnOnce(&codex_plus_core::multi_worker::CodexInstanceIdentity) -> Option, + target_absent: impl FnOnce() -> Option, +) -> Option { + match identity { + Some(identity) => { + if session.owned && session.started_at_ms != Some(identity.generation) { + return None; + } + process_running(identity) + } + None => { + if session.owned { + return None; + } + target_absent().and_then(|absent| absent.then_some(false)) + } + } +} + +fn verified_main_neighbors() -> Vec { + let root = codex_plus_core::paths::default_app_state_dir().join("codex-multi"); + let Ok(entries) = fs::read_dir(root) else { + return Vec::new(); + }; + entries + .flatten() + .filter_map(|entry| { + if !entry.file_type().ok()?.is_dir() { + return None; + } + // 准入探测只读,不调用可能刷新清单的 load_multi_instance_manifest。 + let mut instance: CodexMultiInstance = serde_json::from_slice( + &fs::read(multi_instance_manifest_path(&entry.path())).ok()?, + ) + .ok()?; + if instance.is_main + || entry.file_name() + != std::ffi::OsStr::new(&multi_instance_slug(&instance.instance_id)) + { + return None; + } + if let Some(result) = load_multi_launch_result(&entry.path()).filter(|result| { + result.instance_id == instance.instance_id + && result.generation == instance.generation + }) { + if let Some(identity) = result.identity { + instance.debug_port = result.debug_port.unwrap_or(instance.debug_port); + instance.helper_port = result.helper_port.unwrap_or(instance.helper_port); + instance.identity = Some(identity); + } + } + let identity = instance.identity.as_ref()?; + if identity.instance_id == "main" + || !multi_manifest_identity_matches(&instance, identity) + { + return None; + } + tauri::async_runtime::block_on( + codex_plus_core::multi_worker::codex_instance_needs_worker(identity), + ) + .ok() + .filter(|running| *running) + .map(|_| identity.clone()) + }) + .collect() +} + +fn main_command_is_electron_child(arguments: &[String]) -> bool { + let mut arguments = arguments.iter().skip(1); + while let Some(argument) = arguments.next() { + let role = if argument == "--type" { + arguments.next().map(String::as_str) + } else { + argument.strip_prefix("--type=") + }; + if role.is_some_and(|role| { + matches!( + role, + "renderer" | "gpu-process" | "utility" | "zygote" | "crashpad-handler" + ) + }) { + return true; + } + } + false +} + +fn main_command_is_bundled_app_server(executable: &Path, arguments: &[String]) -> bool { + let bundled = executable + .parent() + .and_then(Path::file_name) + .is_some_and(|parent| parent.eq_ignore_ascii_case("resources")); + if !bundled { + return false; + } + let mut arguments = arguments.iter().skip(1); + while let Some(argument) = arguments.next() { + // CLI 全局参数可位于子命令之前;未知参数保持保守。 + match argument.as_str() { + "-c" | "--config" | "--enable" | "--disable" | "-p" | "--profile" | "-C" | "--cd" + | "-m" | "--model" => { + if arguments.next().is_none() { + return false; + } + } + "app-server" => return true, + value + if [ + "--config=", + "--enable=", + "--disable=", + "--profile=", + "--cd=", + "--model=", + ] + .iter() + .any(|prefix| value.starts_with(prefix)) => {} + _ => return false, + } + } + false +} + +#[cfg(windows)] +fn main_candidate_is_verified_neighbor( + process: &codex_plus_core::WindowsProcessInfo, + neighbors: &[codex_plus_core::multi_worker::CodexInstanceIdentity], + birth: Option, +) -> bool { + neighbors.iter().any(|identity| { + identity.process_id == process.process_id + && birth == Some(identity.process_birth_id) + && process + .executable_path + .as_ref() + .and_then(|path| path.canonicalize().ok()) + .as_ref() + == Some(&identity.executable) + }) +} + +fn main_target_absent(debug_port: u16) -> anyhow::Result { + let neighbors = verified_main_neighbors(); + if codex_plus_core::cdp::endpoint_available(debug_port) + && !neighbors + .iter() + .any(|identity| identity.debug_port == debug_port) + { + return Ok(false); + } + // 无身份时仅用于启动准入,不按这个候选集合终止任何进程。 + #[cfg(windows)] + { + let processes = codex_plus_core::windows_enumerate_processes(); + anyhow::ensure!(!processes.is_empty(), "无法确认主实例进程已退出"); + let candidates = codex_plus_core::watcher::find_codex_processes_from_snapshot(&processes); + for process in processes + .iter() + .filter(|process| candidates.contains(&process.process_id)) + { + if main_candidate_is_verified_neighbor( + process, + &neighbors, + codex_plus_core::windows_process_birth_id(process.process_id), + ) { + continue; + } + if let Some(arguments) = + codex_plus_core::windows_process_arguments(process.process_id) + { + if main_command_is_electron_child(&arguments) { + continue; + } + let internal_cli = process + .executable_path + .as_ref() + .is_some_and(|path| main_command_is_bundled_app_server(path, &arguments)); + if internal_cli { + continue; + } + } + return Ok(false); + } + Ok(true) + } + #[cfg(not(windows))] + { + Ok(codex_plus_core::watcher::find_codex_processes() + .into_iter() + .all(|pid| { + neighbors.iter().any(|identity| { + identity.process_id == pid + && !codex_plus_core::multi_worker::codex_instance_process_exited(identity) + }) + })) + } +} + +fn main_control_request(mut request: LaunchRequest) -> LaunchRequest { + let session = codex_plus_launcher::session_snapshot(); + let latest = StatusStore::default().load_latest().ok().flatten(); + request = main_control_request_from_status(request, &session, latest.as_ref()); + normalize_launch_request(request) +} + +fn main_control_request_from_status( + mut request: LaunchRequest, + session: &codex_plus_launcher::SessionSnapshot, + latest: Option<&LaunchStatus>, +) -> LaunchRequest { + request.debug_port = session + .owned + .then_some(session.debug_port) + .flatten() + .or_else(|| latest.and_then(|status| status.debug_port)) + .unwrap_or(request.debug_port); + if let Some(latest) = latest { + request.helper_port = latest.helper_port.unwrap_or(request.helper_port); + if let Some(app) = &latest.codex_app { + request.app_path = app.clone(); + } + } + request +} + +struct MainCodexStopTarget(Option); + +impl MainCodexStopTarget { + fn capture(request: &LaunchRequest) -> anyhow::Result { + let session = codex_plus_launcher::session_snapshot(); + if let Some(receipt) = load_main_identity()? { + anyhow::ensure!( + receipt.identity.debug_port == request.debug_port + && (!session.owned + || session.started_at_ms == Some(receipt.identity.generation)), + "主实例身份记录与当前会话代际或端口不匹配" + ); + return Ok(Self(Some(receipt.identity))); + } + anyhow::ensure!( + !session.owned && main_target_absent(request.debug_port)?, + "缺少可信主实例身份,未执行停止或重新启动;请手动关闭旧实例后启动" + ); + Ok(Self(None)) + } + + fn stop_and_wait(self) -> anyhow::Result<()> { + let Some(identity) = self.0 else { + return Ok(()); + }; + if codex_plus_core::multi_worker::codex_instance_process_exited(&identity) { + return Ok(()); + } + anyhow::ensure!( + tauri::async_runtime::block_on( + codex_plus_core::multi_worker::terminate_verified_codex_instance(&identity), + )?, + "主实例身份已改变,未执行停止" + ); + { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + if codex_plus_core::multi_worker::codex_instance_process_exited(&identity) { + break; + } + anyhow::ensure!(std::time::Instant::now() < deadline, "主实例尚未确认退出"); + std::thread::sleep(std::time::Duration::from_millis(100)); + } + } + Ok(()) } } fn restart_codex_plus_blocking(request: LaunchRequest) -> CommandResult { + if let Err(error) = prepare_main_launch_options(Default::default()) { + return failed( + &format!("显式启动目录无效,未执行重启:{error:#}"), + json!({}), + ); + } let _launch_guard = match try_acquire_launch_guard() { Ok(guard) => guard, Err(message) => return failed(message, json!({})), }; - let request = normalize_launch_request(request); - // 按本实例的调试端口判定是否在跑。 - // 早先用 `find_codex_processes_for_debug_port`,但它在非 macOS 上返回全部 - // Codex 进程:Windows 上只要有多开实例在跑,就会把「主 Codex 没开」误判成 - // 「已运行」,进而走进重启分支去停进程。 - let app_running = codex_plus_core::cdp::endpoint_available(request.debug_port); - if restart_disposition(request.sync_active_relay, app_running) == RestartDisposition::LaunchOnly + let request = main_control_request(request); + let target = match MainCodexStopTarget::capture(&request) { + Ok(target) => target, + Err(error) => return failed(&format!("无法确认重启目标:{error}"), json!({})), + }; + if codex_plus_core::app_paths::resolve_codex_app_dir( + (!request.app_path.trim().is_empty()).then(|| Path::new(request.app_path.trim())), + ) + .is_none() { - return spawn_codex_plus_launch(request, "Codex 未运行,已按直接启动方式在后台唤起。"); + return failed("无法定位 Codex 应用,未执行重启。", json!({})); } let Ok(_guard) = relay_switch_mutex().try_lock() else { return failed( @@ -2314,6 +2895,12 @@ fn restart_codex_plus_blocking(request: LaunchRequest) -> CommandResult { Ok(guard) => guard, Err(message) => return failed(&message, json!({})), }; + if let Err(error) = preflight_restart_helper(settings.as_ref(), request.helper_port) { + return failed( + &format!("代理端口尚未就绪,未停止 Codex:{error:#}"), + json!({"debugPort": request.debug_port, "helperPort": request.helper_port}), + ); + } // 停止 Codex 后清理同进程后台服务,再确认浏览器恢复结果。 // 先记录停止阶段,让界面在等待期间显示进度(issue #2403)。 // 该状态带的是当前时间戳,早于后面返回给前端的 launch_started_at_ms, @@ -2327,14 +2914,17 @@ fn restart_codex_plus_blocking(request: LaunchRequest) -> CommandResult { 10, )); let native_browser_shutdown = match stop_codex_plus_for_restart( - || stop_main_codex_process(request.debug_port), + || target.stop_and_wait(), codex_plus_launcher::stop, wait_native_browser_shutdown, ) { Ok(shutdown) => shutdown, Err(error) => { + let message = restart_stop_failure_message(&error); + let _ = + save_requested_launch_status(&request, "failed", &message, current_timestamp_ms()); return failed( - &restart_stop_failure_message(&error), + &message, json!({"debugPort": request.debug_port, "helperPort": request.helper_port}), ); } @@ -2350,8 +2940,10 @@ fn restart_codex_plus_blocking(request: LaunchRequest) -> CommandResult { ); } if let Err(error) = prepare_fixed_helper_port_for_restart(settings.as_ref()) { + let message = format!("旧 Codex 已停止,替代实例尚未启动:{error:#}"); + let _ = save_requested_launch_status(&request, "failed", &message, current_timestamp_ms()); return failed( - &format!("重启 Codex 失败:{error:#}"), + &message, json!({"debugPort": request.debug_port, "helperPort": request.helper_port}), ); } @@ -2373,7 +2965,7 @@ fn restart_codex_plus_blocking(request: LaunchRequest) -> CommandResult { launch_started_at_ms, ) { return failed( - &format!("记录重启状态失败,未执行重启:{error}"), + &format!("旧 Codex 已停止,但记录启动状态失败,替代实例未启动:{error}"), json!({ "debugPort": request.debug_port, "helperPort": request.helper_port, @@ -2417,26 +3009,10 @@ fn restart_codex_plus_blocking(request: LaunchRequest) -> CommandResult { "syncActiveRelay": request.sync_active_relay, "launchStartedAtMs": launch_started_at_ms, "nativeBrowserRestoreFailed": native_browser_restore_failed - }), - ) - } - } -} - -/// 结束主 Codex 进程并确认调试端口已关闭。重启和「停止 Codex」共用。 -fn stop_main_codex_process(debug_port: u16) -> anyhow::Result<()> { - // 主实例在 Windows 上是 AUMID 激活的,命令行未必带调试端口; - // 这里允许「按端口没匹配到就退回全局结束」,否则会卡在 - // 「Codex 进程尚未退出」。多开页的结束走的是不允许退回的那个版本。 - #[cfg(windows)] - codex_plus_core::watcher::stop_codex_processes_for_debug_port_with_fallback(debug_port, true); - #[cfg(not(windows))] - codex_plus_core::watcher::stop_codex_processes_for_debug_port_and_wait(debug_port); - anyhow::ensure!( - !codex_plus_core::cdp::endpoint_available(debug_port), - "Codex 进程尚未退出" - ); - Ok(()) + }), + ) + } + } } fn wait_native_browser_shutdown() @@ -2459,10 +3035,11 @@ fn stop_codex_plus_blocking(request: LaunchRequest) -> CommandResult { Ok(guard) => guard, Err(message) => return failed(message, json!({})), }; - let request = normalize_launch_request(request); - if !codex_plus_core::cdp::endpoint_available(request.debug_port) { - return ok("Codex 没有在运行。", json!({})); - } + let request = main_control_request(request); + let target = match MainCodexStopTarget::capture(&request) { + Ok(target) => target, + Err(error) => return failed(&format!("无法确认停止目标:{error}"), json!({})), + }; let Ok(_guard) = relay_switch_mutex().try_lock() else { return failed("供应商切换正在进行,未停止 Codex;请稍后重试。", json!({})); }; @@ -2479,22 +3056,39 @@ fn stop_codex_plus_blocking(request: LaunchRequest) -> CommandResult { 10, )); match stop_codex_plus_for_restart( - || stop_main_codex_process(request.debug_port), + || target.stop_and_wait(), codex_plus_launcher::stop, wait_native_browser_shutdown, ) { - Ok(_) => { + Ok(shutdown) => { + let restore_failed = matches!( + shutdown, + codex_plus_core::native_browser::NativeBrowserShutdown::RestoreFailed + ); + let message = if restore_failed { + "Codex 已停止,但原生浏览器文件恢复失败,请检查恢复状态。" + } else { + "Codex 已停止。" + }; let _ = StatusStore::default().save_latest(&requested_launch_status_with_phase( &request, "stopped", - "Codex 已停止。", + message, current_timestamp_ms(), "user_stopped", 100, )); - ok("Codex 已停止。", json!({})) + ok( + message, + json!({"nativeBrowserRestoreFailed": restore_failed}), + ) + } + Err(error) => { + let message = stop_failure_message(&error); + let _ = + save_requested_launch_status(&request, "failed", &message, current_timestamp_ms()); + failed(&message, json!({})) } - Err(error) => failed(&stop_failure_message(&error), json!({})), } } @@ -2656,6 +3250,32 @@ fn probe_loopback_port(port: u16) -> std::io::Result<()> { Ok(()) } +fn preflight_restart_helper( + settings: Option<&BackendSettings>, + helper_port: u16, +) -> anyhow::Result<()> { + let port = settings + .and_then(codex_plus_core::launcher::required_fixed_helper_port) + .unwrap_or(helper_port); + if codex_plus_core::resident_helper::resident_helper_port() == Some(port) + || probe_loopback_port(port).is_ok() + { + return Ok(()); + } + let healthy = tauri::async_runtime::block_on(async { + let client = codex_plus_core::http_client::local_helper_client()?; + let response = client.get(format!("http://127.0.0.1:{port}/backend/status")).send().await?; + anyhow::ensure!(response.status().is_success(), "占用端口的服务未就绪"); + let body: Value = response.json().await?; + anyhow::ensure!( + body["status"] == "ok" && body["transport"] == "http-helper", + "占用端口的服务不是可用 helper" + ); + Ok::<_, anyhow::Error>(()) + }); + healthy +} + fn spawn_after_provider_guard_release( guard: codex_plus_data::ProviderSyncLifecycleGuard, request: &LaunchRequest, @@ -2852,6 +3472,12 @@ fn normalize_launch_request(mut request: LaunchRequest) -> LaunchRequest { } fn spawn_codex_plus_launch(request: LaunchRequest, accepted_message: &str) -> CommandResult { + if let Err(error) = prepare_main_launch_options(Default::default()) { + return failed( + &format!("显式启动目录无效,未执行启动:{error:#}"), + json!({}), + ); + } let request = normalize_launch_request(request); let debug_port = request.debug_port; let helper_port = request.helper_port; @@ -2864,19 +3490,23 @@ fn spawn_codex_plus_launch(request: LaunchRequest, accepted_message: &str) -> Co "app_path": request.app_path.trim() }), ); - if let Err(error) = save_requested_launch_status( - &request, - "starting", - "Codex++ launcher is starting", - launch_started_at_ms, - ) { - return failed( - &format!("记录启动状态失败,未执行启动:{error}"), - json!({ - "debugPort": debug_port, - "helperPort": helper_port - }), - ); + // 重复激活保留原代健康状态,launcher 据此保留 running_degraded。 + let session = codex_plus_launcher::session_snapshot(); + if !(session.owned && session.ready) { + if let Err(error) = save_requested_launch_status( + &request, + "starting", + "Codex++ launcher is starting", + launch_started_at_ms, + ) { + return failed( + &format!("记录启动状态失败,未执行启动:{error}"), + json!({ + "debugPort": debug_port, + "helperPort": helper_port + }), + ); + } } match start_embedded_launcher(&request) { Ok(()) => { @@ -2992,10 +3622,7 @@ fn current_timestamp_ms() -> u64 { fn start_embedded_launcher(request: &LaunchRequest) -> anyhow::Result<()> { anyhow::ensure!(!crate::app_is_exiting(), "Codex++ 正在退出,未启动 Codex"); - // 先让常驻 helper 占住协议代理端口:启动链路会识别并复用,Codex 会话退出 - // 时就不再关闭它,从而实现「Codex++ 在跑,本地代理就一直可用」。 - sync_resident_helper(); - codex_plus_launcher::start(codex_plus_launcher::LaunchOptions { + start_main_launcher(codex_plus_launcher::LaunchOptions { app_dir: (!request.app_path.trim().is_empty()) .then(|| PathBuf::from(request.app_path.trim())), debug_port: request.debug_port, @@ -3005,16 +3632,294 @@ fn start_embedded_launcher(request: &LaunchRequest) -> anyhow::Result<()> { }) } +fn record_main_launcher_identity( + receipt: &codex_plus_launcher::LaunchReceipt, +) -> anyhow::Result<()> { + let options = prepare_main_launch_options(Default::default())?; + let home = codex_plus_core::relay_config::default_codex_home_dir().canonicalize()?; + let sqlite_home = options.sqlite_home.clone().unwrap_or_else(|| home.clone()); + if let Ok(Some(previous)) = load_main_identity() { + if previous.identity.generation == receipt.started_at_ms + && previous.identity.debug_port == receipt.debug_port + && previous.sqlite_home.as_ref() == Some(&sqlite_home) + { + return Ok(()); + } + } + // 新会话确认前先使旧回执失效;失败时保留 unknown,不能控制旧身份。 + codex_plus_core::settings::atomic_write(&main_identity_path(), b"null\n")?; + let settings = SettingsStore::default().load()?; + let user_data_dir = main_explicit_user_data_dir(&settings.codex_extra_args)?; + let executable = codex_plus_core::app_paths::build_codex_executable(&receipt.app_dir); + let identity = tauri::async_runtime::block_on( + codex_plus_core::multi_worker::capture_main_codex_instance_identity( + receipt.started_at_ms, + receipt.debug_port, + receipt.helper_port, + user_data_dir.as_deref(), + &executable, + ), + )?; + let receipt = MainCodexIdentity { + version: 1, + state_dir: codex_plus_core::paths::default_app_state_dir().canonicalize()?, + codex_home: home, + sqlite_home: Some(sqlite_home), + identity, + }; + codex_plus_core::settings::atomic_write( + &main_identity_path(), + &serde_json::to_vec_pretty(&receipt)?, + ) +} + +fn main_explicit_user_data_dir(arguments: &[String]) -> anyhow::Result> { + let arguments = codex_plus_core::settings::normalize_codex_extra_args(arguments); + let mut path = None; + let mut tokens = arguments.iter(); + while let Some(token) = tokens.next() { + let value = if token == "--user-data-dir" { + Some( + tokens + .next() + .ok_or_else(|| anyhow::anyhow!("缺少 Electron 数据目录参数"))? + .as_str(), + ) + } else { + token.strip_prefix("--user-data-dir=") + }; + if let Some(value) = value { + anyhow::ensure!( + path.is_none() && !value.is_empty(), + "Electron 数据目录参数为空或重复" + ); + path = Some(PathBuf::from(value)); + } + } + // None 由 core 核验 argv 确实没有目录参数;不推断 Electron 的默认目录。 + Ok(path) +} + +fn remember_main_launcher_identity(receipt: &codex_plus_launcher::LaunchReceipt) { + if let Err(error) = record_main_launcher_identity(receipt) { + // 应用已启动;身份记录失败单独报告,不能伪装成应用启动失败。 + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "manager.main_identity_unavailable", + json!({"message": error.to_string()}), + ); + } +} + +fn validated_main_directory(name: &str, path: PathBuf) -> anyhow::Result { + anyhow::ensure!( + path.to_str().is_some_and(|value| !value.trim().is_empty()) && path.is_absolute(), + "{name} 必须是非空的绝对目录,已拒绝回落默认目录" + ); + let canonical = path + .canonicalize() + .map_err(|error| anyhow::anyhow!("{name} 目录不可访问,已拒绝回落默认目录:{error}"))?; + anyhow::ensure!(canonical.is_dir(), "{name} 必须指向已存在的目录"); + Ok(canonical) +} + +fn apply_main_launch_directories( + mut options: codex_plus_launcher::LaunchOptions, + codex_home: Option, + sqlite_home: Option, +) -> anyhow::Result { + options.codex_home = options + .codex_home + .or(codex_home) + .map(|path| validated_main_directory("CODEX_HOME", path)) + .transpose()?; + options.sqlite_home = options + .sqlite_home + .or(sqlite_home) + .map(|path| validated_main_directory("CODEX_SQLITE_HOME", path)) + .transpose()?; + if options.codex_home.is_some() || options.sqlite_home.is_some() { + options + .state_dir + .get_or_insert_with(codex_plus_core::paths::default_app_state_dir); + } + Ok(options) +} + +fn prepare_main_launch_options( + options: codex_plus_launcher::LaunchOptions, +) -> anyhow::Result { + // 包激活不继承管理器环境,显式目录必须随启动请求进入包内 wrapper。 + apply_main_launch_directories( + options, + std::env::var_os("CODEX_HOME").map(PathBuf::from), + std::env::var_os("CODEX_SQLITE_HOME").map(PathBuf::from), + ) +} + +fn main_launch_has_explicit_directories(options: &codex_plus_launcher::LaunchOptions) -> bool { + options.codex_home.is_some() || options.sqlite_home.is_some() +} + +fn validate_main_resume_scope( + options: &codex_plus_launcher::LaunchOptions, + settings: &BackendSettings, + receipt: &MainCodexIdentity, + session: &codex_plus_launcher::SessionSnapshot, +) -> anyhow::Result<()> { + let state_dir = options + .state_dir + .clone() + .unwrap_or_else(codex_plus_core::paths::default_app_state_dir); + let home = options + .codex_home + .clone() + .unwrap_or_else(codex_plus_core::relay_config::default_codex_home_dir) + .canonicalize()?; + let sqlite_home = options.sqlite_home.as_ref().unwrap_or(&home); + anyhow::ensure!( + main_identity_in_scope(receipt, &state_dir, &home) + && receipt.sqlite_home.as_ref() == Some(sqlite_home), + "显式目录与持久主实例身份不一致或缺少 SQLite 归属,拒绝恢复" + ); + let identity = &receipt.identity; + anyhow::ensure!( + identity.debug_port == options.debug_port && identity.helper_port == options.helper_port, + "主实例端口与持久身份不一致,拒绝恢复" + ); + let app_dir = codex_plus_core::app_paths::resolve_codex_app_dir_with_saved( + options.app_dir.as_deref(), + Some(settings.codex_app_path.as_str()), + ) + .ok_or_else(|| anyhow::anyhow!("缺少主实例应用路径,拒绝恢复"))?; + let executable = codex_plus_core::app_paths::build_codex_executable(&app_dir).canonicalize()?; + let user_data = main_explicit_user_data_dir(&settings.codex_extra_args)? + .map(|path| path.canonicalize()) + .transpose()? + .unwrap_or_default(); + anyhow::ensure!( + executable == identity.executable && user_data == identity.user_data_dir, + "主实例映像或 Electron 数据目录与持久身份不一致,拒绝恢复" + ); + anyhow::ensure!( + !session.owned + || (session.ready + && session.started_at_ms == Some(identity.generation) + && session.debug_port == Some(identity.debug_port)), + "当前 launcher 会话与持久主实例身份不一致,拒绝复用" + ); + Ok(()) +} + +fn verified_main_resume_target( + options: &codex_plus_launcher::LaunchOptions, + settings: &BackendSettings, +) -> anyhow::Result { + let session = codex_plus_launcher::session_snapshot(); + let Some(receipt) = load_main_identity()? else { + validate_missing_main_identity(&session, || main_target_absent(options.debug_port))?; + return Ok(false); + }; + if codex_plus_core::multi_worker::codex_instance_process_exited(&receipt.identity) { + validate_missing_main_identity(&session, || main_target_absent(options.debug_port))?; + return Ok(false); + } + validate_main_resume_scope( + options, + settings, + &receipt, + &session, + )?; + anyhow::ensure!( + tauri::async_runtime::block_on( + codex_plus_core::multi_worker::codex_instance_identity_matches(&receipt.identity) + )?, + "无法确认原代主实例的进程及 CDP 归属,拒绝恢复或复用" + ); + Ok(true) +} + +fn validate_missing_main_identity( + session: &codex_plus_launcher::SessionSnapshot, + target_absent: impl FnOnce() -> anyhow::Result, +) -> anyhow::Result<()> { + anyhow::ensure!( + !session.owned && target_absent()?, + "显式目录缺少持久主实例身份,且无法确认目标不存在,拒绝恢复或复用" + ); + Ok(()) +} + +fn start_main_launcher(options: codex_plus_launcher::LaunchOptions) -> anyhow::Result<()> { + let options = prepare_main_launch_options(options)?; + if main_launch_has_explicit_directories(&options) + && codex_plus_launcher::session_snapshot().owned + { + let settings = SettingsStore::default().load()?; + anyhow::ensure!( + verified_main_resume_target(&options, &settings)?, + "原代主实例已退出,拒绝复用旧 launcher 会话" + ); + } + // 校验完成后才绑定 helper,供启动链路识别并复用。 + sync_resident_helper(); + let receipt = codex_plus_launcher::start_with_receipt(options)?; + remember_main_launcher_identity(&receipt); + Ok(()) +} + +fn remember_main_ready_session() { + let session = codex_plus_launcher::session_snapshot(); + if !session.owned || !session.ready { + return; + } + let Some(latest) = StatusStore::default().load_latest().ok().flatten() else { + return; + }; + let Some(app_dir) = latest.codex_app.as_deref().map(PathBuf::from) else { + return; + }; + let (Some(debug_port), Some(helper_port), Some(started_at_ms)) = ( + session.debug_port, + latest.helper_port, + session.started_at_ms, + ) else { + return; + }; + remember_main_launcher_identity(&codex_plus_launcher::LaunchReceipt { + debug_port, + helper_port, + app_dir, + started_at_ms, + }); +} + /// 重新打开管理界面时恢复已有 Codex 的 Helper、代理和注入,不启动官方应用。 pub fn resume_existing_codex_background(app: tauri::AppHandle, start_if_missing: bool) { tauri::async_runtime::spawn_blocking(move || { if crate::app_is_exiting() { return; } - // 必须先于 resume:resume 会走 launch_and_inject,如果它先 bind 了 helper, - // Codex 退出时就把端口带走了;这里先托管常驻实例,会话会复用同一监听。 - sync_resident_helper(); - let settings = SettingsStore::default().load().unwrap_or_default(); + let directory_options = match prepare_main_launch_options(Default::default()) { + Ok(options) => options, + Err(error) => { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "manager.resume_existing_failed", + json!({ "message": format!("{error:#}") }), + ); + return; + } + }; + let settings = match SettingsStore::default().load() { + Ok(settings) => settings, + Err(error) if main_launch_has_explicit_directories(&directory_options) => { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "manager.resume_existing_failed", + json!({ "message": format!("无法读取隔离实例设置,拒绝恢复:{error:#}") }), + ); + return; + } + Err(_) => BackendSettings::default(), + }; let latest = StatusStore::default().load_latest().unwrap_or(None); let options = codex_plus_launcher::LaunchOptions { app_dir: codex_plus_core::app_paths::resolve_codex_app_dir_with_saved( @@ -3033,12 +3938,27 @@ pub fn resume_existing_codex_background(app: tauri::AppHandle, start_if_missing: .and_then(|status| status.helper_port) .unwrap_or_else(default_helper_port), status_store: StatusStore::default(), - ..Default::default() + ..directory_options }; - match codex_plus_launcher::resume_if_running(options.clone()) { - Ok(true) => {} + let resumed = (|| -> anyhow::Result { + if main_launch_has_explicit_directories(&options) + && !verified_main_resume_target(&options, &settings)? + { + return Ok(false); + } + // 归属校验完成后才绑定 helper 或进入 launcher 的恢复操作。 + sync_resident_helper(); + let resumed = codex_plus_launcher::resume_if_running(options.clone())?; + anyhow::ensure!( + resumed || !main_launch_has_explicit_directories(&options), + "身份校验后主实例连接已失联,拒绝回退启动" + ); + Ok(resumed) + })(); + match resumed { + Ok(true) => remember_main_ready_session(), Ok(false) if start_if_missing => { - if let Err(error) = codex_plus_launcher::start(options) { + if let Err(error) = start_main_launcher(options) { let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( "manager.autostart_codex_failed", json!({ "message": error.to_string() }), @@ -9473,6 +10393,213 @@ fn default_log_lines() -> usize { mod tests { use super::*; + #[test] + fn explicit_main_directories_reject_invalid_paths_without_fallback() { + let temp = tempfile::tempdir().unwrap(); + let file = temp.path().join("file"); + fs::write(&file, b"not a directory").unwrap(); + for path in [ + PathBuf::new(), + PathBuf::from(" "), + PathBuf::from("relative-home"), + temp.path().join("missing"), + file, + ] { + for sqlite in [false, true] { + let (home, sqlite_home) = if sqlite { + (None, Some(path.clone())) + } else { + (Some(path.clone()), None) + }; + assert!( + apply_main_launch_directories(Default::default(), home, sqlite_home).is_err(), + "accepted invalid explicit directory: {path:?}" + ); + } + } + } + + #[test] + fn explicit_main_directories_preserve_default_and_options_precedence() { + let default = apply_main_launch_directories(Default::default(), None, None).unwrap(); + assert!(default.codex_home.is_none()); + assert!(default.sqlite_home.is_none()); + assert!(default.state_dir.is_none()); + assert!(!main_launch_has_explicit_directories(&default)); + + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let sqlite = temp.path().join("sqlite"); + fs::create_dir(&home).unwrap(); + fs::create_dir(&sqlite).unwrap(); + let options = apply_main_launch_directories( + codex_plus_launcher::LaunchOptions { + state_dir: Some(temp.path().to_path_buf()), + codex_home: Some(home.clone()), + sqlite_home: Some(sqlite.clone()), + ..Default::default() + }, + Some(PathBuf::from("invalid-lower-priority-home")), + Some(PathBuf::from("invalid-lower-priority-sqlite")), + ) + .unwrap(); + assert_eq!(options.codex_home, Some(home.canonicalize().unwrap())); + assert_eq!(options.sqlite_home, Some(sqlite.canonicalize().unwrap())); + assert_eq!(options.state_dir.as_deref(), Some(temp.path())); + assert!(main_launch_has_explicit_directories(&options)); + for sqlite_only in [false, true] { + let options = apply_main_launch_directories( + codex_plus_launcher::LaunchOptions { + state_dir: Some(temp.path().to_path_buf()), + ..Default::default() + }, + (!sqlite_only).then(|| home.clone()), + sqlite_only.then(|| sqlite.clone()), + ) + .unwrap(); + assert!(main_launch_has_explicit_directories(&options)); + assert_eq!(options.codex_home.is_some(), !sqlite_only); + assert_eq!(options.sqlite_home.is_some(), sqlite_only); + } + } + + fn explicit_main_resume_fixture( + root: &Path, + ) -> ( + codex_plus_launcher::LaunchOptions, + BackendSettings, + MainCodexIdentity, + codex_plus_launcher::SessionSnapshot, + ) { + let executable = codex_plus_core::app_paths::build_codex_executable(root); + fs::create_dir_all(executable.parent().unwrap()).unwrap(); + fs::write(&executable, b"fixture only").unwrap(); + fs::create_dir(root.join("resources")).unwrap(); + fs::write(root.join("resources/app.asar"), b"fixture only").unwrap(); + let root = root.canonicalize().unwrap(); + let options = codex_plus_launcher::LaunchOptions { + app_dir: Some(root.clone()), + debug_port: 49101, + helper_port: 49102, + state_dir: Some(root.clone()), + codex_home: Some(root.clone()), + sqlite_home: Some(root.clone()), + ..Default::default() + }; + let receipt = MainCodexIdentity { + version: 1, + state_dir: root.clone(), + codex_home: root.clone(), + sqlite_home: Some(root), + identity: codex_plus_core::multi_worker::CodexInstanceIdentity { + instance_id: "main".into(), + generation: 7, + debug_port: options.debug_port, + helper_port: options.helper_port, + user_data_dir: PathBuf::new(), + browser_id: "fixture-browser".into(), + process_id: 123, + process_birth_id: 456, + executable: executable.canonicalize().unwrap(), + }, + }; + let session = codex_plus_launcher::SessionSnapshot { + owned: true, + ready: true, + status: Some("running".into()), + debug_port: Some(options.debug_port), + started_at_ms: Some(7), + }; + (options, BackendSettings::default(), receipt, session) + } + + #[test] + fn explicit_main_first_start_requires_confirmed_absence_without_a_receipt() { + let mut session = codex_plus_launcher::SessionSnapshot { + owned: false, ready: false, status: None, debug_port: None, started_at_ms: None, + }; + validate_missing_main_identity(&session, || Ok(true)).unwrap(); + assert!(validate_missing_main_identity(&session, || Ok(false)).is_err()); + assert!(validate_missing_main_identity(&session, || anyhow::bail!("probe unavailable")).is_err()); + session.owned = true; + assert!(validate_missing_main_identity(&session, || panic!("must not probe an owned session")).is_err()); + } + + #[test] + fn explicit_main_resume_preserves_default_electron_directory() { + let temp = tempfile::tempdir().unwrap(); + let (options, settings, receipt, mut session) = explicit_main_resume_fixture(temp.path()); + validate_main_resume_scope(&options, &settings, &receipt, &session).unwrap(); + session.owned = false; + session.ready = false; + session.started_at_ms = None; + validate_main_resume_scope(&options, &settings, &receipt, &session).unwrap(); + } + + #[test] + fn explicit_main_resume_rejects_incomplete_or_foreign_receipts() { + let temp = tempfile::tempdir().unwrap(); + let foreign_dir = tempfile::tempdir().unwrap(); + let foreign = foreign_dir.path().canonicalize().unwrap(); + let (options, settings, receipt, session) = explicit_main_resume_fixture(temp.path()); + for change in 0..9 { + let mut wrong = receipt.clone(); + match change { + 0 => wrong.sqlite_home = None, + 1 => wrong.sqlite_home = Some(foreign.clone()), + 2 => wrong.codex_home = foreign.clone(), + 3 => wrong.state_dir = foreign.clone(), + 4 => wrong.identity.executable = foreign.join("other.exe"), + 5 => wrong.identity.user_data_dir = foreign.clone(), + 6 => wrong.identity.debug_port += 1, + 7 => wrong.identity.helper_port += 1, + 8 => wrong.identity.generation += 1, + _ => unreachable!(), + } + assert!( + validate_main_resume_scope(&options, &settings, &wrong, &session).is_err(), + "accepted conflicting receipt case {change}" + ); + } + let mut old = serde_json::to_value(&receipt).unwrap(); + old.as_object_mut().unwrap().remove("sqlite_home"); + let old: MainCodexIdentity = serde_json::from_value(old).unwrap(); + assert!(validate_main_resume_scope(&options, &settings, &old, &session).is_err()); + for change in 0..3 { + let mut wrong = session.clone(); + match change { + 0 => wrong.ready = false, + 1 => wrong.started_at_ms = None, + 2 => wrong.debug_port = Some(49103), + _ => unreachable!(), + } + assert!(validate_main_resume_scope(&options, &settings, &receipt, &wrong).is_err()); + } + } + + #[test] + fn explicit_main_resume_requires_matching_electron_directory_arguments() { + let temp = tempfile::tempdir().unwrap(); + let (options, mut settings, mut receipt, session) = + explicit_main_resume_fixture(temp.path()); + let data = temp.path().join("user-data"); + fs::create_dir(&data).unwrap(); + receipt.identity.user_data_dir = data.canonicalize().unwrap(); + assert!(validate_main_resume_scope(&options, &settings, &receipt, &session).is_err()); + for args in [ + vec![ + "--user-data-dir".to_string(), + data.to_string_lossy().into_owned(), + ], + vec![format!("--user-data-dir={}", data.display())], + ] { + settings.codex_extra_args = args; + validate_main_resume_scope(&options, &settings, &receipt, &session).unwrap(); + } + settings.codex_extra_args.push("--user-data-dir=".into()); + assert!(validate_main_resume_scope(&options, &settings, &receipt, &session).is_err()); + } + #[test] fn skills_payload_hides_disabled_repository_catalog() { let temp = tempfile::tempdir().unwrap(); @@ -9968,17 +11095,109 @@ base_url = "https://example.invalid/v1" } #[test] - fn restart_absent_app_uses_launch_but_explicit_sync_still_restarts() { + fn main_without_owned_identity_requires_confirmed_absence() { + let session = codex_plus_launcher::SessionSnapshot { + owned: false, + ready: false, + status: Some("failed".into()), + debug_port: Some(9229), + started_at_ms: Some(1), + }; assert_eq!( - restart_disposition(false, false), - RestartDisposition::LaunchOnly + main_running_with(&session, None, |_| panic!("no identity"), || Some(true)), + Some(false) ); - for (sync, running) in [(true, false), (false, true), (true, true)] { - assert_eq!( - restart_disposition(sync, running), - RestartDisposition::StopAndRestart - ); - } + assert_eq!( + main_running_with(&session, None, |_| panic!("no identity"), || Some(false)), + None + ); + assert_eq!( + main_running_with(&session, None, |_| panic!("no identity"), || None), + None + ); + } + + #[test] + fn main_identity_null_is_absent_and_stale_ready_does_not_override_exit() { + let temp = tempfile::tempdir().unwrap(); + assert!( + decode_main_identity(b"null\n", temp.path(), temp.path()) + .unwrap() + .is_none() + ); + let identity = codex_plus_core::multi_worker::CodexInstanceIdentity { + instance_id: "main".into(), + generation: 7, + debug_port: 49101, + helper_port: 49102, + user_data_dir: PathBuf::new(), + browser_id: "browser".into(), + process_id: 123, + process_birth_id: 456, + executable: temp.path().join("Codex.exe"), + }; + let receipt = MainCodexIdentity { + version: 1, + state_dir: temp.path().canonicalize().unwrap(), + codex_home: temp.path().canonicalize().unwrap(), + sqlite_home: None, + identity: identity.clone(), + }; + let bytes = serde_json::to_vec(&receipt).unwrap(); + assert!( + decode_main_identity(&bytes, temp.path(), temp.path()) + .unwrap() + .is_some() + ); + let foreign = tempfile::tempdir().unwrap(); + assert!(decode_main_identity(&bytes, foreign.path(), temp.path()).is_err()); + let session = codex_plus_launcher::SessionSnapshot { + owned: true, + ready: true, + status: Some("running".into()), + debug_port: Some(49101), + started_at_ms: Some(7), + }; + assert_eq!( + main_running_with( + &session, + Some(&identity), + |_| Some(false), + || panic!("has identity") + ), + Some(false) + ); + assert_eq!( + main_running_with( + &session, + Some(&identity), + |_| None, + || panic!("has identity") + ), + None + ); + } + + #[test] + fn main_control_uses_recorded_ports_instead_of_default_form() { + let session = codex_plus_launcher::SessionSnapshot { + owned: true, + ready: true, + status: Some("running_degraded".into()), + debug_port: Some(49101), + started_at_ms: Some(7), + }; + let latest = LaunchStatus { + debug_port: Some(49101), + helper_port: Some(49102), + codex_app: Some("saved-app".into()), + ..LaunchStatus::default() + }; + let request = + main_control_request_from_status(launch_request(false), &session, Some(&latest)); + assert_eq!(request.debug_port, 49101); + assert_eq!(request.helper_port, 49102); + assert_eq!(request.app_path, "saved-app"); } #[test] @@ -11949,6 +13168,22 @@ model_reasoning_effort = "high" wrong.user_data_dir = temp.path().canonicalize().unwrap(); assert!(!multi_manifest_identity_matches(¤t, &wrong)); assert!(!multi_instance_is_running(&instance)); + let mut uncertain = current.clone(); + uncertain.generation += 1; + assert_eq!(multi_instance_snapshot_status(&uncertain, false), "unknown"); + uncertain.identity = None; + uncertain.launch_result.as_mut().unwrap().status = "cleanup_failed".into(); + assert_eq!(multi_instance_snapshot_status(&uncertain, false), "unknown"); + } + + #[test] + fn multi_instance_requests_preserve_generation_and_allow_legacy_clients() { + let current: CodexMultiInstanceRequest = + serde_json::from_value(json!({ "instanceId": "fixture", "generation": 7 })).unwrap(); + assert_eq!(current.generation, Some(7)); + let legacy: CodexMultiInstanceRequest = + serde_json::from_value(json!({ "instanceId": "fixture" })).unwrap(); + assert_eq!(legacy.generation, None); } #[cfg(unix)] @@ -12099,12 +13334,11 @@ model_reasoning_effort = "high" assert_eq!(instance.debug_port, 9456); } - #[cfg(unix)] #[test] fn multi_worker_stop_never_terminates_current_process() { let options = std::env::temp_dir().join("codex-plus-worker-options-test.json"); let pid = std::process::id(); - terminate_multi_worker(pid, &options, None).unwrap(); + assert!(terminate_multi_worker(pid, &options, None).is_err()); } #[test] diff --git a/apps/codex-plus-manager/src-tauri/src/commands/multi_instance_resume.rs b/apps/codex-plus-manager/src-tauri/src/commands/multi_instance_resume.rs new file mode 100644 index 0000000000..8bf0215160 --- /dev/null +++ b/apps/codex-plus-manager/src-tauri/src/commands/multi_instance_resume.rs @@ -0,0 +1,1679 @@ +use super::*; +use anyhow::{Context, ensure}; +use codex_plus_core::multi_worker::CodexInstanceIdentity; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +const OPTIONS: &str = "worker-options.json"; +const READY: &str = "worker-ready.json"; +const REQUEST: &str = "worker-handoff.json"; +const RELEASE: &str = "worker-handoff-release.json"; +const COMMIT: &str = "worker-handoff-commit.json"; +const CANDIDATE: &str = "worker-handoff-candidate.json"; +const HANDOFF_ENV: &str = "CODEX_PLUS_WORKER_HANDOFF"; +const RESUME_LOCK: &str = "worker-resume.lock"; +const OUTCOME: &str = "worker-resume-result.json"; +const STAGE_TIMEOUT: Duration = Duration::from_secs(90); +const HANDOFF_LIFETIME: Duration = Duration::from_secs(180); +const HANDOFF_PROTOCOL: u32 = 2; +const POLL: Duration = Duration::from_millis(100); + +/// 记录 worker 启动时的文件属性;原路径被替换后不能据路径相同复用旧构建。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct BinaryStamp { + executable: PathBuf, + size: u64, + modified_ns: String, + version: String, +} + +impl BinaryStamp { + pub(super) fn current() -> anyhow::Result { + Self::at(&std::env::current_exe()?) + } + + fn at(path: &Path) -> anyhow::Result { + let executable = path.canonicalize().context("无法定位新版 worker 二进制")?; + let file = fs::File::open(&executable).context("新版 worker 二进制不可读")?; + let metadata = file.metadata()?; + ensure!( + metadata.is_file() && metadata.len() != 0, + "新版 worker 二进制无效" + ); + Ok(Self { + executable, + size: metadata.len(), + modified_ns: metadata + .modified()? + .duration_since(UNIX_EPOCH)? + .as_nanos() + .to_string(), + version: env!("CARGO_PKG_VERSION").into(), + }) + } +} + +/// 交接始终绑定完整 ready;v1 worker 不理解撤销,不能向其发送退出请求。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct WorkerReady { + protocol: u32, + instance_id: String, + generation: u64, + pid: u32, + worker_birth_id: Option, + token: String, + options_path: PathBuf, + identity: CodexInstanceIdentity, + binary: BinaryStamp, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct HandoffRequest { + protocol: u32, + token: String, + worker: WorkerReady, + requester_pid: u32, + requester_birth: String, + issued_at_ms: u64, + expires_at_ms: u64, + successor_token: String, + successor_binary: BinaryStamp, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct HandoffRelease { + request: HandoffRequest, + options: CodexMultiWorkerOptions, + released: bool, +} + +#[derive(Serialize, Deserialize)] +struct HandoffCommit { + request: HandoffRequest, + successor: WorkerReady, +} + +fn read_json(path: &Path) -> Option { + serde_json::from_slice(&fs::read(path).ok()?).ok() +} + +fn open_resume_lock(root: &Path) -> anyhow::Result { + let path = root.join(RESUME_LOCK); + if let Ok(metadata) = fs::symlink_metadata(&path) { + ensure!( + !metadata.file_type().is_symlink(), + "worker 交接锁不能重定向" + ); + } + Ok(fs::OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(path)?) +} + +fn process_birth(pid: u32) -> Option { + #[cfg(windows)] + { + codex_plus_core::windows_process_birth_id(pid).map(|birth| birth.to_string()) + } + #[cfg(not(windows))] + { + match codex_plus_core::watcher::inspect_process_instance(pid) { + codex_plus_core::watcher::ProcessInstanceState::Running { birth_id, .. } => birth_id, + _ => None, + } + } +} + +fn request_matches( + request: &HandoffRequest, + ready: &WorkerReady, + options_path: &Path, + now: u64, +) -> bool { + request.protocol == HANDOFF_PROTOCOL + && ready.protocol == HANDOFF_PROTOCOL + && request.worker == *ready + && options_path.canonicalize().ok().as_ref() == Some(&ready.options_path) + && request.requester_pid != 0 + && request.requester_pid != ready.pid + && !request.requester_birth.is_empty() + && !request.token.is_empty() + && !request.successor_token.is_empty() + && request.successor_token != ready.token + && request.issued_at_ms <= now + && now < request.expires_at_ms + && request.expires_at_ms.saturating_sub(request.issued_at_ms) + <= HANDOFF_LIFETIME.as_millis() as u64 +} + +fn request_active(root: &Path, request: &HandoffRequest, ready: &WorkerReady) -> bool { + if !request_matches(request, ready, &root.join(OPTIONS), current_timestamp_ms()) + || read_json::(&root.join(REQUEST)).as_ref() != Some(request) + || process_birth(request.requester_pid).as_ref() != Some(&request.requester_birth) + { + return false; + } + // 请求必须仍由发起 manager 持锁;崩溃会释放锁,即使磁盘请求尚未来得及撤销。 + let Ok(lock) = open_resume_lock(root) else { + return false; + }; + matches!(lock.try_lock(), Err(std::fs::TryLockError::WouldBlock)) +} + +struct PendingHandoff { + root: PathBuf, + request: HandoffRequest, +} + +impl PendingHandoff { + fn revoke(&self) -> anyhow::Result<()> { + // 只撤销本次请求,不覆盖另一代交接;用 tombstone 避免按路径删除的竞争。 + if read_json::(&self.root.join(REQUEST)).as_ref() == Some(&self.request) { + write_json(&self.root.join(REQUEST), &serde_json::Value::Null)?; + } + Ok(()) + } +} + +impl Drop for PendingHandoff { + fn drop(&mut self) { + if let Err(error) = self.revoke() { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "manager.multi_worker_revoke_failed", + json!({"message": format!("{error:#}")}), + ); + } + } +} + +fn new_token() -> String { + static SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + format!( + "{}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(), + SERIAL.fetch_add(1, Ordering::Relaxed), + ) +} + +fn write_json(path: &Path, value: &impl Serialize) -> anyhow::Result<()> { + if let Ok(metadata) = fs::symlink_metadata(path) { + ensure!(!metadata.file_type().is_symlink(), "续接文件不能是符号链接"); + } + codex_plus_core::settings::atomic_write(path, &serde_json::to_vec_pretty(value)?) +} + +pub(super) fn publish_worker_ready( + options_path: &Path, + options: &CodexMultiWorkerOptions, + identity: &CodexInstanceIdentity, + binary: BinaryStamp, +) -> anyhow::Result { + let ready = worker_ready(options_path, options, identity, binary)?; + validate_candidate(&ready, false)?; + let root = ready.options_path.parent().context("缺少 worker 目录")?; + write_json(&root.join(READY), &ready)?; + Ok(ready) +} + +fn worker_ready( + options_path: &Path, + options: &CodexMultiWorkerOptions, + identity: &CodexInstanceIdentity, + binary: BinaryStamp, +) -> anyhow::Result { + ensure!( + identity.instance_id == options.instance_id && identity.generation == options.generation, + "worker 就绪身份不匹配" + ); + let options_path = options_path.canonicalize()?; + let ready = WorkerReady { + protocol: HANDOFF_PROTOCOL, + instance_id: options.instance_id.clone(), + generation: options.generation, + pid: std::process::id(), + #[cfg(windows)] + worker_birth_id: codex_plus_core::windows_process_birth_id(std::process::id()), + #[cfg(not(windows))] + worker_birth_id: None, + token: options.resume_token.clone().unwrap_or_else(new_token), + options_path: options_path.clone(), + identity: identity.clone(), + binary, + }; + ensure!(!ready.token.is_empty(), "worker token 为空"); + Ok(ready) +} + +fn validate_candidate(ready: &WorkerReady, register: bool) -> anyhow::Result<()> { + let Some(request) = std::env::var_os(HANDOFF_ENV) else { + return Ok(()); + }; + let request: HandoffRequest = + serde_json::from_str(request.to_str().context("候选 worker 交接标记无效")?)?; + validate_candidate_request(&request, ready, register) +} + +fn validate_candidate_request( + request: &HandoffRequest, + ready: &WorkerReady, + register: bool, +) -> anyhow::Result<()> { + ensure!( + successor_matches(request, ready), + "候选 worker 与交接身份不一致" + ); + let root = ready.options_path.parent().context("缺少 worker 目录")?; + // 提交绑定完整 PID/birth,不能用过期请求或另一候选的 commit 放行。 + if read_json::(&root.join(COMMIT)) + .is_some_and(|commit| commit.request == *request && commit.successor == *ready) + { + return Ok(()); + } + if register { + ensure!( + request_active(root, request, &request.worker), + "候选 worker 启动前交接已失效" + ); + // 先注册再复验:manager 在 spawn/manifest 之间退出时,回滚仍能发现候选。 + write_json(&root.join(CANDIDATE), ready)?; + } + ensure!( + request_active(root, request, &request.worker), + "交接已取消或发起 manager 已退出,候选 worker 不得启动服务或发布就绪" + ); + Ok(()) +} + +pub(super) fn handoff_requested( + options_path: &Path, + ready: &WorkerReady, + checkpoint: impl FnOnce() -> anyhow::Result<()>, +) -> bool { + if ready.pid != std::process::id() { + return false; + } + let Some(root) = options_path.parent() else { + return false; + }; + let release = read_json::(&root.join(RELEASE)) + .filter(|release| release.request.worker == *ready); + if let Some(release) = release { + let committed = committed_successor(root, &release.request); + let active = !committed && request_active(root, &release.request, ready); + match finish_handoff(committed, active, || { + restore_released_worker(root, ready, &release) + }) { + Ok(true) => return true, + Err(error) => { + // 保留 release 回执,下轮继续恢复;不能丢掉旧 worker 的恢复责任。 + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "manager.multi_worker_rollback_pending", + json!({"message": format!("{error:#}")}), + ); + } + Ok(false) => {} + } + if active && !release.released { + release_worker_services(root, ready, release, checkpoint); + } + return false; + } + let Some(request) = read_json::(&root.join(REQUEST)) + .filter(|request| request_active(root, request, ready)) + else { + return false; + }; + let Some(options) = read_json::(options_path) else { + return false; + }; + let release = HandoffRelease { + request, + options, + released: false, + }; + // 先持久化恢复材料,stop 或回执写入失败后仍能在下一轮恢复。 + if write_json(&root.join(RELEASE), &release).is_ok() { + release_worker_services(root, ready, release, checkpoint); + } + false +} + +fn finish_handoff( + committed: bool, + active: bool, + restore: impl FnOnce() -> anyhow::Result<()>, +) -> anyhow::Result { + if committed { + return Ok(true); + } + if !active { + restore()?; + } + Ok(false) +} + +fn release_worker_services( + root: &Path, + ready: &WorkerReady, + mut release: HandoffRelease, + checkpoint: impl FnOnce() -> anyhow::Result<()>, +) { + let result = (|| -> anyhow::Result<()> { + ensure!( + request_active(root, &release.request, ready), + "交接请求已失效" + ); + checkpoint()?; + ensure!( + request_active(root, &release.request, ready), + "保存 checkpoint 期间交接已取消" + ); + codex_plus_launcher::stop()?; + ensure!( + request_active(root, &release.request, ready), + "服务释放期间交接已取消" + ); + release.released = true; + write_json(&root.join(RELEASE), &release) + })(); + if let Err(error) = result { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "manager.multi_worker_release_failed", + json!({"message": format!("{error:#}")}), + ); + } +} + +fn successor_matches(request: &HandoffRequest, successor: &WorkerReady) -> bool { + successor.protocol == HANDOFF_PROTOCOL + && successor.instance_id == request.worker.instance_id + && successor.generation == request.worker.generation + && successor.identity == request.worker.identity + && successor.options_path == request.worker.options_path + && successor.pid != 0 + && successor.pid != request.worker.pid + && successor.token == request.successor_token + && successor.binary == request.successor_binary +} + +fn committed_successor(root: &Path, request: &HandoffRequest) -> bool { + let Some(commit) = read_json::(&root.join(COMMIT)).filter(|commit| { + commit.request == *request && successor_matches(request, &commit.successor) + }) else { + return false; + }; + let Ok(instance) = load_multi_instance_manifest(root) else { + return false; + }; + ready_matches(&commit.successor, &instance, &root.join(OPTIONS)) + && worker_present(&instance, &root.join(OPTIONS)).unwrap_or(false) +} + +fn restore_released_worker( + root: &Path, + ready: &WorkerReady, + release: &HandoffRelease, +) -> anyhow::Result<()> { + restore_released_worker_with(root, ready, release, terminate_multi_worker, |options| { + let receipt = + codex_plus_launcher::resume_with_receipt(codex_plus_launcher::LaunchOptions { + app_dir: Some(PathBuf::from(&options.app_path)), + debug_port: ready.identity.debug_port, + helper_port: ready.identity.helper_port, + status_store: StatusStore::new( + Path::new(&options.state_dir).join("latest-status.json"), + ), + state_dir: Some(PathBuf::from(&options.state_dir)), + codex_home: std::env::var_os("CODEX_HOME").map(PathBuf::from), + sqlite_home: std::env::var_os("CODEX_SQLITE_HOME").map(PathBuf::from), + })? + .context("原桌面暂不可续接,保留旧 worker 稍后重试")?; + confirm_resumed_identity(&ready.identity, &receipt) + }) +} + +fn restore_released_worker_with( + root: &Path, + ready: &WorkerReady, + release: &HandoffRelease, + stop_candidate: impl FnOnce(u32, &Path, Option) -> anyhow::Result<()>, + resume: impl FnOnce(&CodexMultiWorkerOptions) -> anyhow::Result<()>, +) -> anyhow::Result<()> { + // manager 先回收自己持有 Child 的候选 worker 并退出事务;旧 worker 随后恢复。 + let lock = open_resume_lock(root)?; + lock.try_lock().context("等待 manager 结束交接事务")?; + let mut instance = load_multi_instance_manifest(root)?; + ensure!( + instance.identity.as_ref() == Some(&ready.identity) + && instance.instance_id == ready.instance_id + && instance.generation == ready.generation, + "恢复时桌面身份已变化,未触动其他代次" + ); + let registered = read_json::(&root.join(CANDIDATE)) + .filter(|candidate| successor_matches(&release.request, candidate)); + let candidate = registered + .as_ref() + .map(|candidate| (candidate.pid, candidate.worker_birth_id)) + .or_else(|| { + instance + .pid + .filter(|pid| *pid != ready.pid) + .map(|pid| (pid, instance.worker_birth_id)) + }); + if let Some((pid, birth)) = candidate { + ensure!( + instance + .pid + .is_none_or(|current| current == ready.pid || current == pid), + "清单指向其他 worker,拒绝停止" + ); + let options: CodexMultiWorkerOptions = + read_json(&root.join(OPTIONS)).context("缺少候选 worker 参数")?; + ensure!( + options.resume_token.as_ref() == Some(&release.request.successor_token) + && options.resume_identity.as_ref() == Some(&ready.identity), + "候选 worker 不属于本次交接,拒绝停止" + ); + // 发起 manager 崩溃时 Child 不会自动退出;仅清理本次完整身份的候选 worker。 + stop_candidate(pid, &root.join(OPTIONS), birth)?; + write_json(&root.join(CANDIDATE), &serde_json::Value::Null)?; + } + write_json(&root.join(OPTIONS), &release.options)?; + instance.pid = Some(ready.pid); + instance.worker_birth_id = ready.worker_birth_id; + save_multi_instance_manifest(root, &instance)?; + resume(&release.options)?; + write_json(&root.join(READY), ready)?; + write_json(&root.join(RELEASE), &serde_json::Value::Null)?; + Ok(()) +} + +fn desktop_matches(identity: &CodexInstanceIdentity, deadline: Instant) -> anyhow::Result<()> { + // 外部 CDP 验证只读;调用方即使超时也不会留下延迟执行的 stop/spawn。 + let identity = identity.clone(); + let (sender, receiver) = mpsc::sync_channel(1); + std::thread::spawn(move || { + let result = tauri::async_runtime::block_on( + codex_plus_core::multi_worker::codex_instance_identity_matches(&identity), + ); + let _ = sender.send(result); + }); + let remaining = deadline.saturating_duration_since(Instant::now()); + ensure!(!remaining.is_zero(), "验证原 Codex 身份超时,可重试"); + ensure!( + receiver + .recv_timeout(remaining) + .context("验证原 Codex 身份超时,可重试")??, + "原 Codex 身份已变化或连接暂不可用,已保留桌面,可重试" + ); + Ok(()) +} + +pub(super) fn validate_worker_resume(options: &CodexMultiWorkerOptions) -> anyhow::Result<()> { + let candidate_request = std::env::var_os(HANDOFF_ENV) + .map(|value| -> anyhow::Result { + Ok(serde_json::from_str( + value.to_str().context("候选 worker 交接标记无效")?, + )?) + }) + .transpose()?; + let candidate = candidate_request + .as_ref() + .map(|request| { + worker_ready( + &request.worker.options_path, + options, + options + .resume_identity + .as_ref() + .context("候选缺少桌面身份")?, + BinaryStamp::current()?, + ) + }) + .transpose()?; + if let Some(ready) = &candidate { + validate_candidate(ready, true)?; + } + if let Some(identity) = &options.resume_identity { + ensure!( + options + .resume_token + .as_ref() + .is_some_and(|token| !token.is_empty()) + && identity.instance_id == options.instance_id + && identity.generation == options.generation + && identity.debug_port == options.debug_port + && identity.helper_port == options.helper_port + && Path::new(&options.user_data_dir).canonicalize()? == identity.user_data_dir + && desktop_executable(Path::new(&options.app_path)) == identity.executable, + "续接参数与原 Codex 身份不一致" + ); + desktop_matches(identity, Instant::now() + STAGE_TIMEOUT)?; + } + if let Some(ready) = &candidate { + validate_candidate(ready, false)?; + } + Ok(()) +} + +pub(super) fn confirm_resumed_identity( + identity: &CodexInstanceIdentity, + receipt: &codex_plus_launcher::LaunchReceipt, +) -> anyhow::Result<()> { + ensure!( + receipt.debug_port == identity.debug_port + && receipt.helper_port == identity.helper_port + && desktop_executable(&receipt.app_dir) == identity.executable, + "续接端口或应用发生变化,拒绝接管" + ); + desktop_matches(identity, Instant::now() + STAGE_TIMEOUT) +} + +fn desktop_executable(app_dir: &Path) -> PathBuf { + let executable = codex_plus_core::app_paths::build_codex_executable(app_dir); + executable.canonicalize().unwrap_or(executable) +} + +fn scoped_options( + root: &Path, + parent: &Path, + instance: &CodexMultiInstance, + legacy_parent: Option<&Path>, +) -> anyhow::Result { + let root = root.canonicalize()?; + let parent = parent.canonicalize()?; + ensure!( + root.parent() == Some(parent.as_path()) + && root.file_name().and_then(|name| name.to_str()) + == Some(multi_instance_slug(&instance.instance_id).as_str()), + "多开目录归属不匹配" + ); + let identity = instance + .identity + .as_ref() + .context("缺少原 Codex 完整身份")?; + let options_path = root.join(OPTIONS); + ensure!( + options_path.canonicalize()? == options_path, + "worker options 路径发生重定向" + ); + let mut options: CodexMultiWorkerOptions = serde_json::from_slice(&fs::read(options_path)?)?; + let state = root + .join("worker-home/.codex-session-delete") + .canonicalize()?; + let user_data = identity.user_data_dir.canonicalize()?; + let legacy_user_data = legacy_parent + .map(|parent| parent.join(multi_instance_slug(&instance.instance_id))) + .and_then(|path| path.canonicalize().ok()); + let configured_user_data = std::env::var_os("CODEX_ELECTRON_USER_DATA_PATH") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .map(|parent| { + parent + .join("multi") + .join(multi_instance_slug(&instance.instance_id)) + }) + .and_then(|path| path.canonicalize().ok()); + ensure!( + multi_manifest_identity_matches(instance, identity) + && options.instance_id == instance.instance_id + && options.generation == instance.generation + && instance.generation != 0 + && state.starts_with(&root) + && Path::new(&options.state_dir).canonicalize()? == state + && Path::new(&options.user_data_dir).canonicalize()? == user_data + && (user_data.starts_with(&root) + || legacy_user_data.as_ref() == Some(&user_data) + || configured_user_data.as_ref() == Some(&user_data)) + && instance + .codex_home + .as_ref() + .map(Path::new) + .map(Path::canonicalize) + .transpose()? + == Some(root.join("codex-home").canonicalize()?) + && instance + .sqlite_home + .as_ref() + .map(Path::new) + .map(Path::canonicalize) + .transpose()? + == Some(root.join("sqlite-home").canonicalize()?), + "manifest、窗口或隔离路径不完整,未触动旧 worker" + ); + ensure!( + root.join("codex-home").canonicalize()?.starts_with(&root) + && root.join("sqlite-home").canonicalize()?.starts_with(&root) + && root.join("worker-home").canonicalize()?.starts_with(&root), + "多开隔离目录发生重定向" + ); + // 不读取供应商内容或主配置,更不从主实例重建 worker 的 provider。 + ensure!( + state.join("settings.json").is_file(), + "缺少原 worker 设置,已保留旧 worker" + ); + let _settings = fs::File::open(state.join("settings.json"))?; + options.app_path = instance.app_path.clone().context("缺少原应用目录")?; + // WindowsApps 映像可能拒绝 metadata;实际 exe 归属由桌面身份核验。 + let _app_entries = fs::read_dir(&options.app_path).context("原 Codex 应用目录不可访问")?; + options.debug_port = identity.debug_port; + options.helper_port = identity.helper_port; + options.resume_identity = Some(identity.clone()); + options.resume_token = Some(new_token()); + Ok(options) +} + +fn legacy_parent(parent: &Path) -> Option { + let dirs = directories::BaseDirs::new()?; + let production = dirs.home_dir().join(".codex-session-delete/codex-multi"); + // 隔离 manager 不能以旧布局为由借用生产窗口。 + (parent.canonicalize().ok()? == production.canonicalize().ok()?) + .then(|| dirs.data_local_dir().join("CodexPlusPlus/multi")) +} + +fn ready_matches(ready: &WorkerReady, instance: &CodexMultiInstance, options_path: &Path) -> bool { + matches!(ready.protocol, 1 | HANDOFF_PROTOCOL) + && ready.pid != 0 + && Some(ready.pid) == instance.pid + && ready.worker_birth_id == instance.worker_birth_id + && ready.instance_id == instance.instance_id + && ready.generation == instance.generation + && !ready.token.is_empty() + && instance.identity.as_ref() == Some(&ready.identity) + && options_path.canonicalize().ok().as_ref() == Some(&ready.options_path) +} + +fn read_ready(root: &Path) -> anyhow::Result> { + match fs::read(root.join(READY)) { + Ok(bytes) => Ok(Some( + serde_json::from_slice(&bytes).context("worker ready 协议损坏")?, + )), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error.into()), + } +} + +fn worker_present(instance: &CodexMultiInstance, options_path: &Path) -> anyhow::Result { + let Some(pid) = instance.pid else { + return Ok(false); + }; + #[cfg(windows)] + { + codex_plus_core::multi_worker::verified_worker_is_running( + pid, + options_path, + instance.worker_birth_id, + ) + } + #[cfg(not(windows))] + { + let _ = options_path; + match codex_plus_core::watcher::inspect_process_instance(pid) { + codex_plus_core::watcher::ProcessInstanceState::NotRunning => Ok(false), + _ => anyhow::bail!("当前系统缺少精确 worker 核验接口,已保留旧 worker"), + } + } +} + +fn verify_legacy_services( + instance: &CodexMultiInstance, + options_path: &Path, +) -> anyhow::Result<()> { + verify_legacy_services_with( + || worker_present(instance, options_path), + || { + tauri::async_runtime::block_on(async { + let client = codex_plus_core::http_client::local_helper_client()?; + let response = client + .get(format!( + "http://127.0.0.1:{}/backend/status", + instance.helper_port + )) + .send() + .await? + .error_for_status()?; + let status: serde_json::Value = response.json().await?; + ensure!( + status["status"] == "ok" && status["transport"] == "http-helper", + "旧 worker 的 helper 尚未确认健康,已保留并延期交接" + ); + Ok::<_, anyhow::Error>(()) + }) + }, + ) +} + +fn verify_legacy_services_with( + mut present: impl FnMut() -> anyhow::Result, + healthy: impl FnOnce() -> anyhow::Result<()>, +) -> anyhow::Result<()> { + ensure!(present()?, "旧 worker 已退出"); + healthy()?; + ensure!(present()?, "健康检查期间旧 worker 已变化"); + Ok(()) +} + +fn generation_may_be_alive( + expected_birth: Option, + state: codex_plus_core::watcher::ProcessInstanceState, +) -> bool { + use codex_plus_core::watcher::ProcessInstanceState; + match state { + ProcessInstanceState::NotRunning => false, + ProcessInstanceState::Running { + birth_id: Some(birth), + .. + } => !expected_birth + .zip(birth.parse::().ok()) + .is_some_and(|(a, b)| a != b), + _ => true, + } +} + +fn worker_could_hold_handles(root: &Path) -> bool { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct WorkerHint { + #[serde(default)] + pid: Option, + #[serde(default)] + worker_birth_id: Option, + } + let Ok(bytes) = fs::read(multi_instance_manifest_path(root)) else { + return false; + }; + let Ok(hint) = serde_json::from_slice::(&bytes) else { + return false; + }; + hint.pid.is_some_and(|pid| { + generation_may_be_alive( + hint.worker_birth_id, + codex_plus_core::watcher::inspect_process_instance(pid), + ) + }) +} + +fn check_active(cancel: &AtomicBool, deadline: Instant) -> anyhow::Result<()> { + ensure!( + !crate::app_is_exiting() && !cancel.load(Ordering::Acquire), + "管理器恢复已取消,原 Codex 保持运行,可重试" + ); + ensure!( + Instant::now() < deadline, + "worker 交接阶段超过 90 秒,可重试" + ); + Ok(()) +} + +fn same_generation(current: &CodexMultiInstance, original: &CodexMultiInstance) -> bool { + current.instance_id == original.instance_id + && current.generation == original.generation + && current.identity == original.identity +} + +struct PendingWorker { + child: Child, + armed: bool, +} + +impl PendingWorker { + fn reclaim(&mut self) -> anyhow::Result<()> { + if self.child.try_wait()?.is_some() { + self.armed = false; + return Ok(()); + } + // 持有本次 spawn 的 Child 句柄;绝不按 PID 或程序名结束其他进程。 + self.child.kill().context("回收本次未就绪 worker 失败")?; + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + if self.child.try_wait()?.is_some() { + self.armed = false; + return Ok(()); + } + std::thread::sleep(POLL); + } + anyhow::bail!("本次未就绪 worker 尚未确认退出") + } +} + +impl Drop for PendingWorker { + fn drop(&mut self) { + if self.armed { + let _ = self.reclaim(); + } + } +} + +fn spawn_resume( + root: &Path, + binary: &BinaryStamp, + options: &CodexMultiWorkerOptions, + instance: &CodexMultiInstance, + handoff: Option<&HandoffRequest>, +) -> anyhow::Result { + ensure!( + BinaryStamp::at(&binary.executable)? == *binary, + "新版二进制预检后发生变化,可重试" + ); + let mut command = Command::new(&binary.executable); + command.env_remove(HANDOFF_ENV); + if let Some(request) = handoff { + command.env(HANDOFF_ENV, serde_json::to_string(request)?); + } + command + .arg("--codex-plus-multi-worker") + .arg(root.join(OPTIONS)) + .env("HOME", root.join("worker-home")) + .env("CODEX_HOME", root.join("codex-home")) + .env("CODEX_SQLITE_HOME", root.join("sqlite-home")) + .env("CODEX_ELECTRON_USER_DATA_PATH", &options.user_data_dir) + .env("CODEX_PLUS_MULTI_INSTANCE", "1") + .env("CODEX_PLUS_INSTANCE_ID", &instance.instance_id) + .env("CODEX_PLUS_INSTANCE_LABEL", &instance.profile_name) + .env( + "CODEX_PLUS_LAUNCHER_GUARD_PORT", + select_multi_instance_port(0, &format!("{}-guard", instance.instance_id)).to_string(), + ) + .env( + "CODEX_PLUS_PROTOCOL_PROXY_PORT", + options.helper_port.to_string(), + ) + .env(codex_plus_core::paths::STATE_DIR_ENV, &options.state_dir) + .env( + "XDG_CONFIG_HOME", + directories::BaseDirs::new() + .context("无法定位共享脚本目录")? + .home_dir() + .join(".config"), + ) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(codex_plus_core::windows_create_no_window()); + } + Ok(PendingWorker { + child: command.spawn()?, + armed: true, + }) +} + +fn resume_one(root: &Path, parent: &Path, cancel: &AtomicBool) -> anyhow::Result<&'static str> { + let root = root.canonicalize()?; + ensure!( + root.parent() == Some(parent.canonicalize()?.as_path()), + "实例目录越界" + ); + let lock = open_resume_lock(&root)?; + lock.try_lock() + .context("另一个管理器正在交接此 worker,可重试")?; + let mut instance = load_multi_instance_manifest(&root)?; + let options = scoped_options(&root, parent, &instance, legacy_parent(parent).as_deref())?; + let binary = BinaryStamp::current()?; + let options_path = root.join(OPTIONS); + let identity = options.resume_identity.as_ref().context("缺少续接身份")?; + let preflight_deadline = Instant::now() + STAGE_TIMEOUT; + check_active(cancel, preflight_deadline)?; + desktop_matches(identity, preflight_deadline)?; + let mut handoff = None; + if worker_present(&instance, &options_path)? { + if let Some(ready) = read_ready(&root)? { + ensure!( + ready_matches(&ready, &instance, &options_path), + "旧 worker 协议身份不一致,已保留原 worker,可重试" + ); + if ready.protocol == 1 { + verify_legacy_services(&instance, &options_path) + .context("legacy worker 健康状态不明确,已保留并延期交接")?; + desktop_matches(identity, preflight_deadline)?; + check_active(cancel, preflight_deadline)?; + return Ok("reused_legacy"); + } + if ready.binary == binary + && !read_json::(&root.join(RELEASE)) + .is_some_and(|release| release.request.worker == ready) + { + return Ok("reused"); + } + ensure!( + BinaryStamp::current()? == binary, + "新版二进制已变化,保留旧 worker" + ); + check_active(cancel, preflight_deadline)?; + let issued_at_ms = current_timestamp_ms(); + let request = HandoffRequest { + protocol: HANDOFF_PROTOCOL, + token: new_token(), + worker: ready, + requester_pid: std::process::id(), + requester_birth: process_birth(std::process::id()) + .context("无法核验发起 manager 的创建身份,未请求交接")?, + issued_at_ms, + expires_at_ms: issued_at_ms.saturating_add(HANDOFF_LIFETIME.as_millis() as u64), + successor_token: options.resume_token.clone().context("缺少续接 token")?, + successor_binary: binary.clone(), + }; + handoff = Some(PendingHandoff { + root: root.clone(), + request, + }); + let request = &handoff.as_ref().unwrap().request; + write_json(&root.join(REQUEST), request)?; + let release_deadline = Instant::now() + STAGE_TIMEOUT; + loop { + check_active(cancel, release_deadline)?; + ensure!( + worker_present(&instance, &options_path)?, + "旧 worker 在交接提交前退出,未继续启动" + ); + if read_json::(&root.join(RELEASE)) + .is_some_and(|release| release.request == *request && release.released) + { + break; + } + std::thread::sleep(POLL); + } + } else { + verify_legacy_services(&instance, &options_path) + .context("无 ready 的 legacy worker 尚未确认健康,已保留并延期交接")?; + desktop_matches(identity, preflight_deadline)?; + check_active(cancel, preflight_deadline)?; + return Ok("reused_legacy"); + } + } + let ready_deadline = Instant::now() + STAGE_TIMEOUT; + check_active(cancel, ready_deadline)?; + desktop_matches(identity, ready_deadline)?; + { + let _guard = multi_instance_mutation_lock() + .lock() + .unwrap_or_else(|e| e.into_inner()); + let current = load_multi_instance_manifest(&root)?; + ensure!( + same_generation(¤t, &instance) && current.pid == instance.pid, + "交接期间实例代次或 worker 发生变化,可重试" + ); + write_json(&options_path, &options)?; + } + check_active(cancel, ready_deadline)?; + let mut pending = spawn_resume( + &root, + &binary, + &options, + &instance, + handoff.as_ref().map(|handoff| &handoff.request), + )?; + let new_pid = pending.child.id(); + let new_birth = codex_plus_core::multi_worker::worker_birth_id(&pending.child); + let result = (|| { + { + let _guard = multi_instance_mutation_lock() + .lock() + .unwrap_or_else(|e| e.into_inner()); + let current = load_multi_instance_manifest(&root)?; + ensure!( + same_generation(¤t, &instance) && current.pid == instance.pid, + "启动续接 worker 时清单已变化" + ); + instance.pid = Some(new_pid); + instance.worker_birth_id = new_birth; + save_multi_instance_manifest(&root, &instance)?; + } + loop { + check_active(cancel, ready_deadline)?; + ensure!( + pending.child.try_wait()?.is_none(), + "新版 worker 提前退出,原桌面保持运行,可重试" + ); + if let Ok(Some(ready)) = read_ready(&root) { + if ready_matches(&ready, &instance, &options_path) + && options.resume_token.as_ref() == Some(&ready.token) + && ready.binary == binary + { + desktop_matches(identity, ready_deadline)?; + let _guard = multi_instance_mutation_lock() + .lock() + .unwrap_or_else(|e| e.into_inner()); + let current = load_multi_instance_manifest(&root)?; + ensure!( + same_generation(¤t, &instance) && current.pid == Some(new_pid), + "续接期间清单已变化,已回收本次 worker" + ); + check_active(cancel, ready_deadline)?; + if let Some(handoff) = &handoff { + ensure!( + request_active(&root, &handoff.request, &handoff.request.worker), + "交接请求已过期或撤销,未提交候选 worker" + ); + write_json( + &root.join(COMMIT), + &HandoffCommit { + request: handoff.request.clone(), + successor: ready, + }, + )?; + } + return Ok("resumed"); + } + } + std::thread::sleep(POLL); + } + })(); + match result { + Ok(outcome) => { + pending.armed = false; + Ok(outcome) + } + Err(error) => { + let cleanup = pending.reclaim(); + if cleanup.is_ok() { + let _guard = multi_instance_mutation_lock() + .lock() + .unwrap_or_else(|e| e.into_inner()); + if let Ok(mut current) = load_multi_instance_manifest(&root) { + if same_generation(¤t, &instance) && current.pid == Some(new_pid) { + current.pid = None; + current.worker_birth_id = None; + // 保留原 desktop identity 与隔离配置,重开 manager 即可重试 attach。 + save_multi_instance_manifest(&root, ¤t)?; + } + } + } + match cleanup { + Ok(()) => Err(error), + Err(cleanup) => Err(anyhow::anyhow!( + "{error:#};未就绪 worker 回收失败:{cleanup:#}" + )), + } + } + } +} + +fn worker_resume_roots(parent: &Path, windows: bool) -> Vec { + if !windows { + return Vec::new(); + } + fs::read_dir(parent) + .into_iter() + .flatten() + .flatten() + .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())) + .map(|entry| entry.path()) + .filter(|root| root.join("instance.json").is_file()) + .collect() +} + +/// Windows worker 并行交接后恢复主服务;其他平台保留原 worker,不设全局门槛。 +pub(crate) fn resume_on_manager_start(app: tauri::AppHandle, start_if_missing: bool) { + tauri::async_runtime::spawn_blocking(move || { + let parent = codex_plus_core::paths::default_app_state_dir().join("codex-multi"); + let cancel = Arc::new(AtomicBool::new(false)); + let (sender, receiver) = mpsc::channel(); + let mut count = 0; + for root in worker_resume_roots(&parent, cfg!(windows)) { + count += 1; + let sender = sender.clone(); + let parent = parent.clone(); + let cancel = cancel.clone(); + std::thread::spawn(move || { + let result = + (|| { + let instance = load_multi_instance_manifest(&root)?; + // 确认桌面退出才跳过;缺身份/临时 CDP 失联必须报告而非误认停止。 + if instance.identity.as_ref().is_some_and( + codex_plus_core::multi_worker::codex_instance_process_exited, + ) || (instance.identity.is_none() && !worker_could_hold_handles(&root)) + { + return Ok("not_running"); + } + resume_one(&root, &parent, &cancel) + })(); + let outcome = json!({ + "status": result.as_ref().copied().unwrap_or("deferred"), + "retryable": result.is_err(), + "message": result.as_ref().err().map(|error| format!("{error:#}")), + "finishedAtMs": current_timestamp_ms(), + }); + let _ = write_json(&root.join(OUTCOME), &outcome); + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "manager.multi_worker_resume", + &outcome, + ); + // 已退出的坏历史不阻断主恢复;仍活着的失败 worker 可能遗留监听。 + let _ = sender.send(result.is_ok() || !worker_could_hold_handles(&root)); + }); + } + drop(sender); + // 三个阶段各 90s;实例并行,不按实例数量累加等待。 + let deadline = Instant::now() + STAGE_TIMEOUT * 3 + Duration::from_secs(5); + let mut complete = true; + for _ in 0..count { + match receiver.recv_timeout(deadline.saturating_duration_since(Instant::now())) { + Ok(ok) => complete &= ok, + Err(_) => { + complete = false; + cancel.store(true, Ordering::Release); + break; + } + } + } + if !complete { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "manager.background_restore_deferred", + json!({"message": "多开交接未完成;保留原桌面,主 helper/Gateway 恢复已延期,可重开管理器重试"}), + ); + } else if !crate::app_is_exiting() { + resume_existing_codex_background(app.clone(), start_if_missing); + crate::gateway_commands::start_from_saved_state(); + crate::gateway_commands::start_model_watch(); + crate::auto_takeover::start_supervisor(); + } + let _ = app.emit("runtime-health-changed", ()); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn identity(root: &Path) -> CodexInstanceIdentity { + CodexInstanceIdentity { + instance_id: "test".into(), + generation: 7, + debug_port: 49101, + helper_port: 49102, + user_data_dir: root.into(), + browser_id: "browser".into(), + process_id: 123, + process_birth_id: 456, + executable: root.join("Codex.exe"), + } + } + + #[test] + fn handoff_requires_every_ready_field_and_exact_options_path() { + let temp = tempfile::tempdir().unwrap(); + let options = temp.path().join(OPTIONS); + fs::write(&options, b"{}").unwrap(); + let ready = WorkerReady { + protocol: HANDOFF_PROTOCOL, + instance_id: "test".into(), + generation: 7, + pid: 100, + worker_birth_id: Some(1000), + token: "fresh".into(), + options_path: options.canonicalize().unwrap(), + identity: identity(temp.path()), + binary: BinaryStamp { + executable: temp.path().join("manager.exe"), + size: 100, + modified_ns: "2".into(), + version: "test".into(), + }, + }; + assert!(!handoff_requested(&options, &ready, || panic!( + "no request" + ))); + let request = request_fixture(&ready); + assert!(request_matches(&request, &ready, &options, 1000)); + let value = serde_json::to_value(&ready).unwrap(); + for field in [ + "pid", + "generation", + "token", + "optionsPath", + "identity", + "workerBirthId", + "binary", + ] { + let mut invalid = value.clone(); + invalid[field] = serde_json::Value::Null; + let mut invalid_request = serde_json::to_value(&request).unwrap(); + invalid_request["worker"] = invalid; + write_json(&temp.path().join(REQUEST), &invalid_request).unwrap(); + assert!( + read_json::(&temp.path().join(REQUEST)) + .is_none_or(|request| !request_matches(&request, &ready, &options, 1000)), + "{field}" + ); + } + // v1 是直接比较完整 WorkerReady;v2 使用不同 envelope,旧版无法误消费。 + write_json(&temp.path().join(REQUEST), &ready).unwrap(); + assert!(read_json::(&temp.path().join(REQUEST)).is_none()); + assert!( + serde_json::from_value::(serde_json::to_value(&request).unwrap()).is_err() + ); + let mut wrong = ready.clone(); + wrong.token = "stale".into(); + assert!(!request_matches(&request, &wrong, &options, 1000)); + assert!(!request_matches( + &request, + &ready, + &temp.path().join("other"), + 1000 + )); + } + + fn request_fixture(ready: &WorkerReady) -> HandoffRequest { + HandoffRequest { + protocol: HANDOFF_PROTOCOL, + token: "request".into(), + worker: ready.clone(), + requester_pid: 999, + requester_birth: "9999".into(), + issued_at_ms: 1000, + expires_at_ms: 1000 + HANDOFF_LIFETIME.as_millis() as u64, + successor_token: "successor".into(), + successor_binary: ready.binary.clone(), + } + } + + fn ready_fixture(root: &Path) -> WorkerReady { + let options_path = root.join(OPTIONS); + fs::write(&options_path, b"{}").unwrap(); + WorkerReady { + protocol: HANDOFF_PROTOCOL, + instance_id: "test".into(), + generation: 7, + pid: 100, + worker_birth_id: Some(1000), + token: "original".into(), + options_path: options_path.canonicalize().unwrap(), + identity: identity(root), + binary: BinaryStamp { + executable: root.join("manager.exe"), + size: 100, + modified_ns: "2".into(), + version: "test".into(), + }, + } + } + + #[test] + fn expired_future_overlong_and_legacy_requests_never_release_services() { + let temp = tempfile::tempdir().unwrap(); + let ready = ready_fixture(temp.path()); + let request = request_fixture(&ready); + assert!(!request_matches(&request, &ready, &ready.options_path, 999)); + assert!(!request_matches( + &request, + &ready, + &ready.options_path, + request.expires_at_ms + )); + let mut overlong = request.clone(); + overlong.expires_at_ms += 1; + assert!(!request_matches( + &overlong, + &ready, + &ready.options_path, + 1000 + )); + let mut legacy = ready.clone(); + legacy.protocol = 1; + let legacy_request = request_fixture(&legacy); + assert!(!request_matches( + &legacy_request, + &legacy, + &legacy.options_path, + 1000 + )); + write_json(&temp.path().join(REQUEST), &request).unwrap(); + assert!(!handoff_requested(&ready.options_path, &ready, || panic!( + "expired request" + ))); + assert!(!temp.path().join(RELEASE).exists()); + } + + #[test] + fn cancellation_revokes_only_its_request_and_process_or_lock_loss_invalidates_it() { + let temp = tempfile::tempdir().unwrap(); + let ready = ready_fixture(temp.path()); + let mut request = request_fixture(&ready); + request.requester_pid = std::process::id(); + request.requester_birth = process_birth(request.requester_pid).unwrap(); + request.issued_at_ms = current_timestamp_ms(); + request.expires_at_ms = request.issued_at_ms + HANDOFF_LIFETIME.as_millis() as u64; + let lock = open_resume_lock(temp.path()).unwrap(); + lock.try_lock().unwrap(); + write_json(&temp.path().join(REQUEST), &request).unwrap(); + assert!(request_active(temp.path(), &request, &ready)); + let pending = PendingHandoff { + root: temp.path().to_path_buf(), + request: request.clone(), + }; + drop(pending); + assert!(!request_active(temp.path(), &request, &ready)); + write_json(&temp.path().join(REQUEST), &request).unwrap(); + drop(lock); + assert!(!request_active(temp.path(), &request, &ready)); + let lock = open_resume_lock(temp.path()).unwrap(); + lock.try_lock().unwrap(); + let mut recycled = request.clone(); + recycled.requester_birth.push('0'); + write_json(&temp.path().join(REQUEST), &recycled).unwrap(); + assert!(!request_active(temp.path(), &recycled, &ready)); + let mut exited = request.clone(); + exited.requester_pid = u32::MAX; + write_json(&temp.path().join(REQUEST), &exited).unwrap(); + assert!(!request_active(temp.path(), &exited, &ready)); + let mut replacement = request.clone(); + replacement.token = "different-request".into(); + write_json(&temp.path().join(REQUEST), &replacement).unwrap(); + drop(PendingHandoff { + root: temp.path().to_path_buf(), + request, + }); + assert_eq!( + read_json::(&temp.path().join(REQUEST)), + Some(replacement) + ); + } + + #[test] + fn cancellation_after_release_reclaims_only_its_candidate_and_retries_restore() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + let ready = ready_fixture(root); + let request = request_fixture(&ready); + let original: CodexMultiWorkerOptions = serde_json::from_value(json!({ + "instanceId": "test", "generation": 7, "appPath": root, + "userDataDir": root, "stateDir": root, + "debugPort": 49101, "helperPort": 49102, + })) + .unwrap(); + let mut candidate = original.clone(); + candidate.resume_token = Some(request.successor_token.clone()); + candidate.resume_identity = Some(ready.identity.clone()); + write_json(&root.join(OPTIONS), &candidate).unwrap(); + let instance: CodexMultiInstance = serde_json::from_value(json!({ + "instanceId": "test", "generation": 7, "identity": ready.identity, + "profileId": "test", "profileName": "test", "pid": ready.pid, + "workerBirthId": ready.worker_birth_id, "debugPort": 49101, "helperPort": 49102, + "status": "running", "running": true, "startedAtMs": 1, "isMain": false, + })) + .unwrap(); + save_multi_instance_manifest(root, &instance).unwrap(); + // manager 在 spawn 后、清单写入前退出:仅候选自己的持久化注册可发现它。 + let mut registered = ready.clone(); + registered.pid = 200; + registered.worker_birth_id = Some(2000); + registered.token = request.successor_token.clone(); + write_json(&root.join(CANDIDATE), ®istered).unwrap(); + let release = HandoffRelease { + request, + options: original, + released: true, + }; + write_json(&root.join(RELEASE), &release).unwrap(); + let lock = open_resume_lock(root).unwrap(); + lock.try_lock().unwrap(); + assert!( + restore_released_worker_with( + root, + &ready, + &release, + |_, _, _| panic!("manager still owns transaction"), + |_| panic!("manager still owns transaction") + ) + .is_err() + ); + drop(lock); + let stopped = std::cell::Cell::new(false); + let result = restore_released_worker_with( + root, + &ready, + &release, + |pid, path, birth| { + assert_eq!((pid, birth), (200, Some(2000))); + assert_eq!(path, root.join(OPTIONS)); + stopped.set(true); + Ok(()) + }, + |options| { + assert!(stopped.get()); + assert!(options.resume_token.is_none()); + assert_eq!(load_multi_instance_manifest(root)?.pid, Some(ready.pid)); + anyhow::bail!("temporary attach failure") + }, + ); + assert!(result.is_err()); + assert!(read_json::(&root.join(RELEASE)).is_some()); + restore_released_worker_with( + root, + &ready, + &release, + |_, _, _| panic!("candidate already reclaimed"), + |_| Ok(()), + ) + .unwrap(); + assert!(read_json::(&root.join(RELEASE)).is_none()); + assert_eq!(read_ready(root).unwrap(), Some(ready)); + } + + #[test] + fn candidate_registration_rejects_lost_requester_and_commit_survives_revoke() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + let old = ready_fixture(root); + let mut request = request_fixture(&old); + request.requester_pid = std::process::id(); + request.requester_birth = process_birth(request.requester_pid).unwrap(); + request.issued_at_ms = current_timestamp_ms(); + request.expires_at_ms = request.issued_at_ms + HANDOFF_LIFETIME.as_millis() as u64; + let mut candidate = old.clone(); + candidate.pid = 200; + candidate.worker_birth_id = Some(2000); + candidate.token = request.successor_token.clone(); + let lock = open_resume_lock(root).unwrap(); + lock.try_lock().unwrap(); + write_json(&root.join(REQUEST), &request).unwrap(); + validate_candidate_request(&request, &candidate, true).unwrap(); + assert_eq!( + read_json::(&root.join(CANDIDATE)), + Some(candidate.clone()) + ); + // 模拟注册后 manager 退出/释放事务锁,发布 ready 必须失败。 + drop(lock); + assert!(validate_candidate_request(&request, &candidate, false).is_err()); + let lock = open_resume_lock(root).unwrap(); + lock.try_lock().unwrap(); + for change in 0..3 { + let mut invalid = request.clone(); + match change { + 0 => invalid.requester_birth.push('0'), + 1 => invalid.requester_pid = u32::MAX, + _ => invalid.expires_at_ms = current_timestamp_ms(), + } + write_json(&root.join(REQUEST), &invalid).unwrap(); + assert!(validate_candidate_request(&invalid, &candidate, true).is_err()); + } + write_json(&root.join(REQUEST), &serde_json::Value::Null).unwrap(); + assert!(validate_candidate_request(&request, &candidate, false).is_err()); + write_json( + &root.join(COMMIT), + &HandoffCommit { + request: request.clone(), + successor: candidate.clone(), + }, + ) + .unwrap(); + // 已提交代次不再依赖 manager 的请求、生命周期或 deadline。 + drop(lock); + validate_candidate_request(&request, &candidate, false).unwrap(); + candidate.worker_birth_id = Some(2001); + assert!(validate_candidate_request(&request, &candidate, false).is_err()); + } + + #[test] + fn successor_commit_requires_exact_generation_token_binary_and_identity() { + let temp = tempfile::tempdir().unwrap(); + let ready = ready_fixture(temp.path()); + let request = request_fixture(&ready); + let mut successor = ready.clone(); + successor.pid = 200; + successor.worker_birth_id = Some(2000); + successor.token = request.successor_token.clone(); + assert!(successor_matches(&request, &successor)); + for change in 0..5 { + let mut wrong = successor.clone(); + match change { + 0 => wrong.generation += 1, + 1 => wrong.token = "foreign".into(), + 2 => wrong.binary.size += 1, + 3 => wrong.identity.process_birth_id += 1, + _ => wrong.pid = ready.pid, + } + assert!(!successor_matches(&request, &wrong)); + } + } + + #[test] + fn committed_handoff_never_restores_old_services_but_revoked_release_does() { + assert!( + finish_handoff(true, false, || panic!("committed successor owns services")).unwrap() + ); + assert!(!finish_handoff(false, true, || panic!("manager still holds lease")).unwrap()); + let restored = std::cell::Cell::new(false); + assert!( + !finish_handoff(false, false, || { + restored.set(true); + Ok(()) + }) + .unwrap() + ); + assert!(restored.get()); + assert!(finish_handoff(false, false, || anyhow::bail!("retry restore")).is_err()); + } + + #[test] + fn legacy_reuse_requires_healthy_helper_between_two_identity_checks() { + let checks = std::cell::Cell::new(0); + verify_legacy_services_with( + || { + checks.set(checks.get() + 1); + Ok(true) + }, + || { + assert_eq!(checks.get(), 1); + Ok(()) + }, + ) + .unwrap(); + assert_eq!(checks.get(), 2); + assert!( + verify_legacy_services_with( + || Ok(false), + || panic!("unverified process cannot authorize helper reuse"), + ) + .is_err() + ); + assert!( + verify_legacy_services_with( + || Ok(true), + || anyhow::bail!("unhealthy helper; keep worker and defer"), + ) + .is_err() + ); + let checks = std::cell::Cell::new(0); + assert!( + verify_legacy_services_with( + || { + checks.set(checks.get() + 1); + Ok(checks.get() == 1) + }, + || Ok(()), + ) + .is_err() + ); + } + + #[test] + fn non_windows_never_waits_on_existing_workers_before_starting_main_services() { + let temp = tempfile::tempdir().unwrap(); + let worker = temp.path().join("live-worker"); + fs::create_dir(&worker).unwrap(); + // 即使存在损坏/不可核验的历史清单,非 Windows 的等待集合也必须为空。 + fs::write(worker.join("instance.json"), b"unknown legacy worker").unwrap(); + assert_eq!(worker_resume_roots(temp.path(), true), vec![worker]); + assert!(worker_resume_roots(temp.path(), false).is_empty()); + assert!(worker_resume_roots(&temp.path().join("missing"), false).is_empty()); + } + + #[test] + fn binary_stamp_does_not_reuse_replaced_same_path() { + let temp = tempfile::tempdir().unwrap(); + let exe = temp.path().join("manager.exe"); + fs::write(&exe, b"old").unwrap(); + let before = BinaryStamp::at(&exe).unwrap(); + fs::write(&exe, b"new worker build").unwrap(); + assert_ne!(before, BinaryStamp::at(&exe).unwrap()); + } + + #[test] + fn legacy_options_default_to_normal_start_and_resume_requires_token() { + let options: CodexMultiWorkerOptions = serde_json::from_value(json!({ + "appPath": "old", "debugPort": 1, "helperPort": 2, + })) + .unwrap(); + assert!(options.resume_identity.is_none()); + assert!(options.resume_token.is_none()); + let mut resume = options; + resume.resume_identity = Some(identity(Path::new("unused"))); + assert!(validate_worker_resume(&resume).is_err()); + } + + #[test] + fn dead_and_reused_worker_generations_do_not_block_main_restore() { + use codex_plus_core::watcher::ProcessInstanceState; + assert!(!generation_may_be_alive( + Some(1), + ProcessInstanceState::NotRunning + )); + assert!(!generation_may_be_alive( + Some(1), + ProcessInstanceState::Running { + started_at_secs: None, + birth_id: Some("2".into()), + } + )); + assert!(generation_may_be_alive( + Some(1), + ProcessInstanceState::Running { + started_at_secs: None, + birth_id: Some("1".into()), + } + )); + assert!(generation_may_be_alive( + Some(1), + ProcessInstanceState::Unknown + )); + let temp = tempfile::tempdir().unwrap(); + fs::write( + temp.path().join("instance.json"), + b"invalid historical json", + ) + .unwrap(); + assert!(!worker_could_hold_handles(temp.path())); + } + + #[test] + fn cancelled_or_expired_stage_stops_before_next_side_effect() { + assert!(check_active(&AtomicBool::new(true), Instant::now() + STAGE_TIMEOUT).is_err()); + assert!(check_active(&AtomicBool::new(false), Instant::now()).is_err()); + } +} diff --git a/apps/codex-plus-manager/src-tauri/src/lib.rs b/apps/codex-plus-manager/src-tauri/src/lib.rs index 1a4026b38d..b69d099cdf 100644 --- a/apps/codex-plus-manager/src-tauri/src/lib.rs +++ b/apps/codex-plus-manager/src-tauri/src/lib.rs @@ -241,15 +241,10 @@ pub fn run() { cu_pip::start(app.handle().clone()); commands::start_weixin_connect_from_saved_settings(); register_main_window_events(main_window); - commands::resume_existing_codex_background( + commands::multi_instance_resume::resume_on_manager_start( app.handle().clone(), start_codex_at_login || start_codex_on_open, ); - // 网关模式:有 Agent 走网关时常驻 helper 必须在线。 - gateway_commands::start_from_saved_state(); - gateway_commands::start_model_watch(); - // 自动接管官方 App(默认关闭):设置里开着才起守护线程。 - auto_takeover::start_supervisor(); if let Err(error) = global_dictation::apply_config() { let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( "dictation.global_hotkey_start_failed", diff --git a/apps/codex-plus-manager/src-tauri/src/runtime_health.rs b/apps/codex-plus-manager/src-tauri/src/runtime_health.rs index 1293e4e158..60718af20c 100644 --- a/apps/codex-plus-manager/src-tauri/src/runtime_health.rs +++ b/apps/codex-plus-manager/src-tauri/src/runtime_health.rs @@ -39,13 +39,19 @@ impl RuntimeHealth { /// 仅在读取概览时检查本地端点,不启动服务、不请求供应商,也不创建后台监控线程。 pub fn inspect(settings: &BackendSettings, latest: Option<&LaunchStatus>) -> RuntimeHealth { - inspect_with( + let running = crate::commands::main_codex_running(latest); + let mut health = inspect_with( settings, latest, - !codex_plus_core::watcher::find_codex_processes().is_empty(), + running.unwrap_or(false), helper_available, codex_plus_core::cdp::endpoint_available, - ) + ); + if running.is_none() { + health.codex_app.status = "not_checked"; + health.codex_app.message = "无法确认主 Codex 身份,请重新连接或手动关闭后启动"; + } + health } fn inspect_with( diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index 61cf0a29c0..ae32d694e0 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -2719,7 +2719,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); @@ -2736,7 +2736,7 @@ export function App() { setMultiLaunchActionPending(instance.instanceId); try { const result = await run(() => call("restart_codex_multi_instance", { - request: { instanceId: instance.instanceId }, + request: { instanceId: instance.instanceId, generation: instance.generation }, })); if (result) showNotice(t("重启 Codex 实例"), result.message, result.status); await refreshMultiLaunchInstances(true); @@ -2752,7 +2752,7 @@ export function App() { setMultiLaunchActionPending(instance.instanceId); try { const result = await run(() => call>("stop_codex_multi_instance", { - request: { instanceId: instance.instanceId }, + request: { instanceId: instance.instanceId, generation: instance.generation }, })); if (result) showNotice(t("结束 Codex 实例"), result.message, result.status); await refreshMultiLaunchInstances(true); @@ -2835,12 +2835,13 @@ export function App() { }; const launchCommand = async (command: "launch_codex_plus" | "restart_codex_plus" | "stop_codex_plus", syncActiveRelay = false) => { + const currentLaunch = command === "launch_codex_plus" ? null : overview?.latest_launch; const result = await run(() => call(command, { request: { - appPath: launchForm.appPath, - debugPort: numberOrDefault(launchForm.debugPort, 9229), - helperPort: numberOrDefault(launchForm.helperPort, 57321), + appPath: currentLaunch?.codex_app || launchForm.appPath, + debugPort: currentLaunch?.debug_port ?? numberOrDefault(launchForm.debugPort, 9229), + helperPort: currentLaunch?.helper_port ?? numberOrDefault(launchForm.helperPort, 57321), syncActiveRelay, }, }), @@ -7273,6 +7274,7 @@ function CodexMultiLaunchScreen({ {actions.multiLaunchInstances.map((instance) => { const status = multiLaunchInstanceStatus(instance); const running = multiLaunchInstanceRunning(instance); + const canStop = running || status === "unknown"; const pending = actions.multiLaunchActionPending === instance.instanceId; const label = formatMultiLaunchInstanceLabel(instance, { main: t("主 Codex"), fallback: t("Codex 实例") }); // 启动失败的原因必须让用户看得见:Windows 上多开曾经完全不吭声。 @@ -7282,7 +7284,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 (
@@ -7303,10 +7305,10 @@ function CodexMultiLaunchScreen({ {pending ? t("处理中") : t("重启")}