From ce8d4dded5a7d11d0da9f43c0a270af90541932d Mon Sep 17 00:00:00 2001 From: Yuimi_chaya <124485273+Yuimi-chaya@users.noreply.github.com> Date: Sat, 26 Sep 2026 22:42:36 +0800 Subject: [PATCH 1/3] fix: make launch and restart lifecycle deterministic --- .../src-tauri/src/commands.rs | 310 +++++++++--------- apps/codex-plus-manager/src/App.tsx | 101 +++--- .../src/launch-status.test.ts | 11 +- apps/codex-plus-manager/src/launch-status.ts | 12 + crates/codex-plus-core/src/native_browser.rs | 94 +++++- crates/codex-plus-core/src/watcher.rs | 10 + 6 files changed, 327 insertions(+), 211 deletions(-) diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index d47e3d325..540896572 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -807,14 +807,70 @@ pub async fn load_overview() -> CommandResult { } #[tauri::command] -pub fn launch_codex_plus(request: LaunchRequest) -> CommandResult { - spawn_codex_plus_launch(request, "启动任务已在后台开始,可稍后查看概览状态。") +pub async fn launch_codex_plus(request: LaunchRequest) -> CommandResult { + match tauri::async_runtime::spawn_blocking(move || { + let _guard = match try_acquire_launch_guard() { + Ok(guard) => guard, + Err(message) => return failed(message, json!({})), + }; + spawn_codex_plus_launch(request, "启动任务已在后台开始,可稍后查看概览状态。") + }).await { + Ok(result) => result, + Err(error) => failed(&format!("启动后台任务失败:{error}"), json!({})), + } } #[tauri::command] -pub fn restart_codex_plus(request: LaunchRequest) -> CommandResult { - let Ok(_guard) = relay_switch_mutex().lock() else { - return failed("供应商切换锁已损坏,请重启管理器后再试。", json!({})); +pub async fn restart_codex_plus(request: LaunchRequest) -> CommandResult { + match tauri::async_runtime::spawn_blocking(move || restart_codex_plus_blocking(request)).await { + Ok(result) => result, + Err(error) => failed(&format!("重启后台任务失败:{error}"), json!({})), + } +} + +static LAUNCH_IN_PROGRESS: AtomicBool = AtomicBool::new(false); + +struct LaunchGuard; + +impl Drop for LaunchGuard { + fn drop(&mut self) { + LAUNCH_IN_PROGRESS.store(false, Ordering::Release); + } +} + +fn try_acquire_launch_guard() -> Result { + LAUNCH_IN_PROGRESS.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) + .map(|_| LaunchGuard) + .map_err(|_| "启动或重启正在进行,请等待本次操作完成。") +} + +#[derive(Debug, PartialEq, Eq)] +enum RestartDisposition { + LaunchOnly, + StopAndRestart, +} + +fn restart_disposition(sync_active_relay: bool, app_running: bool) -> RestartDisposition { + if !sync_active_relay && !app_running { + RestartDisposition::LaunchOnly + } else { + RestartDisposition::StopAndRestart + } +} + +fn restart_codex_plus_blocking(request: LaunchRequest) -> CommandResult { + let _launch_guard = match try_acquire_launch_guard() { + Ok(guard) => guard, + Err(message) => return failed(message, json!({})), + }; + let request = normalize_launch_request(request); + // macOS scopes by debug port; other platforms detect all Codex desktop processes. + let app_running = !codex_plus_core::watcher::find_codex_processes_for_debug_port(request.debug_port).is_empty(); + if restart_disposition(request.sync_active_relay, app_running) == RestartDisposition::LaunchOnly { + return spawn_codex_plus_launch(request, "Codex 未运行,已按直接启动方式在后台唤起。"); + } + let Ok(_guard) = relay_switch_mutex().try_lock() else { + return failed("供应商切换正在进行或锁不可用,未执行重启;请稍后重试。", json!({})); }; let settings = if request.sync_active_relay { match SettingsStore::default().load() { @@ -832,23 +888,22 @@ pub fn restart_codex_plus(request: LaunchRequest) -> CommandResult { } else { None }; - if let Err(message) = ensure_provider_sync_is_idle_before_stop() { - return failed( - &message, - json!({ - "debugPort": request.debug_port, - "helperPort": request.helper_port, - "syncActiveRelay": request.sync_active_relay - }), - ); - } + let provider_guard = match ensure_provider_sync_is_idle_before_stop() { + Ok(guard) => guard, + Err(message) => return failed(&message, json!({})), + }; #[cfg(windows)] let launchers = match codex_plus_core::watcher::LauncherExitSnapshot::capture() { Ok(snapshot) => snapshot, Err(error) => return failed(&format!("无法确认旧启动器身份,未执行重启:{error}"), json!({})), }; let native_browser_shutdown = match stop_codex_plus_for_restart( - || codex_plus_core::watcher::stop_codex_processes_for_debug_port_and_wait(request.debug_port), + || { + codex_plus_core::watcher::stop_codex_processes_for_debug_port_and_wait(request.debug_port); + anyhow::ensure!(codex_plus_core::watcher::find_codex_processes_for_debug_port(request.debug_port).is_empty(), + "Codex 进程尚未退出;未启动新实例"); + Ok(()) + }, || codex_plus_core::native_browser::wait_for_monitor_shutdown(std::time::Duration::from_secs(10)), || { #[cfg(windows)] @@ -878,7 +933,7 @@ pub fn restart_codex_plus(request: LaunchRequest) -> CommandResult { } if let Err(error) = prepare_fixed_helper_port_for_restart(settings.as_ref()) { return failed( - &format!("重启 Codex++ 失败:{error}"), + &format!("重启 Codex++ 失败:{error:#}"), json!({"debugPort": request.debug_port, "helperPort": request.helper_port}), ); } @@ -908,7 +963,9 @@ pub fn restart_codex_plus(request: LaunchRequest) -> CommandResult { }), ); } - match restart_codex_plus_after_stop(&request, &home, settings.as_ref(), spawn_silent_launcher) { + match restart_codex_plus_after_stop(&request, &home, settings.as_ref(), |request| { + spawn_after_provider_guard_release(provider_guard, request, spawn_silent_launcher) + }) { Ok(()) => CommandResult { status: "accepted".to_string(), message: "Codex 已请求重启,启动任务正在后台运行。".to_string(), @@ -939,12 +996,12 @@ pub fn restart_codex_plus(request: LaunchRequest) -> CommandResult { } fn stop_codex_plus_for_restart( - stop_codex: impl FnOnce(), + stop_codex: impl FnOnce() -> anyhow::Result<()>, wait_native: impl FnOnce() -> anyhow::Result, stop_launcher: impl FnOnce() -> anyhow::Result<()>, ) -> Result { // The launcher owns native recovery; terminating it first skips that cleanup. - stop_codex(); + stop_codex().map_err(RestartStopError::Codex)?; let shutdown = wait_native().map_err(RestartStopError::NativeBrowser)?; stop_launcher().map_err(RestartStopError::Launcher)?; Ok(shutdown) @@ -952,12 +1009,16 @@ fn stop_codex_plus_for_restart( #[derive(Debug)] enum RestartStopError { + Codex(anyhow::Error), NativeBrowser(anyhow::Error), Launcher(anyhow::Error), } fn restart_stop_failure_message(error: &RestartStopError) -> String { match error { + RestartStopError::Codex(error) => { + format!("Codex 进程尚未确认退出,未清理原生浏览器或启动新实例:{error}") + } RestartStopError::NativeBrowser(error) => { let summary = if error .downcast_ref::() @@ -1005,6 +1066,15 @@ fn probe_loopback_port(port: u16) -> std::io::Result<()> { Ok(()) } +fn spawn_after_provider_guard_release( + guard: codex_plus_data::ProviderSyncLifecycleGuard, + request: &LaunchRequest, + spawn: impl FnOnce(&LaunchRequest) -> anyhow::Result<()>, +) -> anyhow::Result<()> { + guard.release().map_err(|error| anyhow::anyhow!("释放历史同步锁失败,未启动新实例:{error}"))?; + spawn(request) +} + fn restart_codex_plus_after_stop( request: &LaunchRequest, home: &Path, @@ -1159,10 +1229,7 @@ fn sync_active_relay_to_home( ) } -fn spawn_codex_plus_launch( - mut request: LaunchRequest, - accepted_message: &str, -) -> CommandResult { +fn normalize_launch_request(mut request: LaunchRequest) -> LaunchRequest { // launcher 收到显式 --app-path 时不会回退自动探测(避免静默启动错误目录), // 所以这里先把明显无效的路径摘掉,让它走探测而不是永久失败(#1972)。 let requested_app_path = request.app_path.trim().to_string(); @@ -1176,6 +1243,14 @@ fn spawn_codex_plus_launch( ); request.app_path = String::new(); } + request +} + +fn spawn_codex_plus_launch( + request: LaunchRequest, + accepted_message: &str, +) -> CommandResult { + let request = normalize_launch_request(request); let debug_port = request.debug_port; let helper_port = request.helper_port; let launch_started_at_ms = current_timestamp_ms(); @@ -6535,62 +6610,11 @@ fn failed(message: &str, payload: T) -> CommandResult { } } -/// provider sync 正在进行时,最多等它这么久再考虑放弃重启。 -const PROVIDER_SYNC_WAIT_TIMEOUT_MS: u64 = 30_000; -const PROVIDER_SYNC_WAIT_INTERVAL_MS: u64 = 200; - -/// 等待正在执行的 provider sync 结束。 -/// -/// launcher 在同步期间持有 `~/.codex/tmp/provider-sync.lock`,而这一步之后调用方会 -/// `TerminateProcess` 强杀 launcher。被强杀的进程来不及 `release_lock()`,会留下残留锁, -/// 使后续启动全部跳过同步,用户侧表现为历史会话消失或「修复 0 个会话」(issue #1901)。 -/// 因此这里先等同步自然结束;等不到就拒绝本次重启,而不是把它打断。 -fn wait_for_idle_provider_sync( - inspect: impl Fn() -> codex_plus_data::ProviderSyncLockState, - sleep: impl Fn(u64), - timeout_ms: u64, -) -> Result<(), codex_plus_data::ProviderSyncLockState> { - use codex_plus_data::ProviderSyncLockState; - - let mut waited_ms = 0; - loop { - // Stale 锁的持有者已经退出,下一次 acquire_lock 会自动回收它,不必等。 - match inspect() { - ProviderSyncLockState::Free | ProviderSyncLockState::Stale { .. } => return Ok(()), - state => { - if waited_ms >= timeout_ms { - return Err(state); - } - } - } - sleep(PROVIDER_SYNC_WAIT_INTERVAL_MS); - waited_ms += PROVIDER_SYNC_WAIT_INTERVAL_MS; - } -} - -/// 在强杀 launcher 前放行或拦截本次重启,并把判定结果写进诊断日志。 -fn ensure_provider_sync_is_idle_before_stop() -> Result<(), String> { - let outcome = wait_for_idle_provider_sync( - || codex_plus_data::inspect_provider_sync_lock(None), - |ms| std::thread::sleep(std::time::Duration::from_millis(ms)), - PROVIDER_SYNC_WAIT_TIMEOUT_MS, - ); - match outcome { - Ok(()) => Ok(()), - Err(state) => { - let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( - "manager.restart_blocked_by_provider_sync", - json!({ - "state": state, - "waited_ms": PROVIDER_SYNC_WAIT_TIMEOUT_MS, - }), - ); - Err(format!( - "历史会话同步正在进行中(已等待 {} 秒)。为避免中断同步导致会话丢失,本次重启未执行;请等待同步完成后重试。", - PROVIDER_SYNC_WAIT_TIMEOUT_MS / 1000 - )) - } - } +// Hold ownership across stop and config sync, then release before the successor starts. +fn ensure_provider_sync_is_idle_before_stop() -> Result { + codex_plus_data::try_acquire_provider_sync_lifecycle_guard(None).map_err(|error| { + format!("历史会话同步正在进行或锁不可用,未执行重启;请稍后重试:{error}") + }) } fn default_debug_port() -> u16 { @@ -6841,83 +6865,48 @@ base_url = "https://example.invalid/v1" } #[test] - fn restart_does_not_wait_when_no_provider_sync_is_running() { - let slept = std::cell::Cell::new(0); - - let outcome = wait_for_idle_provider_sync( - || codex_plus_data::ProviderSyncLockState::Free, - |ms| slept.set(slept.get() + ms), - PROVIDER_SYNC_WAIT_TIMEOUT_MS, - ); - - assert!(outcome.is_ok()); - assert_eq!(slept.get(), 0); - } - - #[test] - fn restart_does_not_wait_on_a_lock_whose_owner_already_exited() { - let slept = std::cell::Cell::new(0); - - let outcome = wait_for_idle_provider_sync( - || codex_plus_data::ProviderSyncLockState::Stale { pid: Some(4321) }, - |ms| slept.set(slept.get() + ms), - PROVIDER_SYNC_WAIT_TIMEOUT_MS, - ); - - assert!(outcome.is_ok()); - assert_eq!(slept.get(), 0); + fn restart_absent_app_uses_launch_but_explicit_sync_still_restarts() { + assert_eq!(restart_disposition(false, false), RestartDisposition::LaunchOnly); + for (sync, running) in [(true, false), (false, true), (true, true)] { + assert_eq!(restart_disposition(sync, running), RestartDisposition::StopAndRestart); + } } #[test] - fn restart_proceeds_once_an_in_flight_provider_sync_releases_the_lock() { - let polls = std::cell::Cell::new(0); - - let outcome = wait_for_idle_provider_sync( - || { - polls.set(polls.get() + 1); - if polls.get() < 3 { - codex_plus_data::ProviderSyncLockState::Held { - pid: 4321, - started_at: 1234, - } - } else { - codex_plus_data::ProviderSyncLockState::Free - } - }, - |_| {}, - PROVIDER_SYNC_WAIT_TIMEOUT_MS, - ); - - assert!(outcome.is_ok()); - assert_eq!(polls.get(), 3); + fn restart_and_launch_share_a_single_flight_guard() { + let first = try_acquire_launch_guard().unwrap(); + assert!(try_acquire_launch_guard().is_err()); + drop(first); + assert!(try_acquire_launch_guard().is_ok()); } - /// issue #1901:同步一直不结束时宁可拒绝重启,也不能强杀持锁的 launcher。 #[test] - fn restart_is_refused_while_a_provider_sync_keeps_holding_the_lock() { - let held = codex_plus_data::ProviderSyncLockState::Held { - pid: 4321, - started_at: 1234, - }; - - let outcome = - wait_for_idle_provider_sync(|| held.clone(), |_| {}, PROVIDER_SYNC_WAIT_TIMEOUT_MS); - - assert_eq!(outcome, Err(held)); + fn restart_provider_guard_is_held_until_spawn_and_released_first() { + let temp = tempfile::tempdir().unwrap(); + let guard = codex_plus_data::try_acquire_provider_sync_lifecycle_guard(Some(temp.path())).unwrap(); + assert!(codex_plus_data::try_acquire_provider_sync_lifecycle_guard(Some(temp.path())).is_err()); + let spawned = std::cell::Cell::new(false); + spawn_after_provider_guard_release(guard, &launch_request(false), |_| { + let next = codex_plus_data::try_acquire_provider_sync_lifecycle_guard(Some(temp.path()))?; + next.release()?; + spawned.set(true); + Ok(()) + }).unwrap(); + assert!(spawned.get()); } #[test] - fn restart_is_refused_while_the_lock_owner_cannot_be_determined() { - let outcome = wait_for_idle_provider_sync( - || codex_plus_data::ProviderSyncLockState::Indeterminate, - |_| {}, - PROVIDER_SYNC_WAIT_TIMEOUT_MS, - ); - - assert_eq!( - outcome, - Err(codex_plus_data::ProviderSyncLockState::Indeterminate) - ); + fn restart_provider_guard_release_failure_blocks_spawn() { + let temp = tempfile::tempdir().unwrap(); + let guard = codex_plus_data::try_acquire_provider_sync_lifecycle_guard(Some(temp.path())).unwrap(); + fs::write(temp.path().join("tmp/provider-sync.lock/owner.json"), + json!({"pid": std::process::id(), "startedAt":1, "lockId":"replacement"}).to_string()).unwrap(); + let spawned = std::cell::Cell::new(false); + assert!(spawn_after_provider_guard_release(guard, &launch_request(false), |_| { + spawned.set(true); + Ok(()) + }).is_err()); + assert!(!spawned.get()); } #[test] @@ -7368,7 +7357,6 @@ base_url = "https://example.invalid/v1" assert!(config.contains(r#"experimental_bearer_token = "codex-plus-aggregate""#)); } - #[test] /// 回归(issue #1604):用户点「重启 Codex++」走的就是这条同步路径。 /// 现场遗留的 0 字节 auth.json 必须被修复成合法 JSON,否则仍然停在登录页。 #[test] @@ -7405,6 +7393,7 @@ base_url = "https://example.invalid/v1" ); } + #[test] fn failed_active_relay_sync_does_not_spawn_or_change_live_files() { let temp = tempfile::tempdir().unwrap(); std::fs::write(temp.path().join("config.toml"), "model = \"old\"\n").unwrap(); @@ -7448,7 +7437,7 @@ base_url = "https://example.invalid/v1" fn restart_stops_codex_then_waits_for_native_cleanup_before_launcher() { let events = std::cell::RefCell::new(Vec::new()); stop_codex_plus_for_restart( - || events.borrow_mut().push("codex"), + || { events.borrow_mut().push("codex"); Ok(()) }, || { events.borrow_mut().push("cleanup"); Ok(codex_plus_core::native_browser::NativeBrowserShutdown::Ready) @@ -7462,7 +7451,7 @@ base_url = "https://example.invalid/v1" fn restore_failure_still_stops_the_old_launcher() { let stopped_launcher = std::cell::Cell::new(false); let shutdown = stop_codex_plus_for_restart( - || {}, + || Ok(()), || Ok(codex_plus_core::native_browser::NativeBrowserShutdown::RestoreFailed), || { stopped_launcher.set(true); @@ -7492,6 +7481,12 @@ base_url = "https://example.invalid/v1" assert!(launcher.contains("旧启动器尚未退出")); assert!(!launcher.contains("原生浏览器文件仍在恢复")); + let codex = restart_stop_failure_message(&RestartStopError::Codex(anyhow::anyhow!( + "app remains running" + ))); + assert!(codex.contains("Codex 进程尚未确认退出")); + assert!(!codex.contains("原生浏览器文件恢复失败")); + let failed = restart_stop_failure_message(&RestartStopError::NativeBrowser( anyhow::anyhow!("Invalid native cleanup receipt"), )); @@ -7504,7 +7499,7 @@ base_url = "https://example.invalid/v1" let stopped = std::cell::Cell::new(false); let killed = std::cell::Cell::new(false); let result = stop_codex_plus_for_restart( - || stopped.set(true), + || { stopped.set(true); Ok(()) }, || anyhow::bail!("cleanup still running"), || { killed.set(true); Ok(()) }, ); @@ -7513,6 +7508,16 @@ base_url = "https://example.invalid/v1" assert!(!killed.get()); } + #[test] + fn restart_does_not_cleanup_or_spawn_if_app_stop_fails() { + let result = stop_codex_plus_for_restart( + || anyhow::bail!("app remains running"), + || panic!("must not wait on cleanup"), + || panic!("must not stop launcher"), + ); + assert!(result.is_err()); + } + #[test] fn failed_spawn_rolls_back_synced_live_files_for_retry() { let temp = tempfile::tempdir().unwrap(); @@ -7954,7 +7959,6 @@ enabled = true ); } - #[test] /// #1972:用户误把 Codex++ 自己的 exe 选成了「Codex 应用路径」——文件选择器 /// 只按 exe 扩展名过滤,拦不住。以前无效路径会原样存进 settings.json,而 /// launcher 拿到显式无效 --app-path 又不回退自动探测,于是启动永久失败, diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index 8ffe31048..3ce3a61b7 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -143,7 +143,7 @@ import { type ProviderSyncStreamProgress, } from "./provider-sync-flow"; import { isProviderSyncTargetSelectable, preferredProviderSyncTarget } from "./provider-sync-target"; -import { resolveLaunchStatus } from "./launch-status"; +import { resolveLaunchStatus, launchCompletionNotice } from "./launch-status"; import { defaultDreamSkinTheme, defaultDreamSkinColors, @@ -1220,6 +1220,8 @@ export function App() { helperPort: "57321", }); const prevLaunchStatusRef = useRef(null); + const launchPendingRef = useRef(false); + const [launchPending, setLaunchPending] = useState(false); const [settingsForm, setSettingsForm] = useState({ ...defaultSettings }); // 顶栏工具切换条的数据源。后端是唯一事实来源,不落 localStorage —— 多窗口 // 同时开着时才不会各说各话。 @@ -2204,39 +2206,55 @@ export function App() { }; const launch = async () => { - const result = await launchCommand("launch_codex_plus"); - if (!result) return; - if (!isSuccessStatus(result.status)) { - showNotice(t("启动任务"), result.message, result.status); - return; + if (launchPendingRef.current) return; + launchPendingRef.current = true; + setLaunchPending(true); + try { + const result = await launchCommand("launch_codex_plus"); + if (!result) return; + if (!isSuccessStatus(result.status)) { + showNotice(t("启动任务"), result.message, result.status); + return; + } + showNotice(t("启动任务"), t("正在等待 Codex 启动结果…"), "accepted"); + const completion = await waitForLaunchCompletion(result.launchStartedAtMs); + showLaunchCompletionNotice(t("启动任务"), completion, result.launchStartedAtMs); + } finally { + launchPendingRef.current = false; + setLaunchPending(false); } - showNotice(t("启动任务"), t("正在等待 Codex 启动结果…"), "accepted"); - const completion = await waitForLaunchCompletion(result.launchStartedAtMs); - showLaunchCompletionNotice(t("启动任务"), completion); }; const restart = async (syncActiveRelay = false) => { - const result = await launchCommand("restart_codex_plus", syncActiveRelay); - if (!result) return false; - if (!isSuccessStatus(result.status)) { - showNotice(t("重启 Codex++"), result.message, result.status); - return false; + if (launchPendingRef.current) return false; + launchPendingRef.current = true; + setLaunchPending(true); + try { + const result = await launchCommand("restart_codex_plus", syncActiveRelay); + if (!result) return false; + if (!isSuccessStatus(result.status)) { + showNotice(t("重启 Codex++"), result.message, result.status); + return false; + } + showNotice( + t("重启 Codex++"), + result.nativeBrowserRestoreFailed + ? t("原生浏览器文件恢复失败,仍会继续启动。") + : t("正在等待 Codex 重新启动…"), + result.nativeBrowserRestoreFailed ? "failed" : "accepted", + ); + const completion = await waitForLaunchCompletion(result.launchStartedAtMs); + showLaunchCompletionNotice(t("重启 Codex++"), completion, result.launchStartedAtMs); + const succeeded = Boolean( + completion + && resolveLaunchStatus(completion.latest_launch, result.launchStartedAtMs ?? 0) === "success", + ); + if (succeeded) setPendingDreamSkinRestart(null); + return succeeded; + } finally { + launchPendingRef.current = false; + setLaunchPending(false); } - showNotice( - t("重启 Codex++"), - result.nativeBrowserRestoreFailed - ? t("原生浏览器文件恢复失败,仍会继续启动。") - : t("正在等待 Codex 重新启动…"), - result.nativeBrowserRestoreFailed ? "failed" : "accepted", - ); - const completion = await waitForLaunchCompletion(result.launchStartedAtMs); - showLaunchCompletionNotice(t("重启 Codex++"), completion); - const succeeded = Boolean( - completion - && resolveLaunchStatus(completion.latest_launch, result.launchStartedAtMs ?? 0) === "success", - ); - if (succeeded) setPendingDreamSkinRestart(null); - return succeeded; }; const launchCommand = async (command: "launch_codex_plus" | "restart_codex_plus", syncActiveRelay = false) => { @@ -2272,20 +2290,9 @@ export function App() { return null; }; - const showLaunchCompletionNotice = (title: string, result: OverviewResult | null) => { - const status = result?.latest_launch; - if (!status) { - showNotice(title, t("启动仍在后台进行,可在概览的“最近启动”中查看状态。"), "accepted"); - return; - } - if (["failed", "crashed", "stopped"].includes(status.status)) { - showNotice(title, status.message || t("Codex 启动失败。"), "failed"); - return; - } - const message = status.status === "running_degraded" - ? t("Codex 已启动,增强功能仍在等待页面连接。") - : t("Codex 已成功启动。"); - showNotice(title, message, "ok"); + const showLaunchCompletionNotice = (title: string, result: OverviewResult | null, requestedAt?: number) => { + const notice = launchCompletionNotice(result?.latest_launch ?? null, requestedAt ?? 0); + showNotice(title, t(notice.message), notice.status); }; const repairPluginMarketplace = async () => { @@ -3642,7 +3649,7 @@ export function App() { {theme === "dark" ? : } {activeTool === "codex" ? ( - @@ -4547,7 +4554,7 @@ function OverviewScreen({ - @@ -5245,7 +5252,7 @@ function DreamSkinScreen({ {t("当前运行")}:{pendingRestart.currentThemeName}。{t("配置已保存,可以继续浏览和编辑,稍后重启即可生效。")} - @@ -6815,7 +6822,7 @@ function MaintenanceScreen({ - + diff --git a/apps/codex-plus-manager/src/launch-status.test.ts b/apps/codex-plus-manager/src/launch-status.test.ts index b9183c4f6..d3c70ffd7 100644 --- a/apps/codex-plus-manager/src/launch-status.test.ts +++ b/apps/codex-plus-manager/src/launch-status.test.ts @@ -1,7 +1,16 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { resolveLaunchStatus } from "./launch-status.ts"; +import { resolveLaunchStatus, launchCompletionNotice } from "./launch-status.ts"; + +test("completion notice never reports pending or stale launch as successful", () => { + for (const snapshot of [null, { status: "starting", message: "starting", started_at_ms: 100 }, + { status: "running", message: "previous", started_at_ms: 99 }]) { + assert.equal(launchCompletionNotice(snapshot, 100).status, "accepted"); + } + assert.equal(launchCompletionNotice({ status: "failed", message: "failure", started_at_ms: 101 }, 100).message, "failure"); + assert.equal(launchCompletionNotice({ status: "running", message: "ready", started_at_ms: 101 }, 100).status, "ok"); +}); test("launch status ignores a terminal result from an older request", () => { assert.equal( diff --git a/apps/codex-plus-manager/src/launch-status.ts b/apps/codex-plus-manager/src/launch-status.ts index 70bb3e518..57f2c8734 100644 --- a/apps/codex-plus-manager/src/launch-status.ts +++ b/apps/codex-plus-manager/src/launch-status.ts @@ -9,6 +9,18 @@ export type LaunchStatusResolution = "pending" | "success" | "failed" | "stale"; const SUCCESS_STATUSES = new Set(["running", "running_degraded"]); const FAILURE_STATUSES = new Set(["failed", "crashed", "stopped"]); +export function launchCompletionNotice(status: LaunchStatusSnapshot | null, requestStartedAtMs: number) { + const resolution = resolveLaunchStatus(status, requestStartedAtMs); + if (resolution === "pending" || resolution === "stale") { + return { status: "accepted", message: "启动仍在后台进行,可在概览的“最近启动”中查看状态。" }; + } + if (resolution === "failed") { + return { status: "failed", message: status?.message || "Codex 启动失败。" }; + } + return { status: "ok", message: status?.status === "running_degraded" + ? "Codex 已启动,增强功能仍在等待页面连接。" : "Codex 已成功启动。" }; +} + export function resolveLaunchStatus( status: LaunchStatusSnapshot | null, requestStartedAtMs: number, diff --git a/crates/codex-plus-core/src/native_browser.rs b/crates/codex-plus-core/src/native_browser.rs index 843c51243..5fed0bdc6 100644 --- a/crates/codex-plus-core/src/native_browser.rs +++ b/crates/codex-plus-core/src/native_browser.rs @@ -1119,7 +1119,10 @@ pub fn wait_for_monitor_shutdown(timeout: Duration) -> Result Result { +fn wait_for_monitor_shutdown_at( + paths: &BrowserPaths, + timeout: Duration, +) -> Result { let path = paths.state_root.join("monitor.lock"); let _guards = pin_parents(&path)?; let mut options = OpenOptions::new(); @@ -1147,15 +1150,25 @@ fn wait_for_monitor_shutdown_at(paths: &BrowserPaths, timeout: Duration) -> Resu let mut bytes = Vec::new(); Read::by_ref(&mut file).take(1025).read_to_end(&mut bytes)?; let receipt: MonitorReceipt = serde_json::from_slice(&bytes)?; - let receipt_is_valid = receipt.schema == 1 - && uuid::Uuid::parse_str(&receipt.generation).is_ok(); - if receipt_is_valid && receipt.state == "restored" { - return Ok(NativeBrowserShutdown::Ready); - } - if receipt_is_valid && receipt.state == "blocked" { + ensure!( + receipt.schema == 1 + && uuid::Uuid::parse_str(&receipt.generation).is_ok() + && matches!(receipt.state.as_str(), "active" | "blocked" | "restored"), + "Invalid native browser cleanup receipt" + ); + // A completed blocked recovery remains non-fatal upstream. A stale + // receipt is ready only when read-only disk verification succeeds. + let restored = verify_restored_state(paths); + if receipt.state == "blocked" && restored.is_err() { return Ok(NativeBrowserShutdown::RestoreFailed); } - anyhow::bail!("Native browser cleanup did not complete successfully"); + restored.with_context(|| { + format!( + "Native browser cleanup is incomplete ({}); files were not overwritten", + receipt.state + ) + })?; + return Ok(NativeBrowserShutdown::Ready); } Err(error) if error.kind() == fs2::lock_contended_error().kind() => { if std::time::Instant::now() >= deadline { @@ -2147,7 +2160,7 @@ mod tests { let (paths, contract, service) = synthetic(&temp); let original = fs::read(&service).unwrap(); let modified = fs::metadata(&service).unwrap().modified().unwrap(); - let monitor = start_monitor_with_contract(paths.clone(), true, contract).await.unwrap(); + let monitor = start_monitor_with_contract(paths.clone(), true, contract.clone()).await.unwrap(); assert_ne!(fs::read(&service).unwrap(), original); monitor.stop().await; assert_eq!(sha(&fs::read(&service).unwrap()), sha(&original)); @@ -2258,7 +2271,7 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let (paths, contract, service) = synthetic(&temp); let original = sha(&fs::read(&service).unwrap()); - let monitor = start_monitor_with_contract(paths.clone(), true, contract).await.unwrap(); + let monitor = start_monitor_with_contract(paths.clone(), true, contract.clone()).await.unwrap(); let wait_paths = paths.clone(); let waiter = tokio::task::spawn_blocking(move || { wait_for_monitor_shutdown_at(&wait_paths, Duration::from_secs(5)).unwrap(); @@ -2286,6 +2299,67 @@ mod tests { assert_eq!(fs::read(&service).unwrap(), before); } + #[test] + fn stale_cleanup_receipt_is_checked_against_disk_without_writes() { + let temp = tempfile::tempdir().unwrap(); + let (paths, contract, service) = synthetic(&temp); + reconcile_contract(&paths, true, &contract).unwrap(); + reconcile_contract(&paths, false, &contract).unwrap(); + let original = fs::read(&service).unwrap(); + let control = fs::read(paths.state_root.join("control.json")).unwrap(); + let mut owner = acquire_monitor_owner(&paths).unwrap(); + let generation = uuid::Uuid::new_v4().to_string(); + for state in ["active", "blocked", "restored"] { + write_monitor_receipt(&mut owner, &generation, state).unwrap(); + FileExt::unlock(&owner).unwrap(); + assert_eq!( + wait_for_monitor_shutdown_at(&paths, Duration::ZERO).unwrap(), + NativeBrowserShutdown::Ready + ); + owner.try_lock_exclusive().unwrap(); + } + write_monitor_receipt(&mut owner, &generation, "restored").unwrap(); + drop(owner); + assert_eq!(fs::read(&service).unwrap(), original); + assert_eq!(fs::read(paths.state_root.join("control.json")).unwrap(), control); + + fs::write(&service, b"external edit").unwrap(); + assert!(wait_for_monitor_shutdown_at(&paths, Duration::ZERO).is_err()); + assert_eq!(fs::read(&service).unwrap(), b"external edit"); + } + + #[test] + fn blocked_cleanup_preserves_restore_failed_and_external_edits() { + let temp = tempfile::tempdir().unwrap(); + let (paths, contract, service) = synthetic(&temp); + reconcile_contract(&paths, true, &contract).unwrap(); + fs::write(&service, b"external edit").unwrap(); + let control = fs::read(paths.state_root.join("control.json")).unwrap(); + let mut owner = acquire_monitor_owner(&paths).unwrap(); + write_monitor_receipt(&mut owner, &uuid::Uuid::new_v4().to_string(), "blocked").unwrap(); + drop(owner); + assert_eq!( + wait_for_monitor_shutdown_at(&paths, Duration::ZERO).unwrap(), + NativeBrowserShutdown::RestoreFailed + ); + assert_eq!(fs::read(&service).unwrap(), b"external edit"); + assert_eq!(fs::read(paths.state_root.join("control.json")).unwrap(), control); + } + + #[test] + fn unknown_cleanup_receipt_is_rejected_even_when_disk_is_restored() { + let temp = tempfile::tempdir().unwrap(); + let (paths, contract, service) = synthetic(&temp); + reconcile_contract(&paths, true, &contract).unwrap(); + reconcile_contract(&paths, false, &contract).unwrap(); + let original = fs::read(&service).unwrap(); + let mut owner = acquire_monitor_owner(&paths).unwrap(); + write_monitor_receipt(&mut owner, &uuid::Uuid::new_v4().to_string(), "unknown").unwrap(); + drop(owner); + assert!(wait_for_monitor_shutdown_at(&paths, Duration::ZERO).is_err()); + assert_eq!(fs::read(&service).unwrap(), original); + } + // The proprietary runtime is supplied locally, never committed or executed by this test. #[test] #[ignore = "requires CPP_NATIVE_BROWSER_FIXTURE and CPP_NATIVE_BROWSER_DESCRIPTOR"] diff --git a/crates/codex-plus-core/src/watcher.rs b/crates/codex-plus-core/src/watcher.rs index 348996e66..5ae0795b6 100644 --- a/crates/codex-plus-core/src/watcher.rs +++ b/crates/codex-plus-core/src/watcher.rs @@ -668,6 +668,16 @@ pub fn stop_codex_processes_and_wait() { #[cfg(not(any(windows, target_os = "macos")))] pub fn stop_codex_processes_and_wait() {} +#[cfg(target_os = "macos")] +pub fn find_codex_processes_for_debug_port(debug_port: u16) -> Vec { + find_macos_codex_processes_for_debug_port(debug_port) +} + +#[cfg(not(target_os = "macos"))] +pub fn find_codex_processes_for_debug_port(_debug_port: u16) -> Vec { + find_codex_processes() +} + #[cfg(target_os = "macos")] pub fn stop_codex_processes_for_debug_port_and_wait(debug_port: u16) { terminate_macos_processes_and_wait( From e758f7c596434f1a12f8870b27b54417749b1c16 Mon Sep 17 00:00:00 2001 From: Yuimi_chaya <124485273+Yuimi-chaya@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:47:06 +0800 Subject: [PATCH 2/3] fix(browser): guard inserted control paths and stale cleanup receipts --- crates/codex-plus-core/src/native_browser.rs | 30 ++++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/codex-plus-core/src/native_browser.rs b/crates/codex-plus-core/src/native_browser.rs index 5fed0bdc6..f81e43b89 100644 --- a/crates/codex-plus-core/src/native_browser.rs +++ b/crates/codex-plus-core/src/native_browser.rs @@ -564,13 +564,15 @@ fn transform_binding(source: &[u8], control: &Path, _contract: &RuntimeContract) let rewritten = &output[..output.len() - HELPER.len() - 1]; // 替换串里构造器用一次、policy 回调用一次、元数据回调用两次 //(`()=>ze(...)` 的调用 + 传给 reader 的实参),所以只有后者 +1。 + // The inserted JSON path is data, not another use of a minified identifier. for (name, added) in [ (&binding.constructor, 0), (&binding.runtime_getter, 1), (&binding.policy, 0), ] { ensure!( - count_occurrences(rewritten, name) == count_occurrences(text, name) + added, + count_occurrences(rewritten, name) + == count_occurrences(text, name) + added + count_occurrences(&path, name), "Binding rewrite changed the {name} occurrence count" ); } @@ -1605,6 +1607,22 @@ mod tests { ); } + #[test] + fn control_path_substrings_are_not_identifier_drift() { + let text = format!("fixture;{ANCHOR};original"); + let contract = RuntimeContract { + service_sha: sha(text.as_bytes()), + files: vec![], + adaptive: false, + }; + let output = transform( + text.as_bytes(), Path::new("C:/conflict/nf-ze-cD/control.json"), &contract, + ).unwrap(); + let rewritten = String::from_utf8(output).unwrap(); + assert!(rewritten.contains("C:/conflict/nf-ze-cD/control.json")); + assert!(rewritten.contains("cppNativeIdentificationReader(this.runtime,cD,ze,")); + } + #[test] fn binding_requires_unique_anchor_and_preserves_other_code() { let path = Path::new("C:/unicode-\u{4e2d}/control.json"); @@ -2324,8 +2342,14 @@ mod tests { assert_eq!(fs::read(paths.state_root.join("control.json")).unwrap(), control); fs::write(&service, b"external edit").unwrap(); - assert!(wait_for_monitor_shutdown_at(&paths, Duration::ZERO).is_err()); - assert_eq!(fs::read(&service).unwrap(), b"external edit"); + for state in ["active", "restored"] { + let mut owner = acquire_monitor_owner(&paths).unwrap(); + write_monitor_receipt(&mut owner, &generation, state).unwrap(); + drop(owner); + assert!(wait_for_monitor_shutdown_at(&paths, Duration::ZERO).is_err()); + assert_eq!(fs::read(&service).unwrap(), b"external edit"); + assert_eq!(fs::read(paths.state_root.join("control.json")).unwrap(), control); + } } #[test] From 540b8628d62ebe124f8ec5c0ffbd4559fcfb2511 Mon Sep 17 00:00:00 2001 From: Yuimi_chaya <124485273+Yuimi-chaya@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:47:06 +0800 Subject: [PATCH 3/3] fix(manager): cover all launch buttons and simplify lifecycle tests --- apps/codex-plus-manager/src-tauri/src/commands.rs | 4 ++-- apps/codex-plus-manager/src/App.tsx | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index 540896572..5e7f157b2 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -7357,9 +7357,9 @@ base_url = "https://example.invalid/v1" assert!(config.contains(r#"experimental_bearer_token = "codex-plus-aggregate""#)); } + #[test] /// 回归(issue #1604):用户点「重启 Codex++」走的就是这条同步路径。 /// 现场遗留的 0 字节 auth.json 必须被修复成合法 JSON,否则仍然停在登录页。 - #[test] fn active_aggregate_sync_repairs_empty_auth_json() { let temp = tempfile::tempdir().unwrap(); std::fs::write(temp.path().join("auth.json"), "").unwrap(); @@ -7959,11 +7959,11 @@ enabled = true ); } + #[test] /// #1972:用户误把 Codex++ 自己的 exe 选成了「Codex 应用路径」——文件选择器 /// 只按 exe 扩展名过滤,拦不住。以前无效路径会原样存进 settings.json,而 /// launcher 拿到显式无效 --app-path 又不回退自动探测,于是启动永久失败, /// 只能手改配置文件才能恢复。 - #[test] fn normalize_settings_before_save_drops_an_invalid_codex_app_path() { let codex_plus_own_exe = if cfg!(windows) { r"D:\Codex++\codex-plus-plus.exe" diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index 3ce3a61b7..f931636dc 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -3663,6 +3663,7 @@ export function App() { {route === "overview" ? (