From 1f513a821201e576548982ac59c4db4d7d2b90b0 Mon Sep 17 00:00:00 2001 From: Yuimi_chaya <124485273+Yuimi-chaya@users.noreply.github.com> Date: Thu, 27 Aug 2026 04:24:25 +0800 Subject: [PATCH 01/12] fix(manager): make restart lifecycle recoverable --- Cargo.lock | 1 + .../src-tauri/src/commands.rs | 263 +++++++-- crates/codex-plus-core/src/watcher.rs | 151 ++++- .../src/windows_integration.rs | 42 +- crates/codex-plus-data/Cargo.toml | 1 + crates/codex-plus-data/src/lib.rs | 5 +- crates/codex-plus-data/src/provider_sync.rs | 516 +++++++++++++++--- crates/codex-plus-data/tests/provider_sync.rs | 2 +- 8 files changed, 866 insertions(+), 115 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2a744529b..272f033d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -454,6 +454,7 @@ dependencies = [ "base64 0.22.1", "chrono", "codex-plus-core", + "fs2", "rusqlite", "serde", "serde_json", diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index 33e13265c..c226b0528 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -629,8 +629,40 @@ pub fn launch_codex_plus(request: LaunchRequest) -> CommandResult { } #[tauri::command] -pub fn restart_codex_plus(request: LaunchRequest) -> CommandResult { - let Ok(_guard) = relay_switch_mutex().lock() else { +pub async fn restart_codex_plus(request: LaunchRequest) -> CommandResult { + let error_payload = request.clone(); + match tauri::async_runtime::spawn_blocking(move || restart_codex_plus_blocking(request)).await { + Ok(result) => result, + Err(error) => failed( + &format!("重启 Codex++ 后台任务失败:{error}"), + json!({ + "debugPort": error_payload.debug_port, + "helperPort": error_payload.helper_port, + "syncActiveRelay": error_payload.sync_active_relay + }), + ), + } +} + +fn restart_codex_plus_blocking(request: LaunchRequest) -> CommandResult { + let _restart_guard = match try_acquire_restart_guard() { + Ok(guard) => guard, + Err(message) => return failed(message, json!({})), + }; + let provider_sync_guard = match ensure_provider_sync_is_idle_before_stop() { + Ok(guard) => guard, + Err(message) => { + return failed( + &message, + json!({ + "debugPort": request.debug_port, + "helperPort": request.helper_port, + "syncActiveRelay": request.sync_active_relay + }), + ); + } + }; + let Ok(_relay_guard) = relay_switch_mutex().lock() else { return failed("供应商切换锁已损坏,请重启管理器后再试。", json!({})); }; let settings = if request.sync_active_relay { @@ -649,16 +681,6 @@ 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 - }), - ); - } codex_plus_core::watcher::stop_launcher_processes_and_wait(); codex_plus_core::watcher::stop_codex_processes_for_debug_port_and_wait(request.debug_port); let home = codex_plus_core::relay_config::default_codex_home_dir(); @@ -687,7 +709,19 @@ pub fn restart_codex_plus(request: LaunchRequest) -> CommandResult { }), ); } - match restart_codex_plus_after_stop(&request, &home, settings.as_ref(), spawn_silent_launcher) { + let spawn_after_guard_release = move |request: &LaunchRequest| { + spawn_after_provider_sync_guard_release( + provider_sync_guard, + request, + spawn_silent_launcher, + ) + }; + match restart_codex_plus_after_stop( + &request, + &home, + settings.as_ref(), + spawn_after_guard_release, + ) { Ok(()) => CommandResult { status: "accepted".to_string(), message: "Codex 已请求重启,启动任务正在后台运行。".to_string(), @@ -748,6 +782,20 @@ where Ok(()) } +fn spawn_after_provider_sync_guard_release( + guard: codex_plus_data::ProviderSyncLifecycleGuard, + request: &LaunchRequest, + spawn: F, +) -> anyhow::Result<()> +where + F: FnOnce(&LaunchRequest) -> anyhow::Result<()>, +{ + guard + .release() + .map_err(|error| anyhow::anyhow!("释放历史会话同步保护锁失败,未启动 Codex++:{error}"))?; + spawn(request) +} + #[derive(Debug)] struct RelayLiveSnapshot { config: Option>, @@ -5507,6 +5555,23 @@ fn relay_switch_mutex() -> &'static Mutex<()> { RELAY_SWITCH_LOCK.get_or_init(|| Mutex::new(())) } +fn restart_mutex() -> &'static Mutex<()> { + static RESTART_LOCK: OnceLock> = OnceLock::new(); + RESTART_LOCK.get_or_init(|| Mutex::new(())) +} + +fn try_acquire_restart_guard() -> Result, &'static str> { + match restart_mutex().try_lock() { + Ok(guard) => Ok(guard), + Err(std::sync::TryLockError::WouldBlock) => { + Err("已有 Codex++ 重启任务正在进行,请稍后再试。") + } + Err(std::sync::TryLockError::Poisoned(_)) => { + Err("重启任务锁已损坏,请重启管理器后再试。") + } + } +} + fn empty_context_entries() -> codex_plus_core::relay_config::CodexContextEntries { codex_plus_core::relay_config::CodexContextEntries { mcp_servers: Vec::new(), @@ -5914,29 +5979,44 @@ fn failed(message: &str, payload: T) -> CommandResult { const PROVIDER_SYNC_WAIT_TIMEOUT_MS: u64 = 30_000; const PROVIDER_SYNC_WAIT_INTERVAL_MS: u64 = 200; -/// 等待正在执行的 provider sync 结束。 +#[derive(Debug)] +enum ProviderSyncWaitFailure { + Busy(codex_plus_data::ProviderSyncLockState), + Acquire(std::io::Error), +} + +/// 等待并原子取得 provider-sync 生命周期 guard。 /// -/// launcher 在同步期间持有 `~/.codex/tmp/provider-sync.lock`,而这一步之后调用方会 -/// `TerminateProcess` 强杀 launcher。被强杀的进程来不及 `release_lock()`,会留下残留锁, -/// 使后续启动全部跳过同步,用户侧表现为历史会话消失或「修复 0 个会话」(issue #1901)。 -/// 因此这里先等同步自然结束;等不到就拒绝本次重启,而不是把它打断。 -fn wait_for_idle_provider_sync( +/// guard 同时持有 OS 文件锁与兼容旧版本的目录锁。取得后,旧/新 launcher 都不能在 +/// 最终检查和停止进程之间启动新的 provider sync,关闭先检查后强杀的竞态(issue #1901)。 +/// 等不到就拒绝本次重启,而不是把同步中的 launcher 打断。 +fn wait_for_idle_provider_sync( + try_acquire: impl Fn() -> std::io::Result, inspect: impl Fn() -> codex_plus_data::ProviderSyncLockState, sleep: impl Fn(u64), timeout_ms: u64, -) -> Result<(), codex_plus_data::ProviderSyncLockState> { +) -> Result { use codex_plus_data::ProviderSyncLockState; let mut waited_ms = 0; loop { - // Stale 锁的持有者已经退出,下一次 acquire_lock 会自动回收它,不必等。 - match inspect() { - ProviderSyncLockState::Free | ProviderSyncLockState::Stale { .. } => return Ok(()), - state => { + match try_acquire() { + Ok(guard) => return Ok(guard), + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::WouldBlock + ) => + { if waited_ms >= timeout_ms { - return Err(state); + let state = match inspect() { + ProviderSyncLockState::Free => ProviderSyncLockState::Indeterminate, + state => state, + }; + return Err(ProviderSyncWaitFailure::Busy(state)); } } + Err(error) => return Err(ProviderSyncWaitFailure::Acquire(error)), } sleep(PROVIDER_SYNC_WAIT_INTERVAL_MS); waited_ms += PROVIDER_SYNC_WAIT_INTERVAL_MS; @@ -5944,15 +6024,19 @@ fn wait_for_idle_provider_sync( } /// 在强杀 launcher 前放行或拦截本次重启,并把判定结果写进诊断日志。 -fn ensure_provider_sync_is_idle_before_stop() -> Result<(), String> { +fn ensure_provider_sync_is_idle_before_stop() -> Result< + codex_plus_data::ProviderSyncLifecycleGuard, + String, +> { let outcome = wait_for_idle_provider_sync( + || codex_plus_data::try_acquire_provider_sync_lifecycle_guard(None), || 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) => { + Ok(guard) => Ok(guard), + Err(ProviderSyncWaitFailure::Busy(state)) => { let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( "manager.restart_blocked_by_provider_sync", json!({ @@ -5965,6 +6049,13 @@ fn ensure_provider_sync_is_idle_before_stop() -> Result<(), String> { PROVIDER_SYNC_WAIT_TIMEOUT_MS / 1000 )) } + Err(ProviderSyncWaitFailure::Acquire(error)) => { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "manager.restart_provider_sync_guard_failed", + json!({ "message": error.to_string() }), + ); + Err(format!("无法取得历史会话同步保护锁,未执行重启:{error}")) + } } } @@ -6146,6 +6237,7 @@ mod tests { let slept = std::cell::Cell::new(0); let outcome = wait_for_idle_provider_sync( + || Ok(()), || codex_plus_data::ProviderSyncLockState::Free, |ms| slept.set(slept.get() + ms), PROVIDER_SYNC_WAIT_TIMEOUT_MS, @@ -6155,11 +6247,23 @@ mod tests { assert_eq!(slept.get(), 0); } + #[test] + fn concurrent_restart_is_rejected_by_single_flight_guard() { + let first = try_acquire_restart_guard().unwrap(); + + let second = try_acquire_restart_guard(); + + assert!(matches!(second, Err(message) if message.contains("正在进行"))); + drop(first); + assert!(try_acquire_restart_guard().is_ok()); + } + #[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( + || Ok(()), || codex_plus_data::ProviderSyncLockState::Stale { pid: Some(4321) }, |ms| slept.set(slept.get() + ms), PROVIDER_SYNC_WAIT_TIMEOUT_MS, @@ -6177,14 +6281,15 @@ mod tests { || { polls.set(polls.get() + 1); if polls.get() < 3 { - codex_plus_data::ProviderSyncLockState::Held { - pid: 4321, - started_at: 1234, - } + Err(std::io::Error::from(std::io::ErrorKind::WouldBlock)) } else { - codex_plus_data::ProviderSyncLockState::Free + Ok(()) } }, + || codex_plus_data::ProviderSyncLockState::Held { + pid: 4321, + started_at: 1234, + }, |_| {}, PROVIDER_SYNC_WAIT_TIMEOUT_MS, ); @@ -6201,24 +6306,106 @@ mod tests { started_at: 1234, }; - let outcome = - wait_for_idle_provider_sync(|| held.clone(), |_| {}, PROVIDER_SYNC_WAIT_TIMEOUT_MS); + let outcome = wait_for_idle_provider_sync( + || Err::<(), _>(std::io::Error::from(std::io::ErrorKind::WouldBlock)), + || held.clone(), + |_| {}, + PROVIDER_SYNC_WAIT_TIMEOUT_MS, + ); - assert_eq!(outcome, Err(held)); + assert!(matches!( + outcome, + Err(ProviderSyncWaitFailure::Busy(state)) if state == held + )); } #[test] fn restart_is_refused_while_the_lock_owner_cannot_be_determined() { let outcome = wait_for_idle_provider_sync( + || Err::<(), _>(std::io::Error::from(std::io::ErrorKind::AlreadyExists)), || codex_plus_data::ProviderSyncLockState::Indeterminate, |_| {}, PROVIDER_SYNC_WAIT_TIMEOUT_MS, ); - assert_eq!( + assert!(matches!( outcome, - Err(codex_plus_data::ProviderSyncLockState::Indeterminate) + Err(ProviderSyncWaitFailure::Busy( + codex_plus_data::ProviderSyncLockState::Indeterminate + )) + )); + } + + #[test] + fn restart_reports_non_contention_guard_errors_without_waiting() { + let slept = std::cell::Cell::new(0); + let outcome = wait_for_idle_provider_sync( + || Err::<(), _>(std::io::Error::from(std::io::ErrorKind::PermissionDenied)), + || codex_plus_data::ProviderSyncLockState::Free, + |ms| slept.set(slept.get() + ms), + PROVIDER_SYNC_WAIT_TIMEOUT_MS, ); + + assert!(matches!( + outcome, + Err(ProviderSyncWaitFailure::Acquire(error)) + if error.kind() == std::io::ErrorKind::PermissionDenied + )); + assert_eq!(slept.get(), 0); + } + + #[test] + fn restart_releases_provider_guard_before_spawning() { + let temp = tempfile::tempdir().unwrap(); + let guard = codex_plus_data::try_acquire_provider_sync_lifecycle_guard(Some(temp.path())) + .unwrap(); + let spawned = std::cell::Cell::new(false); + + spawn_after_provider_sync_guard_release(guard, &launch_request(false), |_| { + assert_eq!( + codex_plus_data::inspect_provider_sync_lock(Some(temp.path())), + codex_plus_data::ProviderSyncLockState::Free + ); + let replacement = + codex_plus_data::try_acquire_provider_sync_lifecycle_guard(Some(temp.path()))?; + replacement.release()?; + spawned.set(true); + Ok(()) + }) + .unwrap(); + + assert!(spawned.get()); + } + + #[test] + fn restart_does_not_spawn_when_provider_guard_release_fails() { + let temp = tempfile::tempdir().unwrap(); + let guard = codex_plus_data::try_acquire_provider_sync_lifecycle_guard(Some(temp.path())) + .unwrap(); + std::fs::write( + temp.path().join("tmp/provider-sync.lock/owner.json"), + json!({ + "pid": std::process::id(), + "startedAt": 1, + "lockId": "replacement-owner", + }) + .to_string(), + ) + .unwrap(); + let spawned = std::cell::Cell::new(false); + + let error = spawn_after_provider_sync_guard_release( + guard, + &launch_request(false), + |_| { + spawned.set(true); + Ok(()) + }, + ) + .unwrap_err(); + + assert!(!spawned.get()); + assert!(error.to_string().contains("未启动 Codex++")); } #[test] diff --git a/crates/codex-plus-core/src/watcher.rs b/crates/codex-plus-core/src/watcher.rs index e674cff94..6d69873f5 100644 --- a/crates/codex-plus-core/src/watcher.rs +++ b/crates/codex-plus-core/src/watcher.rs @@ -153,22 +153,159 @@ pub fn process_ids_still_running( .collect() } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProcessInstanceState { + NotRunning, + Running { + started_at_secs: Option, + birth_id: Option, + }, + Unknown, +} + #[cfg(windows)] -pub fn process_id_is_running(process_id: u32) -> Option { +pub fn inspect_process_instance(process_id: u32) -> ProcessInstanceState { if process_id == 0 { - return Some(false); + return ProcessInstanceState::NotRunning; } let processes = crate::windows_integration::enumerate_processes(); if processes.is_empty() { - return None; + return ProcessInstanceState::Unknown; } - Some( - processes - .iter() - .any(|process| process.process_id == process_id), + if !processes + .iter() + .any(|process| process.process_id == process_id) + { + return ProcessInstanceState::NotRunning; + } + let birth_id = crate::windows_integration::process_birth_id(process_id); + ProcessInstanceState::Running { + started_at_secs: birth_id + .and_then(crate::windows_integration::process_started_at_secs_from_birth_id), + birth_id: birth_id.map(|birth_id| birth_id.to_string()), + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub fn inspect_process_instance(process_id: u32) -> ProcessInstanceState { + match process_id_is_running(process_id) { + Some(false) => ProcessInstanceState::NotRunning, + Some(true) => { + let (started_at_secs, birth_id) = unix_process_identity(process_id); + ProcessInstanceState::Running { + started_at_secs, + birth_id, + } + } + None => ProcessInstanceState::Unknown, + } +} + +#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] +pub fn inspect_process_instance(process_id: u32) -> ProcessInstanceState { + if process_id == 0 { + ProcessInstanceState::NotRunning + } else { + ProcessInstanceState::Unknown + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn unix_process_identity(process_id: u32) -> (Option, Option) { + let process_id_arg = process_id.to_string(); + let output = std::process::Command::new("ps") + .args([ + "-p", + process_id_arg.as_str(), + "-o", + "etime=", + "-o", + "lstart=", + ]) + .env("LC_ALL", "C") + .output(); + let Ok(output) = output else { + return (None, None); + }; + if !output.status.success() { + return (None, None); + } + let text = String::from_utf8_lossy(&output.stdout); + let text = text.trim(); + let Some(split_at) = text.find(char::is_whitespace) else { + return (None, None); + }; + let elapsed = parse_ps_elapsed_seconds(&text[..split_at]); + let birth_id = text[split_at..].trim(); + let started_at_secs = elapsed.and_then(|elapsed| { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok() + .map(|now| now.as_secs().saturating_sub(elapsed)) + }); + ( + started_at_secs, + (!birth_id.is_empty()).then(|| birth_id.to_string()), ) } +#[cfg(any(target_os = "linux", target_os = "macos", test))] +fn parse_ps_elapsed_seconds(value: &str) -> Option { + let (days, time) = if let Some((days, time)) = value.split_once('-') { + (days.parse().ok()?, time) + } else { + (0, value) + }; + let parts = time + .split(':') + .map(str::parse::) + .collect::, _>>() + .ok()?; + let (hours, minutes, seconds) = match parts.as_slice() { + [minutes, seconds] => (0, *minutes, *seconds), + [hours, minutes, seconds] => (*hours, *minutes, *seconds), + _ => return None, + }; + Some(days * 86_400 + hours * 3_600 + minutes * 60 + seconds) +} + +#[cfg(test)] +mod process_identity_tests { + use super::*; + + #[test] + fn parses_ps_elapsed_time_formats() { + assert_eq!(parse_ps_elapsed_seconds("03:04"), Some(184)); + assert_eq!(parse_ps_elapsed_seconds("02:03:04"), Some(7_384)); + assert_eq!(parse_ps_elapsed_seconds("2-02:03:04"), Some(180_184)); + assert_eq!(parse_ps_elapsed_seconds("invalid"), None); + } + + #[cfg(windows)] + #[test] + fn current_windows_process_has_a_stable_birth_identity() { + let ProcessInstanceState::Running { + started_at_secs, + birth_id, + } = inspect_process_instance(std::process::id()) + else { + panic!("current process should be visible"); + }; + + assert!(started_at_secs.is_some()); + assert!(birth_id.is_some()); + } +} + +#[cfg(windows)] +pub fn process_id_is_running(process_id: u32) -> Option { + match inspect_process_instance(process_id) { + ProcessInstanceState::NotRunning => Some(false), + ProcessInstanceState::Running { .. } => Some(true), + ProcessInstanceState::Unknown => None, + } +} + #[cfg(target_os = "linux")] pub fn process_id_is_running(process_id: u32) -> Option { if process_id == 0 { diff --git a/crates/codex-plus-core/src/windows_integration.rs b/crates/codex-plus-core/src/windows_integration.rs index f5d3cdd4b..17921d8b8 100644 --- a/crates/codex-plus-core/src/windows_integration.rs +++ b/crates/codex-plus-core/src/windows_integration.rs @@ -12,7 +12,9 @@ use std::sync::OnceLock; #[cfg(windows)] use anyhow::Context; #[cfg(windows)] -use windows::Win32::Foundation::{BOOL, CloseHandle, HANDLE, HWND, LPARAM, MAX_PATH, WPARAM}; +use windows::Win32::Foundation::{ + BOOL, CloseHandle, FILETIME, HANDLE, HWND, LPARAM, MAX_PATH, WPARAM, +}; #[cfg(windows)] use windows::Win32::System::Com::{ CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED, CoCreateInstance, CoInitializeEx, @@ -30,8 +32,8 @@ use windows::Win32::System::Registry::{ }; #[cfg(windows)] use windows::Win32::System::Threading::{ - OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_TERMINATE, QueryFullProcessImageNameW, - TerminateProcess, + GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_TERMINATE, + QueryFullProcessImageNameW, TerminateProcess, }; #[cfg(windows)] use windows::Win32::UI::Shell::PropertiesSystem::{IPropertyStore, SHGetPropertyStoreForWindow}; @@ -345,6 +347,40 @@ pub fn terminate_process(process_id: u32) -> bool { unsafe { TerminateProcess(handle, 0) }.is_ok() } +#[cfg(windows)] +pub fn process_birth_id(process_id: u32) -> Option { + let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, process_id).ok()? }; + if handle.is_invalid() { + return None; + } + let _guard = HandleGuard(handle); + let mut creation_time = FILETIME::default(); + let mut exit_time = FILETIME::default(); + let mut kernel_time = FILETIME::default(); + let mut user_time = FILETIME::default(); + unsafe { + GetProcessTimes( + handle, + &mut creation_time, + &mut exit_time, + &mut kernel_time, + &mut user_time, + ) + .ok()?; + } + Some(((creation_time.dwHighDateTime as u64) << 32) | creation_time.dwLowDateTime as u64) +} + +#[cfg(windows)] +pub fn process_started_at_secs_from_birth_id(birth_id: u64) -> Option { + const WINDOWS_TO_UNIX_EPOCH_100NS: u64 = 116_444_736_000_000_000; + const TICKS_PER_SECOND: u64 = 10_000_000; + + birth_id + .checked_sub(WINDOWS_TO_UNIX_EPOCH_100NS) + .map(|unix_ticks| unix_ticks / TICKS_PER_SECOND) +} + #[cfg(windows)] pub fn activate_process_window(process_id: u32) -> bool { let Some(hwnd) = process_window(process_id, false) else { diff --git a/crates/codex-plus-data/Cargo.toml b/crates/codex-plus-data/Cargo.toml index c0abc389b..6ad7f59ed 100644 --- a/crates/codex-plus-data/Cargo.toml +++ b/crates/codex-plus-data/Cargo.toml @@ -10,6 +10,7 @@ anyhow.workspace = true base64.workspace = true chrono = { version = "0.4", default-features = false, features = ["clock"] } codex-plus-core = { path = "../codex-plus-core" } +fs2.workspace = true rusqlite.workspace = true serde.workspace = true serde_json = { workspace = true, features = ["preserve_order"] } diff --git a/crates/codex-plus-data/src/lib.rs b/crates/codex-plus-data/src/lib.rs index 86b62e0a4..0c63033a3 100644 --- a/crates/codex-plus-data/src/lib.rs +++ b/crates/codex-plus-data/src/lib.rs @@ -6,8 +6,8 @@ pub mod storage; pub use backup::BackupStore; pub use markdown::{MarkdownExportService, export_markdown_from_paths}; pub use provider_sync::{ - ProviderSyncAudit, ProviderSyncLockState, ProviderSyncResult, ProviderSyncStatus, - ProviderSyncTargetList, ProviderSyncTargetOption, ProviderSyncTargetSource, + ProviderSyncAudit, ProviderSyncLifecycleGuard, ProviderSyncLockState, ProviderSyncResult, + ProviderSyncStatus, ProviderSyncTargetList, ProviderSyncTargetOption, ProviderSyncTargetSource, SessionIndexCleanupApplyError, SessionIndexCleanupCandidate, SessionIndexCleanupPreview, SessionIndexCleanupResult, apply_session_index_cleanup, inspect_provider_sync_lock, load_provider_sync_targets, preview_session_index_cleanup, @@ -15,5 +15,6 @@ pub use provider_sync::{ run_provider_sync_with_target, run_remote_control_session_catalog_recovery_for_thread_with_target, run_remote_control_session_finalization_for_thread_with_target, + try_acquire_provider_sync_lifecycle_guard, }; pub use storage::{LocalSession, SQLiteStorageAdapter, delete_local_from_paths}; diff --git a/crates/codex-plus-data/src/provider_sync.rs b/crates/codex-plus-data/src/provider_sync.rs index a2f1aa766..de2fdcd2b 100644 --- a/crates/codex-plus-data/src/provider_sync.rs +++ b/crates/codex-plus-data/src/provider_sync.rs @@ -1,3 +1,4 @@ +use fs2::FileExt; use rusqlite::{Connection, OptionalExtension, params_from_iter, types::Value as SqlValue}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; @@ -17,12 +18,23 @@ const REMOTE_CONTROL_CREATION_WINDOW_SECS: i64 = 15 * 60; /// 该窗口只有几毫秒,因此超过这个时长仍缺 owner 的锁一定是中断残留,可以安全回收; /// 反过来说,宽限期内的无主锁必须保留,否则会把正在建锁的同伴进程挤掉。 const LOCK_INTERRUPTED_GRACE_SECS: u64 = 60; +/// Legacy owner files do not record the OS process creation time. A live PID whose process began +/// well after the lock was created is a reused PID, not the original lock owner. +const LEGACY_PID_REUSE_TOLERANCE_SECS: u64 = 5 * 60; +const LEGACY_PID_REUSE_MIN_LOCK_AGE_SECS: u64 = 24 * 60 * 60; +const PROCESS_START_MATCH_TOLERANCE_SECS: u64 = 5; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct ProviderSyncLockOwner { pid: u32, started_at: u64, + #[serde(default)] + process_started_at: Option, + #[serde(default)] + process_birth_id: Option, + #[serde(default)] + lock_id: Option, } /// provider sync 锁的可观测状态。管理器在强杀 launcher 前用它判断 @@ -41,6 +53,55 @@ pub enum ProviderSyncLockState { Indeterminate, } +#[derive(Debug)] +pub struct ProviderSyncLifecycleGuard { + lock_dir: PathBuf, + lock_file: File, + lock_id: String, + directory_released: bool, + file_unlocked: bool, +} + +impl ProviderSyncLifecycleGuard { + /// Releases both compatibility and OS ownership before a caller starts a successor process. + /// A mismatched owner is an ABA conflict and must block the successor instead of deleting it. + pub fn release(mut self) -> std::io::Result<()> { + if !release_owned_lock(&self.lock_dir, &self.lock_id)? { + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + "provider-sync lock ownership changed before release", + )); + } + self.directory_released = true; + FileExt::unlock(&self.lock_file)?; + self.file_unlocked = true; + Ok(()) + } +} + +impl Drop for ProviderSyncLifecycleGuard { + fn drop(&mut self) { + if !self.directory_released { + let _ = release_owned_lock(&self.lock_dir, &self.lock_id); + } + if !self.file_unlocked { + let _ = FileExt::unlock(&self.lock_file); + } + } +} + +/// Atomically reserves provider-sync lifecycle ownership for a restart or a real sync. +/// The OS file lock is released automatically if the process exits; the legacy directory remains +/// present while held so older launchers also stay out of the critical section. +pub fn try_acquire_provider_sync_lifecycle_guard( + codex_home: Option<&Path>, +) -> std::io::Result { + let home = codex_home + .map(Path::to_path_buf) + .unwrap_or_else(default_codex_home_dir); + acquire_lock_inner(&home.join("tmp/provider-sync.lock"), false) +} + /// 读取 provider sync 锁的当前状态,不获取也不修改它。 pub fn inspect_provider_sync_lock(codex_home: Option<&Path>) -> ProviderSyncLockState { let home = codex_home @@ -56,7 +117,7 @@ fn inspect_lock(path: &Path) -> ProviderSyncLockState { classify_lock( read_lock_owner(path).as_ref(), lock_dir_age_secs(path), - codex_plus_core::watcher::process_id_is_running, + codex_plus_core::watcher::inspect_process_instance, ) } @@ -64,7 +125,7 @@ fn inspect_lock(path: &Path) -> ProviderSyncLockState { fn classify_lock( owner: Option<&ProviderSyncLockOwner>, age_secs: Option, - process_alive: impl Fn(u32) -> Option, + inspect_process: impl Fn(u32) -> codex_plus_core::watcher::ProcessInstanceState, ) -> ProviderSyncLockState { let Some(owner) = owner else { // owner.json 缺失或损坏。持有者只在建锁的几毫秒内处于这个状态, @@ -75,18 +136,64 @@ fn classify_lock( ProviderSyncLockState::Indeterminate }; }; - match process_alive(owner.pid) { - Some(false) => ProviderSyncLockState::Stale { + use codex_plus_core::watcher::ProcessInstanceState; + match inspect_process(owner.pid) { + ProcessInstanceState::NotRunning => ProviderSyncLockState::Stale { pid: Some(owner.pid), }, - // `None` 表示进程枚举失败,无法证明持有者已死;按「仍在持有」保守处理。 - _ => ProviderSyncLockState::Held { + ProcessInstanceState::Running { + started_at_secs, + birth_id: current_birth_id, + } => { + let birth_mismatch = owner + .process_birth_id + .as_deref() + .zip(current_birth_id.as_deref()) + .is_some_and(|(expected, current)| expected != current); + let recorded_start_mismatch = owner.process_birth_id.is_none() + && owner.process_started_at.zip(started_at_secs).is_some_and( + |(expected, current)| { + expected.abs_diff(current) > PROCESS_START_MATCH_TOLERANCE_SECS + }, + ); + let legacy_pid_reuse = owner.process_birth_id.is_none() + && owner.process_started_at.is_none() + && age_secs.is_some_and(|age| age >= LEGACY_PID_REUSE_MIN_LOCK_AGE_SECS) + && started_at_secs.is_some_and(|current| { + current + > owner + .started_at + .saturating_add(LEGACY_PID_REUSE_TOLERANCE_SECS) + }); + if birth_mismatch || recorded_start_mismatch || legacy_pid_reuse { + ProviderSyncLockState::Stale { + pid: Some(owner.pid), + } + } else { + ProviderSyncLockState::Held { + pid: owner.pid, + started_at: owner.started_at, + } + } + } + // Unknown process identity cannot prove that the owner is gone. Preserve the lock. + ProcessInstanceState::Unknown => ProviderSyncLockState::Held { pid: owner.pid, started_at: owner.started_at, }, } } +fn current_process_identity() -> (Option, Option) { + match codex_plus_core::watcher::inspect_process_instance(std::process::id()) { + codex_plus_core::watcher::ProcessInstanceState::Running { + started_at_secs, + birth_id, + } => (started_at_secs, birth_id), + _ => (None, None), + } +} + fn read_lock_owner(path: &Path) -> Option { serde_json::from_slice::(&fs::read(path.join("owner.json")).ok()?).ok() } @@ -436,16 +543,19 @@ pub fn run_remote_control_session_catalog_recovery_for_thread_with_target( .map(Path::to_path_buf) .unwrap_or_else(default_codex_home_dir); let lock_dir = home.join("tmp/provider-sync.lock"); - if acquire_lock(&lock_dir).is_err() { - return result( - ProviderSyncStatus::Skipped, - format!("Provider sync lock exists: {}", lock_dir.to_string_lossy()), - target_provider, - None, - 0, - 0, - ); - } + let _lock_guard = match acquire_lock(&lock_dir) { + Ok(guard) => guard, + Err(_) => { + return result( + ProviderSyncStatus::Skipped, + format!("Provider sync lock exists: {}", lock_dir.to_string_lossy()), + target_provider, + None, + 0, + 0, + ); + } + }; let thread_ids = HashSet::from([thread_id.to_string()]); let recovery = run_remote_control_catalog_recovery_for_threads( &home, @@ -453,7 +563,6 @@ pub fn run_remote_control_session_catalog_recovery_for_thread_with_target( target_provider, &thread_ids, ); - let _ = release_lock(&lock_dir); recovery.unwrap_or_else(|error| { result( ProviderSyncStatus::Skipped, @@ -491,16 +600,19 @@ pub fn run_remote_control_session_finalization_for_thread_with_target( .map(Path::to_path_buf) .unwrap_or_else(default_codex_home_dir); let lock_dir = home.join("tmp/provider-sync.lock"); - if acquire_lock(&lock_dir).is_err() { - return result( - ProviderSyncStatus::Skipped, - format!("Provider sync lock exists: {}", lock_dir.to_string_lossy()), - target_provider, - None, - 0, - 0, - ); - } + let _lock_guard = match acquire_lock(&lock_dir) { + Ok(guard) => guard, + Err(_) => { + return result( + ProviderSyncStatus::Skipped, + format!("Provider sync lock exists: {}", lock_dir.to_string_lossy()), + target_provider, + None, + 0, + 0, + ); + } + }; let recovery = (|| -> anyhow::Result { let sqlite_paths = provider_sync_db_paths(&home); let rollout_path = match remote_control_rollout_for_thread( @@ -595,7 +707,6 @@ pub fn run_remote_control_session_finalization_for_thread_with_target( synced.sqlite_catalog_rows_removed = sqlite_updates.catalog_remove_rows; Ok(synced) })(); - let _ = release_lock(&lock_dir); recovery.unwrap_or_else(|error| { result( ProviderSyncStatus::Skipped, @@ -706,16 +817,19 @@ pub fn run_provider_sync_with_target( } } let lock_dir = home.join("tmp/provider-sync.lock"); - if acquire_lock(&lock_dir).is_err() { - return result( - ProviderSyncStatus::Skipped, - format!("Provider sync lock exists: {}", lock_dir.to_string_lossy()), - &target_provider, - None, - 0, - 0, - ); - } + let _lock_guard = match acquire_lock(&lock_dir) { + Ok(guard) => guard, + Err(_) => { + return result( + ProviderSyncStatus::Skipped, + format!("Provider sync lock exists: {}", lock_dir.to_string_lossy()), + &target_provider, + None, + 0, + 0, + ); + } + }; let sync_result = (|| -> anyhow::Result { let sqlite_paths = provider_sync_db_paths(&home); let thread_kinds = sqlite_provider_sync_thread_kinds(&sqlite_paths)?; @@ -847,7 +961,6 @@ pub fn run_provider_sync_with_target( synced.message = provider_sync_message_with_audit(&synced.message, &synced.repair_audit); Ok(synced) })(); - let _ = release_lock(&lock_dir); sync_result.unwrap_or_else(|err| { result( ProviderSyncStatus::Skipped, @@ -1180,16 +1293,42 @@ fn toml_string_value(raw: &str) -> Option { None } -fn acquire_lock(path: &Path) -> std::io::Result<()> { +fn acquire_lock(path: &Path) -> std::io::Result { + acquire_lock_inner(path, true) +} + +fn acquire_lock_inner(path: &Path, log_busy: bool) -> std::io::Result { fs::create_dir_all(path.parent().unwrap_or_else(|| Path::new(".")))?; - match create_lock(path) { - Ok(()) => Ok(()), + let lifecycle_path = path.with_file_name("provider-sync.lifecycle.lock"); + let lock_file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .open(lifecycle_path)?; + if let Err(error) = lock_file.try_lock_exclusive() { + let error = normalize_lock_contention_error(error); + if log_busy { + log_lock_busy(path); + } + return Err(error); + } + let lock_id = uuid::Uuid::new_v4().to_string(); + match create_lock(path, &lock_id) { + Ok(()) => Ok(ProviderSyncLifecycleGuard { + lock_dir: path.to_path_buf(), + lock_file, + lock_id, + directory_released: false, + file_unlocked: false, + }), Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { let Some((owner, isolated_path)) = isolate_stale_lock(path) else { - log_lock_busy(path); + if log_busy { + log_lock_busy(path); + } return Err(error); }; - match create_lock(path) { + match create_lock(path, &lock_id) { Ok(()) => { let quarantine_cleanup_failed = fs::remove_dir_all(&isolated_path).is_err(); let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( @@ -1197,12 +1336,21 @@ fn acquire_lock(path: &Path) -> std::io::Result<()> { json!({ "owner_pid": owner.as_ref().map(|owner| owner.pid), "owner_started_at": owner.as_ref().map(|owner| owner.started_at), + "owner_process_started_at": owner + .as_ref() + .and_then(|owner| owner.process_started_at), // owner 缺失说明持有者是在建锁中途被强杀的(issue #1901) "interrupted": owner.is_none(), "quarantine_cleanup_failed": quarantine_cleanup_failed, }), ); - Ok(()) + Ok(ProviderSyncLifecycleGuard { + lock_dir: path.to_path_buf(), + lock_file, + lock_id, + directory_released: false, + file_unlocked: false, + }) } Err(retry_error) => { let _ = fs::remove_dir_all(isolated_path); @@ -1214,6 +1362,14 @@ fn acquire_lock(path: &Path) -> std::io::Result<()> { } } +fn normalize_lock_contention_error(error: std::io::Error) -> std::io::Error { + #[cfg(windows)] + if error.raw_os_error() == Some(33) { + return std::io::Error::new(std::io::ErrorKind::WouldBlock, error); + } + error +} + /// 锁没能拿到时留下现场,用于区分「另一个同步真的在跑」和「残留锁把同步永久卡死」。 fn log_lock_busy(path: &Path) { let state = inspect_lock(path); @@ -1227,11 +1383,19 @@ fn log_lock_busy(path: &Path) { ); } -fn create_lock(path: &Path) -> std::io::Result<()> { +fn create_lock(path: &Path, lock_id: &str) -> std::io::Result<()> { fs::create_dir(path)?; + let (process_started_at, process_birth_id) = current_process_identity(); let write_result = fs::write( path.join("owner.json"), - json!({"pid": std::process::id(), "startedAt": now_secs()}).to_string(), + json!({ + "pid": std::process::id(), + "startedAt": now_secs(), + "processStartedAt": process_started_at, + "processBirthId": process_birth_id, + "lockId": lock_id, + }) + .to_string(), ); if let Err(error) = write_result { let _ = fs::remove_dir_all(path); @@ -1240,18 +1404,28 @@ fn create_lock(path: &Path) -> std::io::Result<()> { Ok(()) } -/// 把一把可以证明已经失效的锁挪到隔离路径,让调用方重新建锁。 +/// 在已经持有 OS 生命周期锁时,把可回收的兼容目录挪到隔离路径,让调用方重新建锁。 /// -/// 两种可回收的形态: +/// 三种可回收的形态: +/// - owner.json 带 `lockId`,证明目录来自新版协议;OS 锁既然已取得,该目录必为孤儿; /// - owner.json 可读且持有进程已退出(正常的崩溃残留); /// - owner.json 缺失/损坏,且锁目录存在时间已超过 [`LOCK_INTERRUPTED_GRACE_SECS`] /// ——持有者在 `create_lock` 中途被强杀,不会再有人来补写 owner(issue #1901)。 /// /// 其余情况一律保留锁:宁可跳过一次同步,也不能抢走仍在写入的进程的锁。 fn isolate_stale_lock(path: &Path) -> Option<(Option, PathBuf)> { - let owner = match inspect_lock(path) { - ProviderSyncLockState::Stale { .. } => read_lock_owner(path), - _ => return None, + let parsed_owner = read_lock_owner(path); + let owner = if parsed_owner + .as_ref() + .and_then(|owner| owner.lock_id.as_ref()) + .is_some() + { + parsed_owner + } else { + match inspect_lock(path) { + ProviderSyncLockState::Stale { .. } => parsed_owner, + _ => return None, + } }; let file_name = path.file_name()?.to_string_lossy(); let owner_tag = owner @@ -1265,11 +1439,18 @@ fn isolate_stale_lock(path: &Path) -> Option<(Option, Pat Some((owner, isolated_path)) } -fn release_lock(path: &Path) -> std::io::Result<()> { - if path.exists() { +fn release_owned_lock(path: &Path, lock_id: &str) -> std::io::Result { + if !path.exists() { + return Ok(true); + } + if read_lock_owner(path) + .and_then(|owner| owner.lock_id) + .is_some_and(|owner_lock_id| owner_lock_id == lock_id) + { fs::remove_dir_all(path)?; + return Ok(true); } - Ok(()) + Ok(false) } fn collect_session_changes( @@ -1923,7 +2104,7 @@ pub fn apply_session_index_cleanup( .map(Path::to_path_buf) .unwrap_or_else(default_codex_home_dir); let lock_dir = home.join("tmp/provider-sync.lock"); - acquire_lock(&lock_dir).map_err(|error| cleanup_apply_error(error, None))?; + let _lock_guard = acquire_lock(&lock_dir).map_err(|error| cleanup_apply_error(error, None))?; let result = (|| { let sqlite_paths = codex_plus_core::codex_sqlite::codex_thread_reference_db_paths_from_home(&home); @@ -1993,7 +2174,6 @@ pub fn apply_session_index_cleanup( backup_dir: Some(backup_dir), }) })(); - let _ = release_lock(&lock_dir); result } @@ -3921,17 +4101,28 @@ mod non_root_agent_tests { #[cfg(test)] mod lock_state_tests { use super::*; + use codex_plus_core::watcher::ProcessInstanceState; fn owner(pid: u32) -> ProviderSyncLockOwner { ProviderSyncLockOwner { pid, started_at: 1234, + process_started_at: Some(1200), + process_birth_id: Some("birth-1200".to_string()), + lock_id: Some("lock-1".to_string()), + } + } + + fn running(started_at_secs: Option) -> ProcessInstanceState { + ProcessInstanceState::Running { + started_at_secs, + birth_id: started_at_secs.map(|started_at| format!("birth-{started_at}")), } } #[test] fn live_owner_counts_as_held() { - let state = classify_lock(Some(&owner(42)), Some(0), |_| Some(true)); + let state = classify_lock(Some(&owner(42)), Some(0), |_| running(Some(1200))); assert_eq!( state, @@ -3944,14 +4135,112 @@ mod lock_state_tests { #[test] fn dead_owner_counts_as_stale() { - let state = classify_lock(Some(&owner(42)), Some(0), |_| Some(false)); + let state = classify_lock(Some(&owner(42)), Some(0), |_| { + ProcessInstanceState::NotRunning + }); assert_eq!(state, ProviderSyncLockState::Stale { pid: Some(42) }); } #[test] - fn unknown_liveness_is_treated_as_held_rather_than_stolen() { - let state = classify_lock(Some(&owner(42)), Some(9_999), |_| None); + fn reused_pid_with_a_different_process_start_is_stale() { + let state = classify_lock(Some(&owner(42)), Some(9_999), |_| running(Some(5000))); + + assert_eq!(state, ProviderSyncLockState::Stale { pid: Some(42) }); + } + + #[test] + fn matching_birth_id_tolerates_approximate_unix_start_time_drift() { + let state = classify_lock(Some(&owner(42)), Some(0), |_| { + ProcessInstanceState::Running { + started_at_secs: Some(1201), + birth_id: Some("birth-1200".to_string()), + } + }); + + assert_eq!( + state, + ProviderSyncLockState::Held { + pid: 42, + started_at: 1234 + } + ); + } + + #[test] + fn legacy_owner_with_a_much_newer_process_is_stale() { + let legacy_owner = ProviderSyncLockOwner { + process_started_at: None, + process_birth_id: None, + lock_id: None, + ..owner(42) + }; + let state = classify_lock( + Some(&legacy_owner), + Some(LEGACY_PID_REUSE_MIN_LOCK_AGE_SECS), + |_| { + running(Some( + legacy_owner.started_at + LEGACY_PID_REUSE_TOLERANCE_SECS + 1, + )) + }, + ); + + assert_eq!(state, ProviderSyncLockState::Stale { pid: Some(42) }); + } + + #[test] + fn legacy_owner_keeps_a_process_started_before_the_lock() { + let legacy_owner = ProviderSyncLockOwner { + process_started_at: None, + process_birth_id: None, + lock_id: None, + ..owner(42) + }; + let state = classify_lock(Some(&legacy_owner), Some(9_999), |_| { + running(Some(legacy_owner.started_at - 1)) + }); + + assert_eq!( + state, + ProviderSyncLockState::Held { + pid: 42, + started_at: 1234 + } + ); + } + + #[test] + fn recent_legacy_lock_remains_held_even_if_wall_clock_evidence_looks_newer() { + let legacy_owner = ProviderSyncLockOwner { + process_started_at: None, + process_birth_id: None, + lock_id: None, + ..owner(42) + }; + let state = classify_lock( + Some(&legacy_owner), + Some(LEGACY_PID_REUSE_MIN_LOCK_AGE_SECS - 1), + |_| { + running(Some( + legacy_owner.started_at + LEGACY_PID_REUSE_TOLERANCE_SECS + 1, + )) + }, + ); + + assert_eq!( + state, + ProviderSyncLockState::Held { + pid: 42, + started_at: 1234 + } + ); + } + + #[test] + fn unknown_process_identity_is_treated_as_held_rather_than_stolen() { + let state = classify_lock(Some(&owner(42)), Some(9_999), |_| { + ProcessInstanceState::Unknown + }); assert_eq!( state, @@ -3964,22 +4253,121 @@ mod lock_state_tests { #[test] fn aged_lock_without_owner_is_recoverable_interrupted_leftover() { - let state = classify_lock(None, Some(LOCK_INTERRUPTED_GRACE_SECS), |_| Some(true)); + let state = classify_lock(None, Some(LOCK_INTERRUPTED_GRACE_SECS), |_| { + running(Some(1200)) + }); assert_eq!(state, ProviderSyncLockState::Stale { pid: None }); } #[test] fn fresh_lock_without_owner_is_left_alone_for_the_process_still_creating_it() { - let state = classify_lock(None, Some(LOCK_INTERRUPTED_GRACE_SECS - 1), |_| Some(true)); + let state = classify_lock(None, Some(LOCK_INTERRUPTED_GRACE_SECS - 1), |_| { + running(Some(1200)) + }); assert_eq!(state, ProviderSyncLockState::Indeterminate); } #[test] fn unreadable_lock_age_is_left_alone() { - let state = classify_lock(None, None, |_| Some(true)); + let state = classify_lock(None, None, |_| running(Some(1200))); assert_eq!(state, ProviderSyncLockState::Indeterminate); } + + #[test] + fn legacy_owner_json_remains_compatible() { + let owner: ProviderSyncLockOwner = + serde_json::from_str(r#"{"pid":42,"startedAt":1234}"#).unwrap(); + + assert_eq!(owner.pid, 42); + assert_eq!(owner.started_at, 1234); + assert_eq!(owner.process_started_at, None); + assert_eq!(owner.process_birth_id, None); + assert_eq!(owner.lock_id, None); + } + + #[test] + fn lifecycle_guard_serializes_and_releases_the_legacy_directory() { + let temp = tempfile::tempdir().unwrap(); + let lock_dir = temp.path().join("tmp/provider-sync.lock"); + let first = acquire_lock_inner(&lock_dir, false).unwrap(); + + assert!(lock_dir.join("owner.json").is_file()); + let error = acquire_lock_inner(&lock_dir, false).unwrap_err(); + assert!( + matches!( + error.kind(), + std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::WouldBlock + ), + "unexpected lock contention error: {error:?}; raw={:?}", + error.raw_os_error() + ); + + drop(first); + assert!(!lock_dir.exists()); + let second = acquire_lock_inner(&lock_dir, false).unwrap(); + drop(second); + assert!(!lock_dir.exists()); + } + + #[test] + fn a_guard_cannot_remove_a_directory_owned_by_another_lock_id() { + let temp = tempfile::tempdir().unwrap(); + let lock_dir = temp.path().join("tmp/provider-sync.lock"); + let guard = acquire_lock_inner(&lock_dir, false).unwrap(); + + assert!(!release_owned_lock(&lock_dir, "not-the-owner").unwrap()); + assert!(lock_dir.join("owner.json").is_file()); + + drop(guard); + assert!(!lock_dir.exists()); + } + + #[test] + fn explicit_release_rejects_changed_directory_ownership() { + let temp = tempfile::tempdir().unwrap(); + let lock_dir = temp.path().join("tmp/provider-sync.lock"); + let guard = acquire_lock_inner(&lock_dir, false).unwrap(); + fs::write( + lock_dir.join("owner.json"), + json!({ + "pid": std::process::id(), + "startedAt": now_secs(), + "lockId": "replacement-owner", + }) + .to_string(), + ) + .unwrap(); + + let error = guard.release().unwrap_err(); + + assert_eq!(error.kind(), std::io::ErrorKind::Other); + assert!(lock_dir.exists()); + } + + #[test] + fn os_lock_authoritatively_recovers_an_orphaned_new_protocol_directory() { + let temp = tempfile::tempdir().unwrap(); + let lock_dir = temp.path().join("tmp/provider-sync.lock"); + let guard = acquire_lock_inner(&lock_dir, false).unwrap(); + fs::write( + lock_dir.join("owner.json"), + json!({ + "pid": std::process::id(), + "startedAt": now_secs(), + "lockId": "orphaned-owner", + }) + .to_string(), + ) + .unwrap(); + drop(guard); + assert!(lock_dir.exists()); + + let recovered = acquire_lock_inner(&lock_dir, false).unwrap(); + recovered.release().unwrap(); + + assert!(!lock_dir.exists()); + } } diff --git a/crates/codex-plus-data/tests/provider_sync.rs b/crates/codex-plus-data/tests/provider_sync.rs index b61f9e567..7b4ff59a5 100644 --- a/crates/codex-plus-data/tests/provider_sync.rs +++ b/crates/codex-plus-data/tests/provider_sync.rs @@ -2651,7 +2651,7 @@ fn provider_sync_recovers_lock_owned_by_dead_process() { .unwrap() .contains("provider_sync.stale_lock_recovered") ); - assert!(fs::read_dir(home.join("tmp")).unwrap().next().is_none()); + assert!(home.join("tmp/provider-sync.lifecycle.lock").is_file()); } #[test] From 9c901a485f9459199b593f4d43913448e3a61fb1 Mon Sep 17 00:00:00 2001 From: Yuimi_chaya <124485273+Yuimi-chaya@users.noreply.github.com> Date: Thu, 27 Aug 2026 07:19:37 +0800 Subject: [PATCH 02/12] fix(data): bound provider sync memory and recovery --- Cargo.lock | 1 + crates/codex-plus-core/src/settings.rs | 158 +- crates/codex-plus-data/Cargo.toml | 3 + crates/codex-plus-data/src/provider_sync.rs | 2500 ++++++++++++++--- crates/codex-plus-data/tests/provider_sync.rs | 677 +++++ 5 files changed, 2988 insertions(+), 351 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 272f033d2..096c059e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -455,6 +455,7 @@ dependencies = [ "chrono", "codex-plus-core", "fs2", + "libc", "rusqlite", "serde", "serde_json", diff --git a/crates/codex-plus-core/src/settings.rs b/crates/codex-plus-core/src/settings.rs index 80c59105e..cd50605d1 100644 --- a/crates/codex-plus-core/src/settings.rs +++ b/crates/codex-plus-core/src/settings.rs @@ -1685,7 +1685,7 @@ pub fn atomic_write(path: &Path, bytes: &[u8]) -> anyhow::Result<()> { let temp_path = temp_path_for(path); fs::write(&temp_path, bytes) .with_context(|| format!("failed to write temp file {}", temp_path.display()))?; - if let Err(error) = replace_file(&temp_path, path) { + if let Err(error) = atomic_replace_file(&temp_path, path) { let _ = fs::remove_file(&temp_path); return Err(error).with_context(|| { format!( @@ -1699,13 +1699,13 @@ pub fn atomic_write(path: &Path, bytes: &[u8]) -> anyhow::Result<()> { } #[cfg(not(windows))] -fn replace_file(source: &Path, target: &Path) -> anyhow::Result<()> { +pub fn atomic_replace_file(source: &Path, target: &Path) -> anyhow::Result<()> { fs::rename(source, target)?; Ok(()) } #[cfg(windows)] -fn replace_file(source: &Path, target: &Path) -> anyhow::Result<()> { +pub fn atomic_replace_file(source: &Path, target: &Path) -> anyhow::Result<()> { use std::os::windows::ffi::OsStrExt; use windows::Win32::Storage::FileSystem::{ MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, @@ -1732,6 +1732,130 @@ fn replace_file(source: &Path, target: &Path) -> anyhow::Result<()> { Ok(()) } +#[cfg(not(windows))] +pub fn atomic_replace_file_with_backup( + replacement: &Path, + target: &Path, + backup: &Path, +) -> anyhow::Result<()> { + if backup.exists() { + anyhow::bail!( + "atomic replacement backup already exists: {}", + backup.display() + ); + } + fs::rename(target, backup)?; + if let Err(error) = fs::hard_link(replacement, target) { + if !target.exists() { + let _ = fs::rename(backup, target); + } + return Err(error.into()); + } + fs::remove_file(replacement)?; + Ok(()) +} + +#[cfg(windows)] +pub fn directory_instance_identity(path: &Path) -> anyhow::Result { + use std::os::windows::fs::OpenOptionsExt; + use windows::Win32::Storage::FileSystem::FILE_FLAG_BACKUP_SEMANTICS; + + let directory = fs::OpenOptions::new() + .read(true) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS.0) + .open(path)?; + file_instance_identity(&directory) +} + +#[cfg(windows)] +pub fn file_instance_identity(file: &fs::File) -> anyhow::Result { + use std::os::windows::io::AsRawHandle; + use windows::Win32::Foundation::HANDLE; + use windows::Win32::Storage::FileSystem::{ + BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle, + }; + + let mut information = BY_HANDLE_FILE_INFORMATION::default(); + unsafe { + GetFileInformationByHandle(HANDLE(file.as_raw_handle()), &mut information)?; + } + Ok(format!( + "windows:{}:{:08x}{:08x}", + information.dwVolumeSerialNumber, information.nFileIndexHigh, information.nFileIndexLow + )) +} + +#[cfg(unix)] +pub fn directory_instance_identity(path: &Path) -> anyhow::Result { + file_instance_identity(&fs::File::open(path)?) +} + +#[cfg(unix)] +pub fn file_instance_identity(file: &fs::File) -> anyhow::Result { + use std::os::unix::fs::MetadataExt; + + let metadata = file.metadata()?; + Ok(format!("unix:{}:{}", metadata.dev(), metadata.ino())) +} + +#[cfg(not(any(windows, unix)))] +pub fn directory_instance_identity(path: &Path) -> anyhow::Result { + file_instance_identity(&fs::File::open(path)?) +} + +#[cfg(not(any(windows, unix)))] +pub fn file_instance_identity(file: &fs::File) -> anyhow::Result { + let metadata = file.metadata()?; + let modified = metadata + .modified()? + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + Ok(format!( + "generic:{}:{}:{}", + metadata.len(), + modified.as_secs(), + modified.subsec_nanos() + )) +} + +#[cfg(windows)] +pub fn atomic_replace_file_with_backup( + replacement: &Path, + target: &Path, + backup: &Path, +) -> anyhow::Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows::Win32::Storage::FileSystem::{REPLACE_FILE_FLAGS, ReplaceFileW}; + use windows::core::PCWSTR; + + let replacement = replacement + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let target = target + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let backup = backup + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + unsafe { + ReplaceFileW( + PCWSTR(target.as_ptr()), + PCWSTR(replacement.as_ptr()), + PCWSTR(backup.as_ptr()), + REPLACE_FILE_FLAGS(0), + None, + None, + )?; + } + Ok(()) +} + fn temp_path_for(path: &Path) -> PathBuf { let mut temp_path = path.to_path_buf(); let extension = path.extension().and_then(|value| value.to_str()); @@ -1773,6 +1897,34 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); } + #[test] + fn atomic_replace_file_with_backup_preserves_the_displaced_file() { + let dir = temp_dir(); + let target = dir.join("target.json"); + let replacement = dir.join("replacement.json"); + let backup = dir.join("displaced.json"); + std::fs::write(&target, b"old").unwrap(); + std::fs::write(&replacement, b"new").unwrap(); + + atomic_replace_file_with_backup(&replacement, &target, &backup).unwrap(); + + assert_eq!(std::fs::read(&target).unwrap(), b"new"); + assert_eq!(std::fs::read(&backup).unwrap(), b"old"); + assert!(!replacement.exists()); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn directory_instance_identity_is_stable_for_the_same_directory() { + let dir = temp_dir(); + + let first = directory_instance_identity(&dir).unwrap(); + let second = directory_instance_identity(&dir).unwrap(); + + assert_eq!(first, second); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn settings_default_matches_expected_behavior() { let settings = BackendSettings::default(); diff --git a/crates/codex-plus-data/Cargo.toml b/crates/codex-plus-data/Cargo.toml index 6ad7f59ed..9f7b2f121 100644 --- a/crates/codex-plus-data/Cargo.toml +++ b/crates/codex-plus-data/Cargo.toml @@ -18,5 +18,8 @@ sha2.workspace = true thiserror.workspace = true uuid.workspace = true +[target.'cfg(unix)'.dependencies] +libc = "0.2" + [dev-dependencies] tempfile.workspace = true diff --git a/crates/codex-plus-data/src/provider_sync.rs b/crates/codex-plus-data/src/provider_sync.rs index de2fdcd2b..066f8ee3f 100644 --- a/crates/codex-plus-data/src/provider_sync.rs +++ b/crates/codex-plus-data/src/provider_sync.rs @@ -5,7 +5,7 @@ use serde_json::{Map, Value, json}; use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; use std::fs::{self, File, OpenOptions}; -use std::io::{Read, Seek, SeekFrom, Write}; +use std::io::{BufRead, BufReader, BufWriter, Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -13,6 +13,11 @@ const DEFAULT_PROVIDER: &str = "openai"; const SESSION_DIRS: [&str; 2] = ["sessions", "archived_sessions"]; const BACKUP_KEEP_COUNT: usize = 5; const REMOTE_CONTROL_CREATION_WINDOW_SECS: i64 = 15 * 60; +const SESSION_TRANSACTION_FILE: &str = "session-transaction.json"; +const SESSION_TRANSACTION_NAMESPACE: &str = "provider-sync-rollout-transaction"; +const SESSION_TRANSACTION_IN_PROGRESS: &str = "in_progress"; +const SESSION_TRANSACTION_COMMITTED: &str = "committed"; +const SESSION_TRANSACTION_ROLLED_BACK: &str = "rolled_back"; /// `create_lock` 先建目录再写 `owner.json`,两步之间被强杀会留下没有 owner 的锁目录。 /// 该窗口只有几毫秒,因此超过这个时长仍缺 owner 的锁一定是中断残留,可以安全回收; @@ -308,25 +313,34 @@ pub struct ProviderSyncTargetList { #[derive(Debug, Clone)] struct SessionChange { path: PathBuf, - original_text: String, - next_text: String, - original_session_meta_lines: Vec, + original_sha256: String, + original_size: u64, thread_id: Option, cwd: Option, has_user_event: bool, rewrite_needed: bool, original_mtime: Option, + rewrite_mode: SessionRewriteMode, +} + +#[derive(Debug, Clone)] +enum SessionRewriteMode { + AllProviders, + SourceProvider { source_provider: String }, } #[derive(Debug, Default)] struct RolloutRewrite { - next_text: String, rewrite_needed: bool, thread_id: Option, cwd: Option, - providers: Vec, - original_session_meta_lines: Vec, + providers: HashSet, session_meta_count: usize, + has_user_event: bool, + has_encrypted_content: bool, + marks_non_root_agent: bool, + original_sha256: String, + original_size: u64, } #[derive(Debug, Default)] @@ -345,10 +359,94 @@ struct ProviderSyncThreadKinds { #[derive(Debug, Default)] struct AppliedSessionChanges { - changes: Vec, + changed_files: usize, skipped_locked_rollout_files: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SessionTransactionEntry { + relative_path: String, + original_sha256: String, + next_sha256: String, + original_size: u64, + next_size: u64, + session_meta_backup_sha256: String, + original_mtime_secs: Option, + original_mtime_nanos: Option, + #[serde(default)] + external_sha256: Option, + #[serde(default)] + external_size: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum SessionTransactionMode { + Full, + Remote, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum SessionTransactionPhase { + RolloutsApplying, + RolloutsApplied, + DownstreamStarted, + CommitDecided, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SessionTransactionRootEvidence { + canonical_path: String, + identity: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProviderSyncBackupFileEvidence { + size: u64, + sha256: String, +} + +struct PreparedProviderSyncBackupFile { + file: File, + modified: Option, + evidence: ProviderSyncBackupFileEvidence, +} + +struct PreparedProviderSyncBackupSet { + files: HashMap, + snapshot_dir: PathBuf, +} + +impl Drop for PreparedProviderSyncBackupSet { + fn drop(&mut self) { + self.files.clear(); + let _ = fs::remove_dir_all(&self.snapshot_dir); + } +} + +struct ProviderSyncDirectoryEvidence { + canonical_path: PathBuf, + identity: String, + guard: File, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SessionTransactionManifest { + version: u32, + namespace: String, + status: String, + transaction_id: String, + mode: SessionTransactionMode, + phase: SessionTransactionPhase, + rollout_roots: HashMap, + entries: Vec, +} + #[derive(Debug, Clone)] struct SessionIndexPlan { path: PathBuf, @@ -517,6 +615,7 @@ pub fn run_remote_control_session_catalog_recovery_for_thread_with_target( thread_id: &str, target_provider: &str, ) -> ProviderSyncResult { + let require_stopped_app = codex_home.is_none(); let thread_id = thread_id.trim(); if thread_id.is_empty() || thread_id.len() > 128 { return result( @@ -556,6 +655,30 @@ pub fn run_remote_control_session_catalog_recovery_for_thread_with_target( ); } }; + if require_stopped_app { + let running_processes = + codex_plus_core::watcher::find_session_index_cleanup_blocking_processes(); + if !running_processes.is_empty() { + return result( + ProviderSyncStatus::Skipped, + "Remote Control session catalog recovery requires Codex App / ChatGPT to be stopped", + target_provider, + None, + 0, + 0, + ); + } + } + if let Err(error) = recover_interrupted_session_transactions(&home) { + return result( + ProviderSyncStatus::Skipped, + format!("Remote Control session catalog recovery skipped: {error}"), + target_provider, + None, + 0, + 0, + ); + } let thread_ids = HashSet::from([thread_id.to_string()]); let recovery = run_remote_control_catalog_recovery_for_threads( &home, @@ -580,6 +703,7 @@ pub fn run_remote_control_session_finalization_for_thread_with_target( thread_id: &str, target_provider: &str, ) -> ProviderSyncResult { + let require_stopped_app = codex_home.is_none(); let thread_id = thread_id.trim(); let target_provider = target_provider.trim(); if thread_id.is_empty() @@ -613,7 +737,22 @@ pub fn run_remote_control_session_finalization_for_thread_with_target( ); } }; + if require_stopped_app { + let running_processes = + codex_plus_core::watcher::find_session_index_cleanup_blocking_processes(); + if !running_processes.is_empty() { + return result( + ProviderSyncStatus::Skipped, + "Remote Control session finalization requires Codex App / ChatGPT to be stopped", + target_provider, + None, + 0, + 0, + ); + } + } let recovery = (|| -> anyhow::Result { + recover_interrupted_session_transactions(&home)?; let sqlite_paths = provider_sync_db_paths(&home); let rollout_path = match remote_control_rollout_for_thread( &home, @@ -665,20 +804,29 @@ pub fn run_remote_control_session_finalization_for_thread_with_target( .filter(|change| change.rewrite_needed) .cloned() .collect::>(); - let backup_dir = create_backup(&home, target_provider, &rewrite_changes)?; - let applied = apply_session_changes(&rewrite_changes)?; + let backup_dir = create_backup( + &home, + target_provider, + SessionTransactionMode::Remote, + &rewrite_changes, + )?; + let applied = apply_session_changes(&home, &backup_dir, target_provider, &rewrite_changes)?; + set_session_transaction_phase(&backup_dir, SessionTransactionPhase::RolloutsApplied)?; if !rollout_file_matches_provider(&rollout_path, thread_id, target_provider)? { + rollback_session_transaction(&home, &backup_dir)?; let mut deferred = result( ProviderSyncStatus::Skipped, "Remote Control session finalization deferred for a changed or locked rollout", target_provider, Some(backup_dir), - applied.changes.len(), + applied.changed_files, 0, ); deferred.skipped_locked_rollout_files = applied.skipped_locked_rollout_files; return Ok(deferred); } + set_session_transaction_phase(&backup_dir, SessionTransactionPhase::CommitDecided)?; + commit_session_transaction(&backup_dir)?; let thread_ids = HashSet::from([thread_id.to_string()]); let catalog_repairs = repair_missing_local_thread_catalog_rows_for_threads( &home, @@ -699,7 +847,7 @@ pub fn run_remote_control_session_finalization_for_thread_with_target( "Remote Control session finalization complete", target_provider, Some(backup_dir), - applied.changes.len(), + applied.changed_files, sqlite_updates.total(), ); synced.sqlite_provider_rows_updated = sqlite_updates.provider_rows; @@ -830,7 +978,22 @@ pub fn run_provider_sync_with_target( ); } }; + if require_stopped_app { + let running_processes = + codex_plus_core::watcher::find_session_index_cleanup_blocking_processes(); + if !running_processes.is_empty() { + return result( + ProviderSyncStatus::Skipped, + "Codex App / ChatGPT started before provider-sync recovery", + &target_provider, + None, + 0, + 0, + ); + } + } let sync_result = (|| -> anyhow::Result { + recover_interrupted_session_transactions(&home)?; let sqlite_paths = provider_sync_db_paths(&home); let thread_kinds = sqlite_provider_sync_thread_kinds(&sqlite_paths)?; let repair_audit = match audit_provider_sync_state(&home, &sqlite_paths) { @@ -909,8 +1072,25 @@ pub fn run_provider_sync_with_target( provider_sync_message_with_audit(&synced.message, &synced.repair_audit); return Ok(synced); } - let backup_dir = create_backup(&home, &target_provider, &rewrite_changes)?; - let applied = apply_session_changes(&rewrite_changes)?; + if require_stopped_app { + let running_processes = + codex_plus_core::watcher::find_session_index_cleanup_blocking_processes(); + if !running_processes.is_empty() { + anyhow::bail!( + "Codex App / ChatGPT started while provider sync was scanning rollouts" + ); + } + } + let backup_dir = create_backup( + &home, + &target_provider, + SessionTransactionMode::Full, + &rewrite_changes, + )?; + let applied = + apply_session_changes(&home, &backup_dir, &target_provider, &rewrite_changes)?; + set_session_transaction_phase(&backup_dir, SessionTransactionPhase::RolloutsApplied)?; + set_session_transaction_phase(&backup_dir, SessionTransactionPhase::DownstreamStarted)?; let apply_result = (|| -> anyhow::Result<(SqliteUpdateCounts, usize)> { let sqlite_updates = apply_sqlite_update_for_paths( &sqlite_paths, @@ -926,22 +1106,31 @@ pub fn run_provider_sync_with_target( sqlite_updates.catalog_remove_rows = catalog_repairs.removed_rows; let updated_workspace_roots = apply_global_state_update(&home.join(".codex-global-state.json"))?; - prune_backups(&home)?; Ok((sqlite_updates, updated_workspace_roots)) })(); let (sqlite_updates, updated_workspace_roots) = match apply_result { Ok(counts) => counts, Err(err) => { - let _ = restore_session_changes(&applied.changes); + restore_provider_sync_downstream_backup(&home, &backup_dir).map_err( + |restore_error| { + anyhow::anyhow!( + "provider-sync downstream update failed ({err}); backup restore also failed: {restore_error}" + ) + }, + )?; + rollback_session_transaction(&home, &backup_dir)?; return Err(err); } }; + set_session_transaction_phase(&backup_dir, SessionTransactionPhase::CommitDecided)?; + commit_session_transaction(&backup_dir)?; + prune_backups(&home)?; let mut synced = result( ProviderSyncStatus::Synced, "Provider sync complete", &target_provider, Some(backup_dir), - applied.changes.len(), + applied.changed_files, sqlite_updates.total(), ); synced.skipped_locked_rollout_files = collected.skipped_locked_rollout_files; @@ -1461,15 +1650,18 @@ fn collect_session_changes( ) -> anyhow::Result { let mut collected = SessionChanges::default(); for path in rollout_files(home)? { - let text = match fs::read_to_string(&path) { - Ok(text) => text, + let rewrite = match scan_rollout_session_meta_providers( + &path, + target_provider, + &SessionRewriteMode::AllProviders, + ) { + Ok(rewrite) => rewrite, Err(error) if is_locked_io_error(&error) => { collected.skipped_locked_rollout_files.push(path); continue; } Err(error) => return Err(error.into()), }; - let rewrite = rewrite_rollout_session_meta_providers(&text, target_provider)?; if rewrite.session_meta_count == 0 { continue; } @@ -1477,7 +1669,7 @@ fn collect_session_changes( .thread_id .as_ref() .is_some_and(|thread_id| explicit_user_thread_ids.contains(thread_id)); - if rollout_session_meta_marks_non_root_agent(&text) { + if rewrite.marks_non_root_agent { if let Some(thread_id) = &rewrite.thread_id { collected.subagent_thread_ids.insert(thread_id.clone()); } @@ -1491,8 +1683,7 @@ fn collect_session_changes( { continue; } - let has_user_event = text.contains("\"user_message\"") || text.contains("\"user_input\""); - if text.contains("encrypted_content") { + if rewrite.has_encrypted_content { for provider in &rewrite.providers { *collected .encrypted_content_counts @@ -1503,32 +1694,19 @@ fn collect_session_changes( let original_mtime = fs::metadata(&path).and_then(|m| m.modified()).ok(); collected.changes.push(SessionChange { path, - original_text: text, - next_text: rewrite.next_text, - original_session_meta_lines: rewrite.original_session_meta_lines, + original_sha256: rewrite.original_sha256, + original_size: rewrite.original_size, thread_id: rewrite.thread_id, cwd: rewrite.cwd, - has_user_event, + has_user_event: rewrite.has_user_event, rewrite_needed: rewrite.rewrite_needed, original_mtime, + rewrite_mode: SessionRewriteMode::AllProviders, }); } Ok(collected) } -fn rollout_session_meta_marks_non_root_agent(text: &str) -> bool { - text.lines().any(|line| { - let Ok(record) = serde_json::from_str::(line) else { - return false; - }; - record.get("type").and_then(Value::as_str) == Some("session_meta") - && record - .get("payload") - .and_then(|payload| payload.get("source")) - .is_some_and(source_value_marks_non_root_agent) - }) -} - fn remote_control_rollout_for_thread( home: &Path, paths: &[PathBuf], @@ -1641,12 +1819,18 @@ fn resolve_active_rollout_path(home: &Path, value: &str) -> Option { fn rollout_provider_state_for_path( path: &Path, ) -> anyhow::Result)>> { - let text = match fs::read_to_string(path) { - Ok(text) => text, + let rewrite = match scan_rollout_session_meta_providers( + path, + DEFAULT_PROVIDER, + &SessionRewriteMode::AllProviders, + ) { + Ok(rewrite) => rewrite, Err(error) if is_locked_io_error(&error) => return Ok(None), Err(error) => return Err(error.into()), }; - Ok(rollout_thread_provider_state(&text)) + Ok(rewrite + .thread_id + .map(|thread_id| (thread_id, rewrite.providers.into_iter().collect()))) } fn collect_session_change_for_path( @@ -1656,8 +1840,11 @@ fn collect_session_change_for_path( thread_id: &str, ) -> anyhow::Result { let mut collected = SessionChanges::default(); - let text = match fs::read_to_string(path) { - Ok(text) => text, + let rewrite_mode = SessionRewriteMode::SourceProvider { + source_provider: source_provider.to_string(), + }; + let rewrite = match scan_rollout_session_meta_providers(path, target_provider, &rewrite_mode) { + Ok(rewrite) => rewrite, Err(error) if is_locked_io_error(&error) => { collected .skipped_locked_rollout_files @@ -1666,17 +1853,10 @@ fn collect_session_change_for_path( } Err(error) => return Err(error.into()), }; - let rewrite = rewrite_rollout_session_meta_providers_for_threads( - &text, - target_provider, - source_provider, - &HashSet::from([thread_id.to_string()]), - )?; if rewrite.session_meta_count == 0 || rewrite.thread_id.as_deref() != Some(thread_id) { return Ok(collected); } - let has_user_event = text.contains("\"user_message\"") || text.contains("\"user_input\""); - if text.contains("encrypted_content") { + if rewrite.has_encrypted_content { for provider in &rewrite.providers { *collected .encrypted_content_counts @@ -1689,14 +1869,14 @@ fn collect_session_change_for_path( .ok(); collected.changes.push(SessionChange { path: path.to_path_buf(), - original_text: text, - next_text: rewrite.next_text, - original_session_meta_lines: rewrite.original_session_meta_lines, + original_sha256: rewrite.original_sha256, + original_size: rewrite.original_size, thread_id: rewrite.thread_id, cwd: rewrite.cwd, - has_user_event, + has_user_event: rewrite.has_user_event, rewrite_needed: rewrite.rewrite_needed, original_mtime, + rewrite_mode, }); Ok(collected) } @@ -1714,139 +1894,105 @@ fn rollout_file_matches_provider( && providers.iter().all(|provider| provider == target_provider)) } -fn rewrite_rollout_session_meta_providers( - text: &str, +fn scan_rollout_session_meta_providers( + path: &Path, target_provider: &str, -) -> anyhow::Result { + rewrite_mode: &SessionRewriteMode, +) -> std::io::Result { let mut rewrite = RolloutRewrite::default(); - for segment in text.split_inclusive('\n') { - let (line, line_ending) = split_line_ending(segment); - let mut next_line = line.to_string(); - if !line.trim().is_empty() { - if let Ok(mut record) = serde_json::from_str::(line) { - if record.get("type").and_then(Value::as_str) == Some("session_meta") { - let Some(payload) = record.get_mut("payload").and_then(Value::as_object_mut) - else { - rewrite.next_text.push_str(&next_line); - rewrite.next_text.push_str(line_ending); - continue; - }; - rewrite.session_meta_count += 1; - rewrite.original_session_meta_lines.push(line.to_string()); - if rewrite.thread_id.is_none() { - rewrite.thread_id = payload - .get("id") - .and_then(Value::as_str) - .map(ToString::to_string); - } - if rewrite.cwd.is_none() { - rewrite.cwd = payload - .get("cwd") - .and_then(Value::as_str) - .and_then(to_desktop_workspace_path); - } - let provider = payload - .get("model_provider") - .and_then(Value::as_str) - .unwrap_or("(missing)") - .to_string(); - rewrite.providers.push(provider); - if payload.get("model_provider").and_then(Value::as_str) - != Some(target_provider) - { - payload.insert("model_provider".to_string(), json!(target_provider)); - next_line = serde_json::to_string(&record)?; - rewrite.rewrite_needed = true; - } - } - } + let file = File::open(path)?; + let mut reader = BufReader::new(file); + let mut line = Vec::new(); + let mut hasher = Sha256::new(); + loop { + line.clear(); + let read = reader.read_until(b'\n', &mut line)?; + if read == 0 { + break; + } + rewrite.original_size += read as u64; + hasher.update(&line); + rewrite.has_user_event |= + contains_bytes(&line, b"\"user_message\"") || contains_bytes(&line, b"\"user_input\""); + rewrite.has_encrypted_content |= contains_bytes(&line, b"encrypted_content"); + let (line_bytes, _) = split_line_ending_bytes(&line); + let line_text = std::str::from_utf8(line_bytes) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?; + if !contains_bytes(line_bytes, b"\"session_meta\"") { + continue; + } + let Ok(mut record) = serde_json::from_str::(line_text) else { + continue; + }; + if record.get("type").and_then(Value::as_str) != Some("session_meta") { + continue; } - rewrite.next_text.push_str(&next_line); - rewrite.next_text.push_str(line_ending); + rewrite.marks_non_root_agent |= record + .get("payload") + .and_then(|payload| payload.get("source")) + .is_some_and(source_value_marks_non_root_agent); + let Some(payload) = record.get_mut("payload").and_then(Value::as_object_mut) else { + continue; + }; + rewrite.session_meta_count += 1; + if rewrite.thread_id.is_none() { + rewrite.thread_id = payload + .get("id") + .and_then(Value::as_str) + .map(ToString::to_string); + } + if rewrite.cwd.is_none() { + rewrite.cwd = payload + .get("cwd") + .and_then(Value::as_str) + .and_then(to_desktop_workspace_path); + } + let provider = payload + .get("model_provider") + .and_then(Value::as_str) + .map(ToString::to_string); + rewrite + .providers + .insert(provider.clone().unwrap_or_else(|| "(missing)".to_string())); + rewrite.rewrite_needed |= match rewrite_mode { + SessionRewriteMode::AllProviders => provider.as_deref() != Some(target_provider), + SessionRewriteMode::SourceProvider { source_provider } => provider + .as_deref() + .is_none_or(|provider| provider == source_provider), + }; } + rewrite.original_sha256 = format!("{:x}", hasher.finalize()); Ok(rewrite) } -fn rewrite_rollout_session_meta_providers_for_threads( - text: &str, - target_provider: &str, - source_provider: &str, - thread_ids: &HashSet, -) -> anyhow::Result { - let rollout_thread_id = text.lines().find_map(|line| { - let record = serde_json::from_str::(line).ok()?; - if record.get("type").and_then(Value::as_str) != Some("session_meta") { - return None; - } - record - .get("payload")? - .get("id")? - .as_str() - .map(ToString::to_string) - }); - if rollout_thread_id - .as_ref() - .is_none_or(|thread_id| !thread_ids.contains(thread_id)) - { - return Ok(RolloutRewrite { - next_text: text.to_string(), - ..RolloutRewrite::default() - }); - } +fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { + !needle.is_empty() + && haystack + .windows(needle.len()) + .any(|window| window == needle) +} - let mut rewrite = RolloutRewrite { - thread_id: rollout_thread_id, - ..RolloutRewrite::default() - }; - for segment in text.split_inclusive('\n') { - let (line, line_ending) = split_line_ending(segment); - let mut next_line = line.to_string(); - if !line.trim().is_empty() { - if let Ok(mut record) = serde_json::from_str::(line) { - if record.get("type").and_then(Value::as_str) == Some("session_meta") { - let Some(payload) = record.get_mut("payload").and_then(Value::as_object_mut) - else { - rewrite.next_text.push_str(&next_line); - rewrite.next_text.push_str(line_ending); - continue; - }; - rewrite.session_meta_count += 1; - rewrite.original_session_meta_lines.push(line.to_string()); - if rewrite.cwd.is_none() { - rewrite.cwd = payload - .get("cwd") - .and_then(Value::as_str) - .and_then(to_desktop_workspace_path); - } - let provider = payload - .get("model_provider") - .and_then(Value::as_str) - .map(ToString::to_string); - rewrite - .providers - .push(provider.clone().unwrap_or_else(|| "(missing)".to_string())); - if provider - .as_deref() - .is_none_or(|provider| provider == source_provider) - { - payload.insert("model_provider".to_string(), json!(target_provider)); - next_line = serde_json::to_string(&record)?; - rewrite.rewrite_needed = true; - } - } - } - } - rewrite.next_text.push_str(&next_line); - rewrite.next_text.push_str(line_ending); +fn split_line_ending_bytes(line: &[u8]) -> (&[u8], &[u8]) { + if line.ends_with(b"\r\n") { + (&line[..line.len() - 2], &line[line.len() - 2..]) + } else if line.ends_with(b"\n") { + (&line[..line.len() - 1], &line[line.len() - 1..]) + } else { + (line, &[]) } - Ok(rewrite) } fn rollout_files(home: &Path) -> anyhow::Result> { let mut files = Vec::new(); + let canonical_home = fs::canonicalize(home)?; for dirname in SESSION_DIRS { let root = home.join(dirname); if root.exists() { + ensure_not_reparse_or_symlink(&root)?; + let canonical_root = fs::canonicalize(&root)?; + if !canonical_root.starts_with(&canonical_home) { + anyhow::bail!("provider-sync rollout root resolves outside Codex home"); + } collect_rollout_files(&root, &mut files)?; } } @@ -1867,28 +2013,17 @@ fn collect_live_thread_ids( { ids.insert(id); } - let text = match fs::read_to_string(&path) { - Ok(text) => text, + let rewrite = match scan_rollout_session_meta_providers( + &path, + DEFAULT_PROVIDER, + &SessionRewriteMode::AllProviders, + ) { + Ok(rewrite) => rewrite, Err(error) if is_locked_io_error(&error) => continue, Err(error) => return Err(error.into()), }; - for segment in text.split_inclusive('\n') { - let (line, _) = split_line_ending(segment); - let Ok(record) = serde_json::from_str::(line) else { - continue; - }; - if record.get("type").and_then(Value::as_str) != Some("session_meta") { - continue; - } - if let Some(id) = record - .get("payload") - .and_then(Value::as_object) - .and_then(|payload| payload.get("id")) - .and_then(Value::as_str) - .filter(|id| !id.trim().is_empty()) - { - ids.insert(id.to_string()); - } + if let Some(id) = rewrite.thread_id.filter(|id| !id.trim().is_empty()) { + ids.insert(id); } } for path in sqlite_paths { @@ -2204,10 +2339,7 @@ pub fn session_index_lines_for_thread( /// /// Best-effort: returns `Ok(0)` without writing when the file is missing or /// changed since it was read, so a delete flow never clobbers fresh entries. -pub fn remove_session_index_entry( - codex_home: &Path, - thread_id: &str, -) -> anyhow::Result { +pub fn remove_session_index_entry(codex_home: &Path, thread_id: &str) -> anyhow::Result { let path = codex_home.join("session_index.jsonl"); if !path.exists() { return Ok(0); @@ -2237,10 +2369,7 @@ pub fn remove_session_index_entry( /// Lines whose `id` already exists are skipped. Returns the number of /// appended lines. Best-effort: returns `Ok(0)` without writing when the /// file changed since it was read. -pub fn restore_session_index_entries( - codex_home: &Path, - lines: &[String], -) -> anyhow::Result { +pub fn restore_session_index_entries(codex_home: &Path, lines: &[String]) -> anyhow::Result { if lines.is_empty() { return Ok(0); } @@ -2318,29 +2447,18 @@ fn cleanup_apply_error( fn rollout_provider_ids(home: &Path) -> anyhow::Result> { let mut ids = HashSet::new(); for path in rollout_files(home)? { - let text = match fs::read_to_string(&path) { - Ok(text) => text, + let rewrite = match scan_rollout_session_meta_providers( + &path, + DEFAULT_PROVIDER, + &SessionRewriteMode::AllProviders, + ) { + Ok(rewrite) => rewrite, Err(error) if is_locked_io_error(&error) => continue, Err(error) => return Err(error.into()), }; - for segment in text.split_inclusive('\n') { - let (line, _) = split_line_ending(segment); - let Ok(record) = serde_json::from_str::(line) else { - continue; - }; - if record.get("type").and_then(Value::as_str) != Some("session_meta") { - continue; - } - let Some(provider) = record - .get("payload") - .and_then(Value::as_object) - .and_then(|payload| payload.get("model_provider")) - .and_then(Value::as_str) - else { - continue; - }; - if is_valid_provider_id_for_discovery(provider) { - ids.insert(provider.to_string()); + for provider in rewrite.providers { + if is_valid_provider_id_for_discovery(&provider) { + ids.insert(provider); } } } @@ -2349,13 +2467,17 @@ fn rollout_provider_ids(home: &Path) -> anyhow::Result> { fn collect_rollout_files(root: &Path, files: &mut Vec) -> anyhow::Result<()> { for entry in fs::read_dir(root)? { - let path = entry?.path(); - if path.is_dir() { + let entry = entry?; + let path = entry.path(); + ensure_not_reparse_or_symlink(&path)?; + let file_type = entry.file_type()?; + if file_type.is_dir() { collect_rollout_files(&path, files)?; - } else if path - .file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name.starts_with("rollout-") && name.ends_with(".jsonl")) + } else if file_type.is_file() + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("rollout-") && name.ends_with(".jsonl")) { files.push(path); } @@ -2417,6 +2539,7 @@ fn build_encrypted_content_warning( fn create_backup( home: &Path, target_provider: &str, + mode: SessionTransactionMode, changes: &[SessionChange], ) -> anyhow::Result { let backup_root = home.join("backups_state/provider-sync"); @@ -2427,6 +2550,8 @@ fn create_backup( backup_dir = backup_root.join(format!("{}-{suffix}", timestamp_name())); } fs::create_dir_all(&backup_dir)?; + let mut global_state_files = Vec::new(); + let mut backup_files = HashMap::new(); for name in [ "config.toml", ".codex-global-state.json", @@ -2434,7 +2559,16 @@ fn create_backup( ] { let source = home.join(name); if source.exists() { - fs::copy(&source, backup_dir.join(name))?; + let target = backup_dir.join(name); + fs::copy(&source, &target)?; + if name != "config.toml" { + global_state_files.push(name); + let (sha256, size) = file_sha256_and_size(&target)?; + backup_files.insert( + name.to_string(), + ProviderSyncBackupFileEvidence { size, sha256 }, + ); + } } } let db_dir = backup_dir.join("db"); @@ -2444,27 +2578,30 @@ fn create_backup( if !source.exists() { continue; } - let relative = codex_plus_core::codex_sqlite::relative_to_codex_home(home, &source); + let relative = source.strip_prefix(home).map_err(|_| { + anyhow::anyhow!( + "provider-sync database is outside Codex home: {}", + source.display() + ) + })?; + validated_backup_relative_path(relative)?; let target = db_dir.join(&relative); if let Some(parent) = target.parent() { fs::create_dir_all(parent)?; } fs::copy(&source, &target)?; - db_files.push(relative.to_string_lossy().replace('\\', "/")); + let relative = relative.to_string_lossy().replace('\\', "/"); + let (sha256, size) = file_sha256_and_size(&target)?; + backup_files.insert( + format!("db/{relative}"), + ProviderSyncBackupFileEvidence { size, sha256 }, + ); + db_files.push(relative); } } - let manifest = changes - .iter() - .map(|change| { - json!({ - "path": change.path.to_string_lossy(), - "originalSessionMetaLines": change.original_session_meta_lines, - }) - }) - .collect::>(); fs::write( backup_dir.join("session-meta-backup.json"), - serde_json::to_string_pretty(&manifest)?, + serde_json::to_string_pretty(&Vec::::new())?, )?; fs::write( backup_dir.join("metadata.json"), @@ -2475,10 +2612,25 @@ fn create_backup( "targetProvider": target_provider, "createdAt": chrono::Utc::now().to_rfc3339(), "dbFiles": db_files, + "globalStateFiles": global_state_files, + "backupFiles": backup_files, "changedSessionFiles": changes.len(), "managedBy": "Codex++ provider sync" }))?, )?; + write_session_transaction( + &backup_dir, + &SessionTransactionManifest { + version: 1, + namespace: SESSION_TRANSACTION_NAMESPACE.to_string(), + status: SESSION_TRANSACTION_IN_PROGRESS.to_string(), + transaction_id: uuid::Uuid::new_v4().simple().to_string(), + mode, + phase: SessionTransactionPhase::RolloutsApplying, + rollout_roots: session_transaction_rollout_roots(home)?, + entries: Vec::new(), + }, + )?; Ok(backup_dir) } @@ -2512,75 +2664,1564 @@ fn create_session_index_cleanup_backup( Ok(backup_dir) } -fn apply_session_changes(changes: &[SessionChange]) -> anyhow::Result { - let mut applied = AppliedSessionChanges::default(); - for change in changes { - match replace_session_text_if_unchanged( - &change.path, - &change.original_text, - &change.next_text, - ) { - Ok(true) => {} - Ok(false) => { +fn apply_session_changes( + home: &Path, + backup_dir: &Path, + target_provider: &str, + changes: &[SessionChange], +) -> anyhow::Result { + let apply_result = (|| -> anyhow::Result { + let mut applied = AppliedSessionChanges::default(); + let mut transaction = read_session_transaction(backup_dir)?; + for change in changes { + let entry_index = transaction.entries.len(); + let relative_path = rollout_relative_path(home, &change.path)?; + let target_path = validated_rollout_transaction_path( + home, + &relative_path, + &transaction.rollout_roots, + )?; + let staged_file_name = format!( + ".{}.provider-sync-{}-{}.tmp", + target_path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("rollout"), + transaction.transaction_id, + entry_index + ); + let staged_path = target_path + .parent() + .ok_or_else(|| anyhow::anyhow!("rollout has no parent: {}", target_path.display()))? + .join(&staged_file_name); + let session_meta_backup_path = + backup_dir.join(format!("session-meta/{entry_index}.jsonl")); + let mut source = match open_session_file_for_update(&target_path) { + Ok(source) => source, + Err(error) if is_locked_io_error(&error) => { + applied + .skipped_locked_rollout_files + .push(change.path.clone()); + continue; + } + Err(error) => return Err(error.into()), + }; + if source.try_lock().is_err() { applied .skipped_locked_rollout_files .push(change.path.clone()); continue; } - Err(error) if is_locked_io_error(&error) => { + let staged = match stage_next_session_file( + &mut source, + &staged_path, + &session_meta_backup_path, + target_provider, + &change.rewrite_mode, + ) { + Ok(staged) => staged, + Err(error) => { + let _ = fs::remove_file(&staged_path); + let _ = fs::remove_file(&session_meta_backup_path); + return Err(error.into()); + } + }; + if staged.original_sha256 != change.original_sha256 + || staged.original_size != change.original_size + { + drop(source); + let _ = fs::remove_file(&staged_path); + let _ = fs::remove_file(&session_meta_backup_path); applied .skipped_locked_rollout_files .push(change.path.clone()); continue; } - Err(error) => return Err(error.into()), + let (original_mtime_secs, original_mtime_nanos) = + system_time_parts(change.original_mtime); + transaction.entries.push(SessionTransactionEntry { + relative_path: relative_path.clone(), + original_sha256: staged.original_sha256, + next_sha256: staged.next_sha256.clone(), + original_size: staged.original_size, + next_size: staged.next_size, + session_meta_backup_sha256: staged.session_meta_backup_sha256, + original_mtime_secs, + original_mtime_nanos, + external_sha256: None, + external_size: None, + }); + if let Err(error) = write_session_transaction(backup_dir, &transaction) { + transaction.entries.pop(); + drop(source); + let _ = fs::remove_file(&staged_path); + let _ = fs::remove_file(&session_meta_backup_path); + let _ = rollback_session_transaction(home, backup_dir); + return Err(error); + } + if let Err(error) = write_session_meta_backup_manifest(backup_dir, &transaction) { + drop(source); + let _ = fs::remove_file(&staged_path); + let _ = rollback_session_transaction(home, backup_dir); + return Err(error); + } + drop(source); + let replacement_target = validated_rollout_transaction_path( + home, + &relative_path, + &transaction.rollout_roots, + )?; + if replacement_target != target_path { + anyhow::bail!("provider-sync rollout path changed before replacement"); + } + let displaced_path = + transaction_displaced_path(&target_path, &transaction.transaction_id, entry_index)?; + if let Err(error) = codex_plus_core::settings::atomic_replace_file_with_backup( + &staged_path, + &target_path, + &displaced_path, + ) { + let _ = fs::remove_file(&staged_path); + if is_locked_io_error_from_anyhow(&error) { + transaction.entries.pop(); + write_session_transaction(backup_dir, &transaction)?; + write_session_meta_backup_manifest(backup_dir, &transaction)?; + let _ = fs::remove_file(&session_meta_backup_path); + applied + .skipped_locked_rollout_files + .push(change.path.clone()); + continue; + } + let _ = rollback_session_transaction(home, backup_dir); + return Err(error); + } + let (displaced_sha256, displaced_size) = file_sha256_and_size(&displaced_path)?; + if displaced_sha256 != change.original_sha256 || displaced_size != change.original_size + { + let entry = transaction + .entries + .last_mut() + .ok_or_else(|| anyhow::anyhow!("provider-sync transaction entry missing"))?; + entry.external_sha256 = Some(displaced_sha256); + entry.external_size = Some(displaced_size); + write_session_transaction(backup_dir, &transaction)?; + restore_displaced_session_file( + &target_path, + &displaced_path, + &transaction.transaction_id, + entry_index, + )?; + anyhow::bail!( + "rollout changed between provider-sync staging and replacement: {}", + target_path.display() + ); + } + restore_file_mtime(&target_path, change.original_mtime); + let (persisted_sha256, persisted_size) = file_sha256_and_size(&target_path)?; + if persisted_sha256 != staged.next_sha256 || persisted_size != staged.next_size { + let _ = rollback_session_transaction(home, backup_dir); + anyhow::bail!( + "rollout changed while provider metadata was being replaced: {}", + target_path.display() + ); + } + fs::remove_file(displaced_path)?; + applied.changed_files += 1; } - restore_file_mtime(&change.path, change.original_mtime); - applied.changes.push(change.clone()); + Ok(applied) + })(); + if apply_result.is_err() { + let _ = rollback_session_transaction(home, backup_dir); } - Ok(applied) + apply_result } -fn restore_session_changes(changes: &[SessionChange]) -> anyhow::Result<()> { - for change in changes { - if replace_session_text_if_unchanged( - &change.path, - &change.next_text, - &change.original_text, - )? { - restore_file_mtime(&change.path, change.original_mtime); +#[derive(Debug)] +struct StagedSessionFile { + original_sha256: String, + next_sha256: String, + original_size: u64, + next_size: u64, + session_meta_backup_sha256: String, +} + +fn stage_next_session_file( + source: &mut File, + staged_path: &Path, + session_meta_backup_path: &Path, + target_provider: &str, + rewrite_mode: &SessionRewriteMode, +) -> std::io::Result { + let staged_file = OpenOptions::new() + .create_new(true) + .write(true) + .open(staged_path)?; + if let Some(parent) = session_meta_backup_path.parent() { + fs::create_dir_all(parent)?; + } + let session_meta_backup_file = OpenOptions::new() + .create_new(true) + .write(true) + .open(session_meta_backup_path)?; + let mut reader = BufReader::new(source); + let mut writer = BufWriter::new(staged_file); + let mut session_meta_writer = BufWriter::new(session_meta_backup_file); + let mut line = Vec::new(); + let mut original_hasher = Sha256::new(); + let mut next_hasher = Sha256::new(); + let mut session_meta_hasher = Sha256::new(); + let mut original_size = 0_u64; + let mut next_size = 0_u64; + loop { + line.clear(); + let read = reader.read_until(b'\n', &mut line)?; + if read == 0 { + break; + } + original_size += read as u64; + original_hasher.update(&line); + let (line_bytes, line_ending) = split_line_ending_bytes(&line); + let line_text = std::str::from_utf8(line_bytes) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?; + if session_meta_line_has_payload_object(line_text) { + let backup_line = serde_json::to_vec(line_text) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?; + session_meta_writer.write_all(&backup_line)?; + session_meta_writer.write_all(b"\n")?; + session_meta_hasher.update(&backup_line); + session_meta_hasher.update(b"\n"); + } + if let Some(next_line) = + rewritten_session_meta_line(line_text, target_provider, rewrite_mode)? + { + writer.write_all(&next_line)?; + writer.write_all(line_ending)?; + next_hasher.update(&next_line); + next_hasher.update(line_ending); + next_size += (next_line.len() + line_ending.len()) as u64; + } else { + writer.write_all(&line)?; + next_hasher.update(&line); + next_size += line.len() as u64; } } - Ok(()) + writer.flush()?; + writer.get_ref().sync_all()?; + session_meta_writer.flush()?; + session_meta_writer.get_ref().sync_all()?; + Ok(StagedSessionFile { + original_sha256: format!("{:x}", original_hasher.finalize()), + next_sha256: format!("{:x}", next_hasher.finalize()), + original_size, + next_size, + session_meta_backup_sha256: format!("{:x}", session_meta_hasher.finalize()), + }) } -fn replace_session_text_if_unchanged( - path: &Path, - expected_text: &str, - next_text: &str, -) -> std::io::Result { - let mut file = open_session_file_for_update(path)?; - file.try_lock()?; - let mut current_text = String::new(); - file.read_to_string(&mut current_text)?; - if current_text != expected_text { - return Ok(false); - } - - file.seek(SeekFrom::Start(0))?; - file.set_len(0)?; - file.write_all(next_text.as_bytes())?; - file.flush()?; +fn write_session_meta_backup_manifest( + backup_dir: &Path, + transaction: &SessionTransactionManifest, +) -> anyhow::Result<()> { + let manifest = transaction + .entries + .iter() + .enumerate() + .map(|(index, entry)| { + json!({ + "path": entry.relative_path, + "originalSha256": entry.original_sha256, + "originalSize": entry.original_size, + "sessionMetaBackup": format!("session-meta/{index}.jsonl"), + }) + }) + .collect::>(); + codex_plus_core::settings::atomic_write( + &backup_dir.join("session-meta-backup.json"), + &serde_json::to_vec_pretty(&manifest)?, + ) +} + +fn session_meta_line_has_payload_object(line: &str) -> bool { + line.contains("\"session_meta\"") + && serde_json::from_str::(line).is_ok_and(|record| { + record.get("type").and_then(Value::as_str) == Some("session_meta") + && record.get("payload").and_then(Value::as_object).is_some() + }) +} + +fn rewritten_session_meta_line( + line: &str, + target_provider: &str, + rewrite_mode: &SessionRewriteMode, +) -> std::io::Result>> { + if !line.contains("\"session_meta\"") { + return Ok(None); + } + let Ok(mut record) = serde_json::from_str::(line) else { + return Ok(None); + }; + if record.get("type").and_then(Value::as_str) != Some("session_meta") { + return Ok(None); + } + let Some(payload) = record.get_mut("payload").and_then(Value::as_object_mut) else { + return Ok(None); + }; + let provider = payload.get("model_provider").and_then(Value::as_str); + let rewrite = match rewrite_mode { + SessionRewriteMode::AllProviders => provider != Some(target_provider), + SessionRewriteMode::SourceProvider { source_provider } => { + provider.is_none_or(|provider| provider == source_provider) + } + }; + if !rewrite { + return Ok(None); + } + payload.insert("model_provider".to_string(), json!(target_provider)); + serde_json::to_vec(&record) + .map(Some) + .map_err(std::io::Error::other) +} - file.seek(SeekFrom::Start(0))?; - let mut persisted_text = String::new(); - file.read_to_string(&mut persisted_text)?; - if persisted_text != next_text { - return Err(std::io::Error::other( - "rollout changed while provider metadata was being written", +fn transaction_displaced_path( + path: &Path, + transaction_id: &str, + entry_index: usize, +) -> anyhow::Result { + Ok(path + .parent() + .ok_or_else(|| anyhow::anyhow!("rollout has no parent: {}", path.display()))? + .join(format!( + ".{}.provider-sync-displaced-{}-{}.tmp", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("rollout"), + transaction_id, + entry_index + ))) +} + +fn restore_displaced_session_file( + path: &Path, + displaced_path: &Path, + transaction_id: &str, + entry_index: usize, +) -> anyhow::Result<()> { + if !path.exists() { + return codex_plus_core::settings::atomic_replace_file(displaced_path, path); + } + let rejected_path = path + .parent() + .ok_or_else(|| anyhow::anyhow!("rollout has no parent: {}", path.display()))? + .join(format!( + ".{}.provider-sync-rejected-{}-{}.tmp", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("rollout"), + transaction_id, + entry_index )); + codex_plus_core::settings::atomic_replace_file_with_backup( + displaced_path, + path, + &rejected_path, + )?; + fs::remove_file(rejected_path)?; + Ok(()) +} + +fn rollback_session_transaction(home: &Path, backup_dir: &Path) -> anyhow::Result<()> { + let mut transaction = read_session_transaction(backup_dir)?; + if transaction.status == SESSION_TRANSACTION_COMMITTED + || transaction.status == SESSION_TRANSACTION_ROLLED_BACK + { + return Ok(()); + } + if transaction.status != SESSION_TRANSACTION_IN_PROGRESS { + anyhow::bail!("unknown provider-sync rollout transaction status"); + } + for entry_index in (0..transaction.entries.len()).rev() { + let entry = transaction.entries[entry_index].clone(); + let path = validated_rollout_transaction_path( + home, + &entry.relative_path, + &transaction.rollout_roots, + )?; + let displaced_path = + transaction_displaced_path(&path, &transaction.transaction_id, entry_index)?; + if let (Some(external_sha256), Some(external_size)) = + (entry.external_sha256.as_deref(), entry.external_size) + { + if path.exists() { + let (current_sha256, current_size) = file_sha256_and_size(&path)?; + if current_sha256 == external_sha256 && current_size == external_size { + if displaced_path.exists() { + let (displaced_sha256, displaced_size) = + file_sha256_and_size(&displaced_path)?; + let displaced_is_original = displaced_sha256 == entry.original_sha256 + && displaced_size == entry.original_size; + let displaced_is_external = + displaced_sha256 == external_sha256 && displaced_size == external_size; + if !displaced_is_original && !displaced_is_external { + anyhow::bail!( + "provider-sync displaced file changed after external restore: {}", + displaced_path.display() + ); + } + fs::remove_file(&displaced_path)?; + } + continue; + } + if current_sha256 != entry.next_sha256 || current_size != entry.next_size { + anyhow::bail!( + "rollout changed after external restore decision: {}", + path.display() + ); + } + } + if displaced_path.exists() { + let (displaced_sha256, displaced_size) = file_sha256_and_size(&displaced_path)?; + if displaced_sha256 != external_sha256 || displaced_size != external_size { + anyhow::bail!( + "provider-sync displaced external version changed: {}", + displaced_path.display() + ); + } + restore_displaced_session_file( + &path, + &displaced_path, + &transaction.transaction_id, + entry_index, + )?; + continue; + } + anyhow::bail!( + "provider-sync external version is missing after restore decision: {}", + path.display() + ); + } + if displaced_path.exists() { + let (displaced_sha256, displaced_size) = file_sha256_and_size(&displaced_path)?; + if !path.exists() { + restore_displaced_session_file( + &path, + &displaced_path, + &transaction.transaction_id, + entry_index, + )?; + if displaced_sha256 == entry.original_sha256 + && displaced_size == entry.original_size + { + restore_file_mtime_parts( + &path, + entry.original_mtime_secs, + entry.original_mtime_nanos, + ); + } + continue; + } + let (current_sha256, current_size) = file_sha256_and_size(&path)?; + if displaced_sha256 == entry.original_sha256 && displaced_size == entry.original_size { + if current_sha256 == entry.original_sha256 && current_size == entry.original_size { + fs::remove_file(&displaced_path)?; + continue; + } + if current_sha256 != entry.next_sha256 || current_size != entry.next_size { + transaction.entries[entry_index].external_sha256 = Some(current_sha256); + transaction.entries[entry_index].external_size = Some(current_size); + write_session_transaction(backup_dir, &transaction)?; + fs::remove_file(&displaced_path)?; + continue; + } + restore_displaced_session_file( + &path, + &displaced_path, + &transaction.transaction_id, + entry_index, + )?; + restore_file_mtime_parts( + &path, + entry.original_mtime_secs, + entry.original_mtime_nanos, + ); + continue; + } + if current_sha256 != entry.next_sha256 || current_size != entry.next_size { + anyhow::bail!( + "rollout and displaced file both changed; refusing recovery: {}", + path.display() + ); + } + transaction.entries[entry_index].external_sha256 = Some(displaced_sha256); + transaction.entries[entry_index].external_size = Some(displaced_size); + write_session_transaction(backup_dir, &transaction)?; + restore_displaced_session_file( + &path, + &displaced_path, + &transaction.transaction_id, + entry_index, + )?; + continue; + } + let (current_sha256, current_size) = file_sha256_and_size(&path)?; + if current_sha256 == entry.original_sha256 && current_size == entry.original_size { + continue; + } + if current_sha256 != entry.next_sha256 || current_size != entry.next_size { + anyhow::bail!( + "rollout changed after provider-sync replacement; refusing rollback: {}", + path.display() + ); + } + let restore_name = format!( + ".{}.provider-sync-restore-{}.tmp", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("rollout"), + transaction.transaction_id + ); + let restore_path = path + .parent() + .ok_or_else(|| anyhow::anyhow!("rollout has no parent: {}", path.display()))? + .join(restore_name); + let restored = match stage_original_session_file( + &path, + &restore_path, + backup_dir, + entry_index, + &entry, + ) { + Ok(restored) => restored, + Err(error) => { + let _ = fs::remove_file(&restore_path); + return Err(error); + } + }; + if restored.0 != entry.original_sha256 || restored.1 != entry.original_size { + let _ = fs::remove_file(&restore_path); + anyhow::bail!("provider-sync rollback hash mismatch: {}", path.display()); + } + codex_plus_core::settings::atomic_replace_file(&restore_path, &path)?; + restore_file_mtime_parts(&path, entry.original_mtime_secs, entry.original_mtime_nanos); + } + cleanup_transaction_staged_files(home, &transaction.transaction_id)?; + transaction.status = SESSION_TRANSACTION_ROLLED_BACK.to_string(); + write_session_transaction(backup_dir, &transaction) +} + +fn stage_original_session_file( + path: &Path, + staged_path: &Path, + backup_dir: &Path, + entry_index: usize, + entry: &SessionTransactionEntry, +) -> anyhow::Result<(String, u64)> { + let mut source = open_session_file_for_update(path)?; + source.try_lock()?; + let staged_file = OpenOptions::new() + .create_new(true) + .write(true) + .open(staged_path)?; + let mut reader = BufReader::new(&mut source); + let mut writer = BufWriter::new(staged_file); + let canonical_backup_dir = fs::canonicalize(backup_dir)?; + let session_meta_backup_path = backup_dir.join(format!("session-meta/{entry_index}.jsonl")); + let canonical_session_meta_backup_path = fs::canonicalize(&session_meta_backup_path)?; + if !canonical_session_meta_backup_path.starts_with(&canonical_backup_dir) { + anyhow::bail!("provider-sync session-meta backup resolves outside its backup directory"); + } + if !fs::symlink_metadata(&session_meta_backup_path)? + .file_type() + .is_file() + { + anyhow::bail!("provider-sync session-meta backup is not a regular file"); + } + let mut session_meta_reader = BufReader::new(File::open(&session_meta_backup_path)?); + let mut line = Vec::new(); + let mut session_meta_line = Vec::new(); + let mut hasher = Sha256::new(); + let mut session_meta_hasher = Sha256::new(); + let mut size = 0_u64; + loop { + line.clear(); + let read = reader.read_until(b'\n', &mut line)?; + if read == 0 { + break; + } + let (line_bytes, line_ending) = split_line_ending_bytes(&line); + let line_text = std::str::from_utf8(line_bytes)?; + let is_session_meta = if line_text.contains("\"session_meta\"") { + serde_json::from_str::(line_text).is_ok_and(|record| { + record.get("type").and_then(Value::as_str) == Some("session_meta") + && record.get("payload").and_then(Value::as_object).is_some() + }) + } else { + false + }; + if is_session_meta { + session_meta_line.clear(); + if session_meta_reader.read_until(b'\n', &mut session_meta_line)? == 0 { + anyhow::bail!("provider-sync rollback metadata is incomplete"); + } + session_meta_hasher.update(&session_meta_line); + let backup_line = session_meta_line + .strip_suffix(b"\n") + .unwrap_or(&session_meta_line); + let original_line: String = serde_json::from_slice(backup_line)?; + writer.write_all(original_line.as_bytes())?; + writer.write_all(line_ending)?; + hasher.update(original_line.as_bytes()); + hasher.update(line_ending); + size += (original_line.len() + line_ending.len()) as u64; + } else { + writer.write_all(&line)?; + hasher.update(&line); + size += line.len() as u64; + } + } + session_meta_line.clear(); + if session_meta_reader.read_until(b'\n', &mut session_meta_line)? != 0 { + anyhow::bail!("provider-sync rollback metadata count mismatch"); + } + let session_meta_backup_sha256 = format!("{:x}", session_meta_hasher.finalize()); + if session_meta_backup_sha256 != entry.session_meta_backup_sha256 { + anyhow::bail!("provider-sync session-meta backup hash mismatch"); + } + writer.flush()?; + writer.get_ref().sync_all()?; + Ok((format!("{:x}", hasher.finalize()), size)) +} + +fn commit_session_transaction(backup_dir: &Path) -> anyhow::Result<()> { + let mut transaction = read_session_transaction(backup_dir)?; + if transaction.phase != SessionTransactionPhase::CommitDecided { + anyhow::bail!("provider-sync rollout transaction has no commit decision"); + } + transaction.status = SESSION_TRANSACTION_COMMITTED.to_string(); + write_session_transaction(backup_dir, &transaction) +} + +fn set_session_transaction_phase( + backup_dir: &Path, + phase: SessionTransactionPhase, +) -> anyhow::Result<()> { + let mut transaction = read_session_transaction(backup_dir)?; + if transaction.status != SESSION_TRANSACTION_IN_PROGRESS { + anyhow::bail!("provider-sync rollout transaction is no longer active"); + } + let valid = matches!( + (transaction.phase, phase), + ( + SessionTransactionPhase::RolloutsApplying, + SessionTransactionPhase::RolloutsApplied + ) | ( + SessionTransactionPhase::RolloutsApplied, + SessionTransactionPhase::DownstreamStarted + ) | ( + SessionTransactionPhase::RolloutsApplied, + SessionTransactionPhase::CommitDecided + ) | ( + SessionTransactionPhase::DownstreamStarted, + SessionTransactionPhase::CommitDecided + ) + ); + if !valid { + anyhow::bail!("invalid provider-sync rollout transaction phase transition"); + } + transaction.phase = phase; + write_session_transaction(backup_dir, &transaction) +} + +fn recover_interrupted_session_transactions(home: &Path) -> anyhow::Result<()> { + let backup_root = home.join("backups_state/provider-sync"); + let entries = match fs::read_dir(&backup_root) { + Ok(entries) => entries, + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory + ) => + { + return Ok(()); + } + Err(error) => return Err(error.into()), + }; + let mut backup_dirs = Vec::new(); + for entry in entries { + let entry = entry?; + if entry.file_type()?.is_dir() && entry.path().join(SESSION_TRANSACTION_FILE).is_file() { + backup_dirs.push(entry.path()); + } + } + backup_dirs.sort(); + for backup_dir in backup_dirs { + let transaction = read_session_transaction(&backup_dir)?; + if transaction.status != SESSION_TRANSACTION_IN_PROGRESS { + continue; + } + match (transaction.mode, transaction.phase) { + (_, SessionTransactionPhase::CommitDecided) => { + commit_session_transaction(&backup_dir)?; + } + (SessionTransactionMode::Full, SessionTransactionPhase::DownstreamStarted) => { + restore_provider_sync_downstream_backup(home, &backup_dir)?; + rollback_session_transaction(home, &backup_dir)?; + } + _ => rollback_session_transaction(home, &backup_dir)?, + } + } + Ok(()) +} + +fn restore_provider_sync_downstream_backup(home: &Path, backup_dir: &Path) -> anyhow::Result<()> { + let transaction = read_session_transaction(backup_dir)?; + if transaction.mode != SessionTransactionMode::Full { + anyhow::bail!("remote provider-sync transactions do not own downstream state"); + } + let metadata: Value = serde_json::from_slice(&fs::read(backup_dir.join("metadata.json"))?)?; + let db_files = metadata + .get("dbFiles") + .and_then(Value::as_array) + .ok_or_else(|| anyhow::anyhow!("provider-sync backup is missing dbFiles"))? + .iter() + .map(|value| { + value + .as_str() + .ok_or_else(|| anyhow::anyhow!("provider-sync backup has an invalid dbFiles entry")) + .and_then(|value| validated_backup_relative_path(Path::new(value))) + }) + .collect::>>()?; + let allowed_db_files = provider_sync_db_paths(home) + .into_iter() + .flat_map(|db_path| codex_plus_core::codex_sqlite::codex_sqlite_sidecar_paths(&db_path)) + .map(|path| { + let relative = path.strip_prefix(home).map_err(|_| { + anyhow::anyhow!( + "provider-sync database is outside Codex home: {}", + path.display() + ) + })?; + Ok(validated_backup_relative_path(relative)? + .to_string_lossy() + .replace('\\', "/")) + }) + .collect::>>()?; + let db_file_set = db_files + .iter() + .map(|path| path.to_string_lossy().replace('\\', "/")) + .collect::>(); + if !db_file_set.is_subset(&allowed_db_files) { + anyhow::bail!("provider-sync backup dbFiles contains an unexpected path"); + } + let actual_backup_db_files = collect_backup_relative_files(&backup_dir.join("db"))?; + if db_file_set != actual_backup_db_files { + anyhow::bail!("provider-sync backup dbFiles does not match the backup directory"); + } + + let global_state_files = metadata + .get("globalStateFiles") + .and_then(Value::as_array) + .ok_or_else(|| anyhow::anyhow!("provider-sync backup is missing globalStateFiles"))? + .iter() + .map(|value| { + value.as_str().ok_or_else(|| { + anyhow::anyhow!("provider-sync backup has an invalid globalStateFiles entry") + }) + }) + .collect::>>()?; + let allowed_global_state_files = + HashSet::from([".codex-global-state.json", ".codex-global-state.json.bak"]); + if !global_state_files.is_subset(&allowed_global_state_files) { + anyhow::bail!("provider-sync backup globalStateFiles contains an unexpected path"); + } + let actual_global_state_files = allowed_global_state_files + .iter() + .copied() + .filter(|name| { + fs::symlink_metadata(backup_dir.join(name)) + .is_ok_and(|metadata| metadata.file_type().is_file()) + }) + .collect::>(); + if global_state_files != actual_global_state_files { + anyhow::bail!("provider-sync backup globalStateFiles does not match the backup directory"); + } + + let backup_files: HashMap = serde_json::from_value( + metadata + .get("backupFiles") + .cloned() + .ok_or_else(|| anyhow::anyhow!("provider-sync backup is missing backupFiles"))?, + )?; + let expected_backup_files = db_file_set + .iter() + .map(|relative| format!("db/{relative}")) + .chain(global_state_files.iter().map(|name| (*name).to_string())) + .collect::>(); + let recorded_backup_files = backup_files.keys().cloned().collect::>(); + if recorded_backup_files != expected_backup_files { + anyhow::bail!("provider-sync backupFiles does not match the recorded backup files"); + } + let mut prepared_backup_files = + prepare_provider_sync_backup_files(backup_dir, &backup_files, &transaction.transaction_id)?; + let target_parents = prepare_provider_sync_target_parents(home, &db_files)?; + + for db_path in provider_sync_db_paths(home) { + for (index, current) in codex_plus_core::codex_sqlite::codex_sqlite_sidecar_paths(&db_path) + .into_iter() + .enumerate() + { + let Ok(relative) = current.strip_prefix(home) else { + anyhow::bail!( + "provider-sync database is outside Codex home: {}", + current.display() + ); + }; + let relative = validated_backup_relative_path(relative)?; + let key = relative.to_string_lossy().replace('\\', "/"); + if index > 0 && current.exists() && !db_file_set.contains(&key) { + let parent_evidence = + validate_provider_sync_target_parent(home, ¤t, &target_parents)?; + ensure_not_reparse_or_symlink(¤t)?; + remove_provider_sync_target_file(¤t, parent_evidence)?; + } + } + } + for relative in db_files { + let key = format!("db/{}", relative.to_string_lossy().replace('\\', "/")); + let prepared = prepared_backup_files + .files + .get_mut(&key) + .ok_or_else(|| anyhow::anyhow!("provider-sync backup file evidence is missing"))?; + let target = home.join(&relative); + restore_file_from_provider_sync_backup( + home, + &target, + &transaction.transaction_id, + prepared, + &target_parents, + )?; + } + for name in [".codex-global-state.json", ".codex-global-state.json.bak"] { + let target = home.join(name); + if global_state_files.contains(name) { + let prepared = prepared_backup_files + .files + .get_mut(name) + .ok_or_else(|| anyhow::anyhow!("provider-sync backup file evidence is missing"))?; + restore_file_from_provider_sync_backup( + home, + &target, + &transaction.transaction_id, + prepared, + &target_parents, + )?; + } else if target.exists() { + let parent_evidence = + validate_provider_sync_target_parent(home, &target, &target_parents)?; + ensure_not_reparse_or_symlink(&target)?; + remove_provider_sync_target_file(&target, parent_evidence)?; + } + } + Ok(()) +} + +fn prepare_provider_sync_backup_files( + backup_dir: &Path, + backup_files: &HashMap, + transaction_id: &str, +) -> anyhow::Result { + let canonical_backup_dir = fs::canonicalize(backup_dir)?; + let snapshot_dir = std::env::temp_dir().join(format!( + "codex-plus-provider-sync-{transaction_id}-{}", + uuid::Uuid::new_v4().simple() + )); + fs::create_dir(&snapshot_dir)?; + ensure_not_reparse_or_symlink(&snapshot_dir)?; + let mut prepared = PreparedProviderSyncBackupSet { + files: HashMap::new(), + snapshot_dir, + }; + for (index, (relative, evidence)) in backup_files.iter().enumerate() { + if evidence.sha256.len() != 64 + || !evidence.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + anyhow::bail!("provider-sync backupFiles contains an invalid hash"); + } + let relative_path = validated_backup_relative_path(Path::new(relative))?; + let source = backup_dir.join(relative_path); + ensure_not_reparse_or_symlink(&source)?; + if !fs::symlink_metadata(&source)?.file_type().is_file() { + anyhow::bail!("provider-sync backupFiles contains a non-file entry"); + } + let canonical_source = fs::canonicalize(&source)?; + if !canonical_source.starts_with(&canonical_backup_dir) { + anyhow::bail!("provider-sync backup file resolves outside its backup directory"); + } + let mut source_file = File::open(&source)?; + let modified = source_file.metadata()?.modified().ok(); + let snapshot_path = prepared.snapshot_dir.join(format!("{index}.snapshot")); + let mut snapshot = OpenOptions::new() + .create_new(true) + .read(true) + .write(true) + .open(&snapshot_path)?; + let mut hasher = Sha256::new(); + let mut size = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = source_file.read(&mut buffer)?; + if read == 0 { + break; + } + snapshot.write_all(&buffer[..read])?; + hasher.update(&buffer[..read]); + size += read as u64; + } + snapshot.sync_all()?; + let sha256 = format!("{:x}", hasher.finalize()); + if sha256 != evidence.sha256 || size != evidence.size { + anyhow::bail!("provider-sync backup file hash or size mismatch"); + } + snapshot.seek(SeekFrom::Start(0))?; + snapshot.lock_exclusive()?; + #[cfg(unix)] + fs::remove_file(&snapshot_path)?; + prepared.files.insert( + relative.clone(), + PreparedProviderSyncBackupFile { + file: snapshot, + modified, + evidence: evidence.clone(), + }, + ); + } + Ok(prepared) +} + +fn file_handle_sha256_and_size(file: &mut File) -> std::io::Result<(String, u64)> { + let mut hasher = Sha256::new(); + let mut size = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + size += read as u64; + } + Ok((format!("{:x}", hasher.finalize()), size)) +} + +fn prepare_provider_sync_target_parents( + home: &Path, + db_files: &[PathBuf], +) -> anyhow::Result> { + let mut parents = HashSet::from([home.to_path_buf()]); + for relative in db_files { + let target = home.join(relative); + let parent = target + .parent() + .ok_or_else(|| anyhow::anyhow!("backup restore target has no parent"))?; + parents.insert(parent.to_path_buf()); + } + let canonical_home = fs::canonicalize(home)?; + let mut evidence = HashMap::new(); + for parent in parents { + create_validated_directory_path(home, &parent)?; + let canonical_path = fs::canonicalize(&parent)?; + if !canonical_path.starts_with(&canonical_home) { + anyhow::bail!("provider-sync backup target resolves outside Codex home"); + } + let guard = open_directory_mutation_guard(&parent)?; + let guarded_identity = codex_plus_core::settings::file_instance_identity(&guard)?; + if fs::canonicalize(&parent)? != canonical_path + || codex_plus_core::settings::directory_instance_identity(&parent)? != guarded_identity + { + anyhow::bail!("provider-sync backup target parent changed while being guarded"); + } + evidence.insert( + parent.clone(), + ProviderSyncDirectoryEvidence { + canonical_path, + identity: guarded_identity, + guard, + }, + ); + } + Ok(evidence) +} + +fn create_validated_directory_path(home: &Path, target: &Path) -> anyhow::Result<()> { + let relative = target + .strip_prefix(home) + .map_err(|_| anyhow::anyhow!("provider-sync target parent is outside Codex home"))?; + let mut current = home.to_path_buf(); + for component in relative.components() { + let std::path::Component::Normal(name) = component else { + anyhow::bail!("provider-sync target parent contains an invalid component"); + }; + current.push(name); + if current.exists() { + ensure_not_reparse_or_symlink(¤t)?; + } else { + fs::create_dir(¤t)?; + ensure_not_reparse_or_symlink(¤t)?; + } + } + Ok(()) +} + +fn validate_provider_sync_target_parent<'a>( + home: &Path, + target: &Path, + target_parents: &'a HashMap, +) -> anyhow::Result<&'a ProviderSyncDirectoryEvidence> { + let parent = target + .parent() + .ok_or_else(|| anyhow::anyhow!("backup restore target has no parent"))?; + let expected = target_parents + .get(parent) + .ok_or_else(|| anyhow::anyhow!("provider-sync backup target parent was not recorded"))?; + ensure_path_components_not_reparse(home, parent)?; + let canonical_parent = fs::canonicalize(parent)?; + if canonical_parent != expected.canonical_path + || codex_plus_core::settings::directory_instance_identity(parent)? != expected.identity + { + anyhow::bail!("provider-sync backup target parent changed during restore"); + } + Ok(expected) +} + +#[cfg(windows)] +fn open_directory_mutation_guard(path: &Path) -> std::io::Result { + use std::os::windows::fs::OpenOptionsExt; + + const FILE_SHARE_READ: u32 = 0x0000_0001; + const FILE_SHARE_WRITE: u32 = 0x0000_0002; + const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; + OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS) + .open(path) +} + +#[cfg(not(windows))] +fn open_directory_mutation_guard(path: &Path) -> std::io::Result { + File::open(path) +} + +fn restore_file_from_provider_sync_backup( + home: &Path, + target: &Path, + transaction_id: &str, + prepared: &mut PreparedProviderSyncBackupFile, + target_parents: &HashMap, +) -> anyhow::Result<()> { + let parent_evidence = validate_provider_sync_target_parent(home, target, target_parents)?; + if target.exists() { + ensure_not_reparse_or_symlink(target)?; + } + let parent = target + .parent() + .ok_or_else(|| anyhow::anyhow!("backup restore target has no parent"))?; + let temp = parent.join(format!( + ".{}.provider-sync-downstream-{}.tmp", + target + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("state"), + transaction_id + )); + let restore_result = (|| -> anyhow::Result<()> { + if let Err(error) = remove_provider_sync_target_file(&temp, parent_evidence) + && error.kind() != std::io::ErrorKind::NotFound + { + return Err(error.into()); + } + let mut temp_file = create_provider_sync_target_file(&temp, parent_evidence)?; + prepared.file.seek(SeekFrom::Start(0))?; + std::io::copy(&mut prepared.file, &mut temp_file)?; + temp_file.sync_all()?; + temp_file.seek(SeekFrom::Start(0))?; + let (sha256, size) = file_handle_sha256_and_size(&mut temp_file)?; + if sha256 != prepared.evidence.sha256 || size != prepared.evidence.size { + anyhow::bail!("provider-sync backup changed during restore"); + } + drop(temp_file); + let parent_evidence = validate_provider_sync_target_parent(home, target, target_parents)?; + if target.exists() { + ensure_not_reparse_or_symlink(target)?; + } + atomic_replace_provider_sync_target(&temp, target, parent_evidence)?; + restore_file_mtime(target, prepared.modified); + Ok(()) + })(); + if restore_result.is_err() { + let _ = remove_provider_sync_target_file(&temp, parent_evidence); + } + restore_result +} + +#[cfg(unix)] +fn create_provider_sync_target_file( + path: &Path, + parent: &ProviderSyncDirectoryEvidence, +) -> std::io::Result { + use std::ffi::CString; + use std::os::fd::{AsRawFd, FromRawFd}; + use std::os::unix::ffi::OsStrExt; + + let name = path.file_name().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "missing file name") + })?; + let name = CString::new(name.as_bytes()) + .map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid file name"))?; + let fd = unsafe { + libc::openat( + parent.guard.as_raw_fd(), + name.as_ptr(), + libc::O_CREAT | libc::O_EXCL | libc::O_RDWR | libc::O_CLOEXEC, + 0o600, + ) + }; + if fd < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(unsafe { File::from_raw_fd(fd) }) +} + +#[cfg(not(unix))] +fn create_provider_sync_target_file( + path: &Path, + parent: &ProviderSyncDirectoryEvidence, +) -> std::io::Result { + let _directory_guard = &parent.guard; + OpenOptions::new() + .create_new(true) + .read(true) + .write(true) + .open(path) +} + +#[cfg(unix)] +fn remove_provider_sync_target_file( + path: &Path, + parent: &ProviderSyncDirectoryEvidence, +) -> std::io::Result<()> { + use std::ffi::CString; + use std::os::fd::AsRawFd; + use std::os::unix::ffi::OsStrExt; + + let name = path.file_name().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "missing file name") + })?; + let name = CString::new(name.as_bytes()) + .map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid file name"))?; + if unsafe { libc::unlinkat(parent.guard.as_raw_fd(), name.as_ptr(), 0) } != 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) +} + +#[cfg(not(unix))] +fn remove_provider_sync_target_file( + path: &Path, + parent: &ProviderSyncDirectoryEvidence, +) -> std::io::Result<()> { + let _directory_guard = &parent.guard; + fs::remove_file(path) +} + +#[cfg(unix)] +fn atomic_replace_provider_sync_target( + replacement: &Path, + target: &Path, + parent: &ProviderSyncDirectoryEvidence, +) -> std::io::Result<()> { + use std::ffi::CString; + use std::os::fd::AsRawFd; + use std::os::unix::ffi::OsStrExt; + + let replacement = replacement.file_name().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "missing replacement name") + })?; + let target = target.file_name().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "missing target name") + })?; + let replacement = CString::new(replacement.as_bytes()).map_err(|_| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid replacement name") + })?; + let target = CString::new(target.as_bytes()).map_err(|_| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid target name") + })?; + if unsafe { + libc::renameat( + parent.guard.as_raw_fd(), + replacement.as_ptr(), + parent.guard.as_raw_fd(), + target.as_ptr(), + ) + } != 0 + { + return Err(std::io::Error::last_os_error()); + } + Ok(()) +} + +#[cfg(not(unix))] +fn atomic_replace_provider_sync_target( + replacement: &Path, + target: &Path, + parent: &ProviderSyncDirectoryEvidence, +) -> anyhow::Result<()> { + let _directory_guard = &parent.guard; + codex_plus_core::settings::atomic_replace_file(replacement, target) +} + +fn validated_backup_relative_path(path: &Path) -> anyhow::Result { + if path.is_absolute() + || path + .components() + .any(|component| !matches!(component, std::path::Component::Normal(_))) + { + anyhow::bail!("invalid provider-sync backup path"); + } + Ok(path.to_path_buf()) +} + +fn collect_backup_relative_files(root: &Path) -> anyhow::Result> { + if !root.exists() { + return Ok(HashSet::new()); + } + let canonical_root = fs::canonicalize(root)?; + let mut files = HashSet::new(); + collect_backup_relative_files_in(root, &canonical_root, &mut files)?; + Ok(files) +} + +fn collect_backup_relative_files_in( + root: &Path, + canonical_root: &Path, + files: &mut HashSet, +) -> anyhow::Result<()> { + for entry in fs::read_dir(root)? { + let entry = entry?; + let path = entry.path(); + let file_type = entry.file_type()?; + if file_type.is_dir() { + collect_backup_relative_files_in(&path, canonical_root, files)?; + continue; + } + if !file_type.is_file() { + anyhow::bail!("provider-sync backup db directory contains a non-file entry"); + } + let canonical_path = fs::canonicalize(&path)?; + let relative = canonical_path.strip_prefix(canonical_root).map_err(|_| { + anyhow::anyhow!("provider-sync backup db file resolves outside its directory") + })?; + files.insert( + validated_backup_relative_path(relative)? + .to_string_lossy() + .replace('\\', "/"), + ); + } + Ok(()) +} + +fn read_session_transaction(backup_dir: &Path) -> anyhow::Result { + let transaction: SessionTransactionManifest = + serde_json::from_slice(&fs::read(backup_dir.join(SESSION_TRANSACTION_FILE))?)?; + if transaction.version != 1 || transaction.namespace != SESSION_TRANSACTION_NAMESPACE { + anyhow::bail!("unsupported provider-sync rollout transaction manifest"); + } + if transaction.transaction_id.len() != 32 + || !transaction + .transaction_id + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + { + anyhow::bail!("invalid provider-sync rollout transaction id"); + } + for (name, root) in &transaction.rollout_roots { + if !SESSION_DIRS.contains(&name.as_str()) + || !Path::new(&root.canonical_path).is_absolute() + || root.identity.trim().is_empty() + { + anyhow::bail!("invalid provider-sync rollout root evidence"); + } + } + for entry in &transaction.entries { + for hash in [ + Some(entry.original_sha256.as_str()), + Some(entry.next_sha256.as_str()), + Some(entry.session_meta_backup_sha256.as_str()), + entry.external_sha256.as_deref(), + ] + .into_iter() + .flatten() + { + if hash.len() != 64 || !hash.bytes().all(|byte| byte.is_ascii_hexdigit()) { + anyhow::bail!("invalid provider-sync rollout transaction hash"); + } + } + if entry.external_sha256.is_some() != entry.external_size.is_some() { + anyhow::bail!("incomplete provider-sync external restore decision"); + } + } + Ok(transaction) +} + +fn write_session_transaction( + backup_dir: &Path, + transaction: &SessionTransactionManifest, +) -> anyhow::Result<()> { + codex_plus_core::settings::atomic_write( + &backup_dir.join(SESSION_TRANSACTION_FILE), + &serde_json::to_vec_pretty(transaction)?, + ) +} + +fn rollout_relative_path(home: &Path, path: &Path) -> anyhow::Result { + let canonical_home = fs::canonicalize(home)?; + let relative = path + .strip_prefix(home) + .or_else(|_| path.strip_prefix(&canonical_home)) + .map_err(|_| anyhow::anyhow!("rollout is outside Codex home: {}", path.display()))?; + validated_rollout_relative_path(&relative.to_string_lossy().replace('\\', "/"))?; + Ok(relative.to_string_lossy().replace('\\', "/")) +} + +fn validated_rollout_relative_path(value: &str) -> anyhow::Result { + let path = PathBuf::from(value); + if path.is_absolute() + || path + .components() + .any(|component| !matches!(component, std::path::Component::Normal(_))) + { + anyhow::bail!("invalid provider-sync rollout transaction path"); + } + let Some(first) = path.components().next() else { + anyhow::bail!("empty provider-sync rollout transaction path"); + }; + let std::path::Component::Normal(first) = first else { + anyhow::bail!("invalid provider-sync rollout transaction root"); + }; + if !SESSION_DIRS + .iter() + .any(|root| first == std::ffi::OsStr::new(root)) + { + anyhow::bail!("provider-sync transaction path is outside rollout roots"); + } + Ok(path) +} + +fn session_transaction_rollout_roots( + home: &Path, +) -> anyhow::Result> { + let canonical_home = fs::canonicalize(home)?; + let mut roots = HashMap::new(); + for dirname in SESSION_DIRS { + let root = home.join(dirname); + if !root.exists() { + continue; + } + let canonical_root = fs::canonicalize(&root)?; + if !canonical_root.starts_with(&canonical_home) { + anyhow::bail!("provider-sync rollout root resolves outside Codex home"); + } + roots.insert( + dirname.to_string(), + SessionTransactionRootEvidence { + canonical_path: canonical_root.to_string_lossy().to_string(), + identity: rollout_root_identity(&root)?, + }, + ); + } + Ok(roots) +} + +fn rollout_root_identity(path: &Path) -> anyhow::Result { + ensure_not_reparse_or_symlink(path)?; + codex_plus_core::settings::directory_instance_identity(path) +} + +fn ensure_not_reparse_or_symlink(path: &Path) -> anyhow::Result<()> { + let metadata = fs::symlink_metadata(path)?; + + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + anyhow::bail!( + "provider-sync path cannot be a reparse point: {}", + path.display() + ); + } + } + #[cfg(not(windows))] + if metadata.file_type().is_symlink() { + anyhow::bail!("provider-sync path cannot be a symlink: {}", path.display()); + } + Ok(()) +} + +fn ensure_path_components_not_reparse(root: &Path, target: &Path) -> anyhow::Result<()> { + let relative = target + .strip_prefix(root) + .map_err(|_| anyhow::anyhow!("provider-sync path is outside its validated root"))?; + let mut current = root.to_path_buf(); + for component in relative.components() { + let std::path::Component::Normal(name) = component else { + anyhow::bail!("provider-sync path contains an invalid component"); + }; + current.push(name); + ensure_not_reparse_or_symlink(¤t)?; + } + Ok(()) +} + +fn validated_rollout_transaction_path( + home: &Path, + value: &str, + rollout_roots: &HashMap, +) -> anyhow::Result { + let relative = validated_rollout_relative_path(value)?; + let root_name = relative + .components() + .next() + .and_then(|component| match component { + std::path::Component::Normal(name) => Some(name.to_os_string()), + _ => None, + }) + .ok_or_else(|| anyhow::anyhow!("provider-sync rollout path has no root"))?; + let root_name_text = root_name.to_string_lossy(); + let expected_root = rollout_roots + .get(root_name_text.as_ref()) + .ok_or_else(|| anyhow::anyhow!("provider-sync rollout root was not recorded"))?; + let path = home.join(&relative); + let parent = path + .parent() + .ok_or_else(|| anyhow::anyhow!("provider-sync rollout path has no parent"))?; + let canonical_home = fs::canonicalize(home)?; + let root = home.join(&root_name); + let canonical_root = fs::canonicalize(&root)?; + if !canonical_root.starts_with(&canonical_home) { + anyhow::bail!("provider-sync rollout root resolves outside Codex home"); + } + if canonical_root != PathBuf::from(&expected_root.canonical_path) + || rollout_root_identity(&root)? != expected_root.identity + { + anyhow::bail!("provider-sync rollout root identity changed after backup"); + } + let canonical_parent = fs::canonicalize(parent)?; + if !canonical_parent.starts_with(&canonical_root) { + anyhow::bail!("provider-sync rollout path resolves outside rollout roots"); + } + ensure_path_components_not_reparse(&root, parent)?; + if path.exists() && !fs::canonicalize(&path)?.starts_with(&canonical_root) { + anyhow::bail!("provider-sync rollout path resolves outside Codex home"); + } + if path.exists() { + ensure_path_components_not_reparse(&root, &path)?; + } + Ok(path) +} + +fn cleanup_transaction_staged_files(home: &Path, transaction_id: &str) -> std::io::Result<()> { + for dirname in SESSION_DIRS { + cleanup_transaction_staged_files_in(&home.join(dirname), transaction_id)?; + } + Ok(()) +} + +fn cleanup_transaction_staged_files_in(root: &Path, transaction_id: &str) -> std::io::Result<()> { + let entries = match fs::read_dir(root) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error), + }; + for entry in entries { + let entry = entry?; + let path = entry.path(); + let file_type = entry.file_type()?; + if file_type.is_dir() { + cleanup_transaction_staged_files_in(&path, transaction_id)?; + continue; + } + if !file_type.is_file() { + continue; + } + let should_remove = path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| provider_sync_temp_name_belongs(name, transaction_id)); + if should_remove { + fs::remove_file(path)?; + } + } + Ok(()) +} + +fn provider_sync_temp_name_belongs(name: &str, transaction_id: &str) -> bool { + let Some(body) = name + .strip_prefix('.') + .and_then(|name| name.strip_suffix(".tmp")) + else { + return false; + }; + for kind in ["", "displaced-", "rejected-"] { + let marker = format!(".provider-sync-{kind}{transaction_id}-"); + if let Some((rollout_name, index)) = body.rsplit_once(&marker) { + return !rollout_name.is_empty() + && !index.is_empty() + && index.bytes().all(|byte| byte.is_ascii_digit()); + } + } + let marker = format!(".provider-sync-restore-{transaction_id}"); + body.strip_suffix(&marker) + .is_some_and(|rollout_name| !rollout_name.is_empty()) +} + +fn file_sha256_and_size(path: &Path) -> std::io::Result<(String, u64)> { + let mut file = File::open(path)?; + let mut buffer = [0_u8; 64 * 1024]; + let mut hasher = Sha256::new(); + let mut size = 0_u64; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + size += read as u64; } - Ok(true) + Ok((format!("{:x}", hasher.finalize()), size)) +} + +fn system_time_parts(time: Option) -> (Option, Option) { + let Some(time) = time else { + return (None, None); + }; + let Ok(duration) = time.duration_since(UNIX_EPOCH) else { + return (None, None); + }; + (Some(duration.as_secs()), Some(duration.subsec_nanos())) +} + +fn restore_file_mtime_parts(path: &Path, secs: Option, nanos: Option) { + let Some(secs) = secs else { return }; + let time = UNIX_EPOCH + std::time::Duration::new(secs, nanos.unwrap_or_default()); + restore_file_mtime(path, Some(time)); +} + +fn is_locked_io_error_from_anyhow(error: &anyhow::Error) -> bool { + error + .chain() + .find_map(|cause| cause.downcast_ref::()) + .is_some_and(is_locked_io_error) } fn open_session_file_for_update(path: &Path) -> std::io::Result { @@ -2644,9 +4285,7 @@ fn sqlite_provider_ids(path: &Path) -> anyhow::Result> { Ok(sorted_provider_ids(ids)) } -fn sqlite_provider_sync_thread_kinds( - paths: &[PathBuf], -) -> anyhow::Result { +fn sqlite_provider_sync_thread_kinds(paths: &[PathBuf]) -> anyhow::Result { let mut kinds = ProviderSyncThreadKinds::default(); for path in paths { if !path.exists() { @@ -2809,38 +4448,6 @@ fn remote_control_catalog_recovery_thread_ids( Ok(known_thread_ids) } -fn rollout_thread_provider_state(text: &str) -> Option<(String, HashSet)> { - let mut thread_id = None; - let mut providers = HashSet::new(); - for segment in text.split_inclusive('\n') { - let (line, _) = split_line_ending(segment); - let Ok(record) = serde_json::from_str::(line) else { - continue; - }; - if record.get("type").and_then(Value::as_str) != Some("session_meta") { - continue; - } - let Some(payload) = record.get("payload").and_then(Value::as_object) else { - continue; - }; - if thread_id.is_none() { - thread_id = payload - .get("id") - .and_then(Value::as_str) - .filter(|id| !id.trim().is_empty()) - .map(ToString::to_string); - } - providers.insert( - payload - .get("model_provider") - .and_then(Value::as_str) - .unwrap_or("(missing)") - .to_string(), - ); - } - thread_id.map(|thread_id| (thread_id, providers)) -} - fn provider_update_thread_ids( db: &Connection, table: &str, @@ -2877,14 +4484,9 @@ fn count_sqlite_updates( let catalog_columns = table_columns(&db, "local_thread_catalog")?; let mut total = 0; if columns.contains("id") && columns.contains("model_provider") { - total += provider_update_thread_ids( - &db, - "threads", - "id", - target_provider, - excluded_thread_ids, - )? - .len(); + total += + provider_update_thread_ids(&db, "threads", "id", target_provider, excluded_thread_ids)? + .len(); } if catalog_columns.contains("thread_id") && catalog_columns.contains("model_provider") { total += provider_update_thread_ids( @@ -2962,13 +4564,9 @@ fn apply_sqlite_update( let tx = db.transaction()?; let mut counts = SqliteUpdateCounts::default(); if columns.contains("id") && columns.contains("model_provider") { - for thread_id in provider_update_thread_ids( - &tx, - "threads", - "id", - target_provider, - excluded_thread_ids, - )? { + for thread_id in + provider_update_thread_ids(&tx, "threads", "id", target_provider, excluded_thread_ids)? + { counts.provider_rows += tx.execute( "UPDATE threads SET model_provider = ?1 WHERE id = ?2 AND COALESCE(model_provider, '') <> ?1", (target_provider, thread_id), @@ -3217,9 +4815,7 @@ fn repair_missing_local_thread_catalog_rows_filtered( update_full_sync_state: bool, ) -> anyhow::Result { let plan = collect_catalog_repair_plan(home, paths, target_provider, thread_ids)?; - if plan.threads.is_empty() - && (!update_full_sync_state || !plan.has_cleanup_candidates()) - { + if plan.threads.is_empty() && (!update_full_sync_state || !plan.has_cleanup_candidates()) { return Ok(CatalogRepairCounts::default()); } let mut total = CatalogRepairCounts::default(); @@ -3519,8 +5115,7 @@ fn collect_catalog_marked_non_root_thread_ids( if thread_source_is_user(thread_source.as_deref()) { continue; } - if source_marks_non_root_agent(&source_kind) || spawned_child_ids.contains(&thread_id) - { + if source_marks_non_root_agent(&source_kind) || spawned_child_ids.contains(&thread_id) { thread_ids_by_path .entry(path.clone()) .or_default() @@ -3544,8 +5139,7 @@ fn is_catalog_non_root_agent( if thread_source_is_user(thread.thread_source.as_deref()) { return false; } - source_marks_non_root_agent(&thread.source_kind) - || spawned_child_ids.contains(&thread.id) + source_marks_non_root_agent(&thread.source_kind) || spawned_child_ids.contains(&thread.id) } fn thread_source_is_user(thread_source: Option<&str>) -> bool { @@ -3556,8 +5150,7 @@ fn thread_source_is_user(thread_source: Option<&str>) -> bool { fn thread_source_marks_non_root(thread_source: Option<&str>) -> bool { thread_source.map(str::trim).is_some_and(|value| { - value.eq_ignore_ascii_case("subagent") - || value.eq_ignore_ascii_case("memory_consolidation") + value.eq_ignore_ascii_case("subagent") || value.eq_ignore_ascii_case("memory_consolidation") }) } @@ -4058,6 +5651,215 @@ fn now_secs() -> u64 { .as_secs() } +#[cfg(test)] +mod bounded_memory_tests { + use super::*; + use std::io::{BufWriter, Write}; + use tempfile::tempdir; + + #[test] + fn collection_retains_metadata_instead_of_rollout_payloads() { + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + fs::create_dir_all(home.join("sessions")).unwrap(); + let chunk = vec![b'A'; 64 * 1024]; + for index in 0..8 { + let path = home.join(format!("sessions/rollout-{index}.jsonl")); + let mut writer = BufWriter::new(File::create(path).unwrap()); + writeln!( + writer, + "{}", + json!({ + "type": "session_meta", + "payload": { + "id": format!("thread-{index}"), + "model_provider": "openai", + "cwd": "C:/workspace" + } + }) + ) + .unwrap(); + writer + .write_all(b"{\"type\":\"event_msg\",\"payload\":{\"blob\":\"") + .unwrap(); + for _ in 0..16 { + writer.write_all(&chunk).unwrap(); + } + writer.write_all(b"\"}}\n").unwrap(); + writer.flush().unwrap(); + } + + let collected = + collect_session_changes(&home, "custom", &HashSet::new(), &HashSet::new()).unwrap(); + + assert_eq!(collected.changes.len(), 8); + let retained_bytes = collected + .changes + .iter() + .map(|change| { + change.original_sha256.len() + + change.thread_id.as_ref().map_or(0, String::len) + + change.cwd.as_ref().map_or(0, String::len) + }) + .sum::(); + assert!( + retained_bytes < 64 * 1024, + "retained {retained_bytes} bytes" + ); + } + + #[test] + fn repeated_session_meta_providers_are_retained_once() { + let tmp = tempdir().unwrap(); + let rollout = tmp.path().join("rollout-many-meta.jsonl"); + let mut writer = BufWriter::new(File::create(&rollout).unwrap()); + for index in 0..10_000 { + writeln!( + writer, + "{}", + json!({ + "type": "session_meta", + "payload": { + "id": format!("thread-{index}"), + "model_provider": "openai" + } + }) + ) + .unwrap(); + } + writer.flush().unwrap(); + + let rewrite = scan_rollout_session_meta_providers( + &rollout, + "custom", + &SessionRewriteMode::AllProviders, + ) + .unwrap(); + + assert_eq!(rewrite.session_meta_count, 10_000); + assert_eq!(rewrite.providers, HashSet::from(["openai".to_string()])); + } + + #[test] + fn prepared_backup_handle_keeps_the_verified_file_instance() { + let tmp = tempdir().unwrap(); + let backup_dir = tmp.path().join("backup"); + let source = backup_dir.join("db/state_5.sqlite"); + fs::create_dir_all(source.parent().unwrap()).unwrap(); + fs::write(&source, b"verified-backup").unwrap(); + let (sha256, size) = file_sha256_and_size(&source).unwrap(); + let evidence = HashMap::from([( + "db/state_5.sqlite".to_string(), + ProviderSyncBackupFileEvidence { size, sha256 }, + )]); + let mut prepared = prepare_provider_sync_backup_files( + &backup_dir, + &evidence, + "0123456789abcdef0123456789abcdef", + ) + .unwrap(); + + fs::write(&source, b"replacement-path-content").unwrap(); + let prepared = prepared.files.get_mut("db/state_5.sqlite").unwrap(); + prepared.file.seek(SeekFrom::Start(0)).unwrap(); + let mut bytes = Vec::new(); + prepared.file.read_to_end(&mut bytes).unwrap(); + + assert_eq!(bytes, b"verified-backup"); + assert_eq!(fs::read(source).unwrap(), b"replacement-path-content"); + } + + #[cfg(unix)] + #[test] + fn rollout_discovery_rejects_nested_symlinks() { + use std::os::unix::fs::symlink; + + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + let outside = tmp.path().join("outside"); + fs::create_dir_all(home.join("sessions")).unwrap(); + fs::create_dir(&outside).unwrap(); + symlink(&outside, home.join("sessions/link")).unwrap(); + + let error = rollout_files(&home).unwrap_err(); + + assert!(error.to_string().contains("symlink")); + } + + #[cfg(windows)] + #[test] + fn rollout_discovery_rejects_nested_junctions() { + use std::process::Command; + + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + let outside = tmp.path().join("outside"); + fs::create_dir_all(home.join("sessions")).unwrap(); + fs::create_dir(&outside).unwrap(); + let link = home.join("sessions/link"); + let output = Command::new("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "New-Item -ItemType Junction -Path $env:CODEXPP_TEST_LINK -Target $env:CODEXPP_TEST_TARGET | Out-Null", + ]) + .env("CODEXPP_TEST_LINK", &link) + .env("CODEXPP_TEST_TARGET", &outside) + .output() + .unwrap(); + assert!( + output.status.success(), + "junction creation failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let error = rollout_files(&home).unwrap_err(); + + assert!(error.to_string().contains("reparse point")); + fs::remove_dir(link).unwrap(); + } + + #[cfg(windows)] + #[test] + fn downstream_directory_guard_blocks_parent_replacement() { + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + let moved = tmp.path().join("moved-home"); + fs::create_dir(&home).unwrap(); + let guards = prepare_provider_sync_target_parents(&home, &[]).unwrap(); + + assert!(fs::rename(&home, &moved).is_err()); + drop(guards); + fs::rename(&home, &moved).unwrap(); + } + + #[test] + fn orphan_cleanup_requires_an_exact_transaction_temp_name() { + let transaction_id = "0123456789abcdef0123456789abcdef"; + assert!(provider_sync_temp_name_belongs( + ".rollout-a.jsonl.provider-sync-0123456789abcdef0123456789abcdef-4.tmp", + transaction_id + )); + assert!(provider_sync_temp_name_belongs( + ".rollout-a.jsonl.provider-sync-displaced-0123456789abcdef0123456789abcdef-4.tmp", + transaction_id + )); + assert!(provider_sync_temp_name_belongs( + ".rollout-a.jsonl.provider-sync-restore-0123456789abcdef0123456789abcdef.tmp", + transaction_id + )); + assert!(!provider_sync_temp_name_belongs( + ".user.provider-sync-note-0123456789abcdef0123456789abcdef.tmp", + transaction_id + )); + assert!(!provider_sync_temp_name_belongs( + ".rollout-a.jsonl.provider-sync-0123456789abcdef0123456789abcdef-x.tmp", + transaction_id + )); + } +} + #[cfg(test)] mod non_root_agent_tests { use super::*; @@ -4068,7 +5870,9 @@ mod non_root_agent_tests { #[test] fn structured_subagent_markers_still_identify_child_threads() { - assert!(marks_non_root(r#"{"subagent":{"thread_spawn":{"depth":1}}}"#)); + assert!(marks_non_root( + r#"{"subagent":{"thread_spawn":{"depth":1}}}"# + )); assert!(marks_non_root(r#"{"sub_agent":{"other":"review"}}"#)); assert!(marks_non_root(r#"{"internal":true}"#)); } diff --git a/crates/codex-plus-data/tests/provider_sync.rs b/crates/codex-plus-data/tests/provider_sync.rs index 7b4ff59a5..ddeff2312 100644 --- a/crates/codex-plus-data/tests/provider_sync.rs +++ b/crates/codex-plus-data/tests/provider_sync.rs @@ -6,10 +6,13 @@ use codex_plus_data::{ run_remote_control_session_catalog_recovery_for_thread_with_target, run_remote_control_session_finalization_for_thread_with_target, }; +use fs2::FileExt; use rusqlite::Connection; use serde_json::json; +use sha2::{Digest, Sha256}; use std::ffi::OsString; use std::fs; +use std::io::{BufWriter, Write}; use std::path::Path; use std::sync::Mutex; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -17,6 +20,61 @@ use tempfile::tempdir; static CODEX_HOME_ENV_LOCK: Mutex<()> = Mutex::new(()); +#[cfg(windows)] +fn current_process_peak_working_set_bytes() -> Option { + use std::ffi::c_void; + + #[repr(C)] + struct ProcessMemoryCounters { + cb: u32, + page_fault_count: u32, + peak_working_set_size: usize, + working_set_size: usize, + quota_peak_paged_pool_usage: usize, + quota_paged_pool_usage: usize, + quota_peak_non_paged_pool_usage: usize, + quota_non_paged_pool_usage: usize, + pagefile_usage: usize, + peak_pagefile_usage: usize, + } + + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetCurrentProcess() -> *mut c_void; + fn K32GetProcessMemoryInfo( + process: *mut c_void, + counters: *mut ProcessMemoryCounters, + size: u32, + ) -> i32; + } + + let mut counters = ProcessMemoryCounters { + cb: std::mem::size_of::() as u32, + page_fault_count: 0, + peak_working_set_size: 0, + working_set_size: 0, + quota_peak_paged_pool_usage: 0, + quota_paged_pool_usage: 0, + quota_peak_non_paged_pool_usage: 0, + quota_non_paged_pool_usage: 0, + pagefile_usage: 0, + peak_pagefile_usage: 0, + }; + let success = unsafe { + K32GetProcessMemoryInfo( + GetCurrentProcess(), + &mut counters, + std::mem::size_of::() as u32, + ) + }; + (success != 0).then_some(counters.peak_working_set_size as u64) +} + +#[cfg(not(windows))] +fn current_process_peak_working_set_bytes() -> Option { + None +} + struct CodexHomeEnvGuard { previous: Option, } @@ -56,6 +114,37 @@ fn write_rollout(path: &Path, provider: &str, thread_id: &str, cwd: &str) { fs::write(path, format!("{first}\n{event}\n")).unwrap(); } +fn write_large_rollout(path: &Path, provider: &str, thread_id: &str, payload_bytes: usize) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let file = fs::File::create(path).unwrap(); + let mut writer = BufWriter::new(file); + writeln!( + writer, + "{}", + json!({ + "type": "session_meta", + "payload": { + "id": thread_id, + "model_provider": provider, + "cwd": "C:/workspace" + } + }) + ) + .unwrap(); + writer + .write_all(b"{\"type\":\"event_msg\",\"payload\":{\"blob\":\"PAYLOAD_SENTINEL_") + .unwrap(); + let chunk = vec![b'A'; 64 * 1024]; + let mut remaining = payload_bytes; + while remaining > 0 { + let write = remaining.min(chunk.len()); + writer.write_all(&chunk[..write]).unwrap(); + remaining -= write; + } + writer.write_all(b"\"}}\n").unwrap(); + writer.flush().unwrap(); +} + fn write_catalog_rollout(path: &Path) { fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(path, "").unwrap(); @@ -2723,6 +2812,594 @@ fn provider_sync_preserves_rollout_mtime() { ); } +#[test] +fn provider_sync_streams_large_payloads_without_copying_them_into_the_journal() { + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + fs::create_dir(&home).unwrap(); + fs::write(home.join("config.toml"), "model_provider = \"custom\"\n").unwrap(); + for index in 0..6 { + write_large_rollout( + &home.join(format!("sessions/rollout-large-{index}.jsonl")), + "openai", + &format!("large-{index}"), + 2 * 1024 * 1024, + ); + } + + let result = run_provider_sync(Some(&home)); + + assert_eq!( + result.status, + ProviderSyncStatus::Synced, + "{}", + result.message + ); + assert_eq!(result.changed_session_files, 6); + let backup_dir = result.backup_dir.unwrap(); + let transaction = fs::read(backup_dir.join("session-transaction.json")).unwrap(); + let session_meta = fs::read(backup_dir.join("session-meta-backup.json")).unwrap(); + assert!(transaction.len() < 64 * 1024); + assert!(session_meta.len() < 64 * 1024); + assert!( + !transaction + .windows(16) + .any(|window| window == b"PAYLOAD_SENTINEL") + ); + assert!( + !session_meta + .windows(16) + .any(|window| window == b"PAYLOAD_SENTINEL") + ); + let session_meta_manifest: serde_json::Value = serde_json::from_slice(&session_meta).unwrap(); + let entries = session_meta_manifest.as_array().unwrap(); + assert_eq!(entries.len(), 6); + for entry in entries { + let relative = entry["sessionMetaBackup"].as_str().unwrap(); + assert!(relative.ends_with(".jsonl")); + let backup = fs::read(backup_dir.join(relative)).unwrap(); + assert!(backup.len() < 64 * 1024); + assert!( + !backup + .windows(16) + .any(|window| window == b"PAYLOAD_SENTINEL") + ); + } +} + +#[test] +fn provider_sync_manifest_indexes_only_rollouts_that_enter_the_transaction() { + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + fs::create_dir(&home).unwrap(); + fs::write(home.join("config.toml"), "model_provider = \"custom\"\n").unwrap(); + let locked_rollout = home.join("sessions/rollout-a-locked.jsonl"); + let applied_rollout = home.join("sessions/rollout-b-applied.jsonl"); + write_rollout(&locked_rollout, "openai", "locked", "C:/locked"); + write_rollout(&applied_rollout, "openai", "applied", "C:/applied"); + let locked = fs::OpenOptions::new() + .read(true) + .write(true) + .open(&locked_rollout) + .unwrap(); + locked.lock_exclusive().unwrap(); + + let result = run_provider_sync(Some(&home)); + + locked.unlock().unwrap(); + assert_eq!(result.status, ProviderSyncStatus::Synced, "{}", result.message); + assert_eq!(result.changed_session_files, 1); + assert_eq!(result.skipped_locked_rollout_files, vec![locked_rollout]); + let backup_dir = result.backup_dir.unwrap(); + let transaction: serde_json::Value = serde_json::from_slice( + &fs::read(backup_dir.join("session-transaction.json")).unwrap(), + ) + .unwrap(); + assert_eq!(transaction["entries"].as_array().unwrap().len(), 1); + assert!(transaction["entries"][0]["relativePath"] + .as_str() + .unwrap() + .ends_with("rollout-b-applied.jsonl")); + let manifest: serde_json::Value = serde_json::from_slice( + &fs::read(backup_dir.join("session-meta-backup.json")).unwrap(), + ) + .unwrap(); + assert_eq!(manifest.as_array().unwrap().len(), 1); + assert!(manifest[0]["path"] + .as_str() + .unwrap() + .ends_with("rollout-b-applied.jsonl")); + assert_eq!(manifest[0]["sessionMetaBackup"], "session-meta/0.jsonl"); +} + +#[test] +#[ignore = "synthetic provider-sync throughput and peak-memory benchmark"] +fn provider_sync_bounded_memory_benchmark() { + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + fs::create_dir(&home).unwrap(); + fs::write(home.join("config.toml"), "model_provider = \"custom\"\n").unwrap(); + let file_count = 48_usize; + let payload_bytes = 4 * 1024 * 1024; + for index in 0..file_count { + write_large_rollout( + &home.join(format!("sessions/rollout-benchmark-{index}.jsonl")), + "openai", + &format!("benchmark-{index}"), + payload_bytes, + ); + } + + let started = Instant::now(); + let result = run_provider_sync(Some(&home)); + let peak_working_set = current_process_peak_working_set_bytes(); + + assert_eq!(result.status, ProviderSyncStatus::Synced, "{}", result.message); + assert_eq!(result.changed_session_files, file_count); + eprintln!( + "provider-sync streamed {} files / {} MiB in {:?}; peak working set: {:?} MiB", + file_count, + file_count * payload_bytes / (1024 * 1024), + started.elapsed(), + peak_working_set.map(|bytes| bytes / (1024 * 1024)) + ); + if let Some(peak_working_set) = peak_working_set { + assert!(peak_working_set < 256 * 1024 * 1024); + } +} + +#[test] +fn provider_sync_recovers_an_interrupted_rollout_transaction_before_resuming() { + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + fs::create_dir(&home).unwrap(); + fs::write(home.join("config.toml"), "model_provider = \"custom\"\n").unwrap(); + let rollout = home.join("sessions/rollout-interrupted.jsonl"); + write_rollout(&rollout, "openai", "thread-1", "C:/workspace"); + + let first = run_provider_sync(Some(&home)); + assert_eq!(first.status, ProviderSyncStatus::Synced); + let first_backup = first.backup_dir.unwrap(); + let transaction_path = first_backup.join("session-transaction.json"); + let mut transaction: serde_json::Value = + serde_json::from_slice(&fs::read(&transaction_path).unwrap()).unwrap(); + transaction["status"] = json!("in_progress"); + transaction["phase"] = json!("rollouts_applied"); + let orphan_stage = rollout.parent().unwrap().join(format!( + ".rollout-interrupted.jsonl.provider-sync-{}-99.tmp", + transaction["transactionId"].as_str().unwrap() + )); + fs::write(&orphan_stage, b"orphaned stage").unwrap(); + fs::write( + &transaction_path, + serde_json::to_vec_pretty(&transaction).unwrap(), + ) + .unwrap(); + + let second = run_provider_sync(Some(&home)); + + assert_eq!( + second.status, + ProviderSyncStatus::Synced, + "{}", + second.message + ); + assert_eq!(second.changed_session_files, 1); + let recovered: serde_json::Value = + serde_json::from_slice(&fs::read(&transaction_path).unwrap()).unwrap(); + assert_eq!(recovered["status"], "rolled_back"); + assert!(!orphan_stage.exists()); + let first_line: serde_json::Value = serde_json::from_str( + fs::read_to_string(&rollout) + .unwrap() + .lines() + .next() + .unwrap(), + ) + .unwrap(); + assert_eq!(first_line["payload"]["model_provider"], "custom"); +} + +#[test] +fn provider_sync_restores_downstream_backup_when_crash_precedes_commit_decision() { + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + fs::create_dir(&home).unwrap(); + fs::write(home.join("config.toml"), "model_provider = \"custom\"\n").unwrap(); + let rollout = home.join("sessions/rollout-downstream.jsonl"); + write_rollout(&rollout, "openai", "thread-1", "C:/workspace"); + let state_db = home.join("state_5.sqlite"); + create_state_db(&state_db); + + let first = run_provider_sync(Some(&home)); + assert_eq!(first.status, ProviderSyncStatus::Synced); + let backup_dir = first.backup_dir.unwrap(); + let backed_up_provider: String = Connection::open(backup_dir.join("db/state_5.sqlite")) + .unwrap() + .query_row( + "SELECT model_provider FROM threads WHERE id = 'thread-1'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(backed_up_provider, "old-provider"); + let transaction_path = backup_dir.join("session-transaction.json"); + let mut transaction: serde_json::Value = + serde_json::from_slice(&fs::read(&transaction_path).unwrap()).unwrap(); + transaction["status"] = json!("in_progress"); + transaction["phase"] = json!("downstream_started"); + fs::write( + &transaction_path, + serde_json::to_vec_pretty(&transaction).unwrap(), + ) + .unwrap(); + let recovery = run_remote_control_session_catalog_recovery_for_thread_with_target( + Some(&home), + "thread-1", + "custom", + ); + assert_eq!(recovery.status, ProviderSyncStatus::Synced, "{}", recovery.message); + let restored_provider: String = Connection::open(&state_db) + .unwrap() + .query_row( + "SELECT model_provider FROM threads WHERE id = 'thread-1'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(restored_provider, "old-provider"); + let recovered: serde_json::Value = + serde_json::from_slice(&fs::read(&transaction_path).unwrap()).unwrap(); + assert_eq!(recovered["status"], "rolled_back"); + let first_line: serde_json::Value = serde_json::from_str( + fs::read_to_string(&rollout).unwrap().lines().next().unwrap(), + ) + .unwrap(); + assert_eq!(first_line["payload"]["model_provider"], "openai"); +} + +#[test] +fn provider_sync_rejects_a_tampered_downstream_backup_manifest() { + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + fs::create_dir(&home).unwrap(); + fs::write(home.join("config.toml"), "model_provider = \"custom\"\n").unwrap(); + let rollout = home.join("sessions/rollout-tampered-backup.jsonl"); + write_rollout(&rollout, "openai", "thread-1", "C:/workspace"); + create_state_db(&home.join("state_5.sqlite")); + + let first = run_provider_sync(Some(&home)); + assert_eq!(first.status, ProviderSyncStatus::Synced); + let backup_dir = first.backup_dir.unwrap(); + let transaction_path = backup_dir.join("session-transaction.json"); + let mut transaction: serde_json::Value = + serde_json::from_slice(&fs::read(&transaction_path).unwrap()).unwrap(); + transaction["status"] = json!("in_progress"); + transaction["phase"] = json!("downstream_started"); + fs::write( + &transaction_path, + serde_json::to_vec_pretty(&transaction).unwrap(), + ) + .unwrap(); + let metadata_path = backup_dir.join("metadata.json"); + let mut metadata: serde_json::Value = + serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap(); + metadata["dbFiles"] + .as_array_mut() + .unwrap() + .push(json!("config.toml")); + fs::write(&metadata_path, serde_json::to_vec_pretty(&metadata).unwrap()).unwrap(); + + let recovery = run_remote_control_session_catalog_recovery_for_thread_with_target( + Some(&home), + "thread-1", + "custom", + ); + + assert_eq!(recovery.status, ProviderSyncStatus::Skipped); + assert!(recovery.message.contains("unexpected path")); + let transaction: serde_json::Value = + serde_json::from_slice(&fs::read(&transaction_path).unwrap()).unwrap(); + assert_eq!(transaction["status"], "in_progress"); + let first_line: serde_json::Value = serde_json::from_str( + fs::read_to_string(&rollout).unwrap().lines().next().unwrap(), + ) + .unwrap(); + assert_eq!(first_line["payload"]["model_provider"], "custom"); +} + +#[test] +fn provider_sync_preflights_all_downstream_backup_hashes_before_live_mutation() { + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + fs::create_dir(&home).unwrap(); + fs::write(home.join("config.toml"), "model_provider = \"custom\"\n").unwrap(); + fs::write( + home.join(".codex-global-state.json"), + serde_json::to_vec(&json!({"modelProviderId": "openai"})).unwrap(), + ) + .unwrap(); + let rollout = home.join("sessions/rollout-tampered-backup-bytes.jsonl"); + write_rollout(&rollout, "openai", "thread-1", "C:/workspace"); + let state_db = home.join("state_5.sqlite"); + create_state_db(&state_db); + + let first = run_provider_sync(Some(&home)); + assert_eq!(first.status, ProviderSyncStatus::Synced); + let backup_dir = first.backup_dir.unwrap(); + let live_db_before = fs::read(&state_db).unwrap(); + let global_state = home.join(".codex-global-state.json"); + let live_global_before = fs::read(&global_state).unwrap(); + let live_wal = home.join("state_5.sqlite-wal"); + fs::write(&live_wal, b"live-wal-must-survive-preflight").unwrap(); + + let transaction_path = backup_dir.join("session-transaction.json"); + let mut transaction: serde_json::Value = + serde_json::from_slice(&fs::read(&transaction_path).unwrap()).unwrap(); + transaction["status"] = json!("in_progress"); + transaction["phase"] = json!("downstream_started"); + fs::write( + &transaction_path, + serde_json::to_vec_pretty(&transaction).unwrap(), + ) + .unwrap(); + let backup_db = backup_dir.join("db/state_5.sqlite"); + let mut tampered = fs::read(&backup_db).unwrap(); + tampered.extend_from_slice(b"tampered"); + fs::write(&backup_db, tampered).unwrap(); + + let recovery = run_remote_control_session_catalog_recovery_for_thread_with_target( + Some(&home), + "thread-1", + "custom", + ); + + assert_eq!(recovery.status, ProviderSyncStatus::Skipped); + assert!(recovery.message.contains("hash or size mismatch")); + assert_eq!(fs::read(&state_db).unwrap(), live_db_before); + assert_eq!(fs::read(&global_state).unwrap(), live_global_before); + assert_eq!( + fs::read(&live_wal).unwrap(), + b"live-wal-must-survive-preflight" + ); + let transaction: serde_json::Value = + serde_json::from_slice(&fs::read(&transaction_path).unwrap()).unwrap(); + assert_eq!(transaction["status"], "in_progress"); +} + +#[test] +fn provider_sync_finishes_commit_decision_without_rolling_back() { + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + fs::create_dir(&home).unwrap(); + fs::write(home.join("config.toml"), "model_provider = \"custom\"\n").unwrap(); + let rollout = home.join("sessions/rollout-commit-decided.jsonl"); + write_rollout(&rollout, "openai", "thread-1", "C:/workspace"); + + let first = run_provider_sync(Some(&home)); + assert_eq!(first.status, ProviderSyncStatus::Synced); + let backup_dir = first.backup_dir.unwrap(); + let transaction_path = backup_dir.join("session-transaction.json"); + let mut transaction: serde_json::Value = + serde_json::from_slice(&fs::read(&transaction_path).unwrap()).unwrap(); + transaction["status"] = json!("in_progress"); + transaction["phase"] = json!("commit_decided"); + fs::write( + &transaction_path, + serde_json::to_vec_pretty(&transaction).unwrap(), + ) + .unwrap(); + + let second = run_provider_sync(Some(&home)); + + assert_eq!(second.status, ProviderSyncStatus::Synced, "{}", second.message); + assert_eq!(second.changed_session_files, 0); + assert!(second.backup_dir.is_none()); + let recovered: serde_json::Value = + serde_json::from_slice(&fs::read(&transaction_path).unwrap()).unwrap(); + assert_eq!(recovered["status"], "committed"); + let first_line: serde_json::Value = serde_json::from_str( + fs::read_to_string(&rollout).unwrap().lines().next().unwrap(), + ) + .unwrap(); + assert_eq!(first_line["payload"]["model_provider"], "custom"); +} + +#[test] +fn provider_sync_recovers_a_displaced_original_left_by_atomic_replacement() { + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + fs::create_dir(&home).unwrap(); + fs::write(home.join("config.toml"), "model_provider = \"custom\"\n").unwrap(); + let rollout = home.join("sessions/rollout-displaced.jsonl"); + write_rollout(&rollout, "openai", "thread-1", "C:/workspace"); + let original = fs::read(&rollout).unwrap(); + + let first = run_provider_sync(Some(&home)); + assert_eq!(first.status, ProviderSyncStatus::Synced); + let backup_dir = first.backup_dir.unwrap(); + let transaction_path = backup_dir.join("session-transaction.json"); + let mut transaction: serde_json::Value = + serde_json::from_slice(&fs::read(&transaction_path).unwrap()).unwrap(); + transaction["status"] = json!("in_progress"); + transaction["phase"] = json!("rollouts_applied"); + fs::write( + &transaction_path, + serde_json::to_vec_pretty(&transaction).unwrap(), + ) + .unwrap(); + let displaced = rollout.parent().unwrap().join(format!( + ".rollout-displaced.jsonl.provider-sync-displaced-{}-0.tmp", + transaction["transactionId"].as_str().unwrap() + )); + fs::write(&displaced, original).unwrap(); + + let second = run_provider_sync(Some(&home)); + + assert_eq!(second.status, ProviderSyncStatus::Synced, "{}", second.message); + assert_eq!(second.changed_session_files, 1); + assert!(!displaced.exists()); + let recovered: serde_json::Value = + serde_json::from_slice(&fs::read(&transaction_path).unwrap()).unwrap(); + assert_eq!(recovered["status"], "rolled_back"); + let first_line: serde_json::Value = serde_json::from_str( + fs::read_to_string(&rollout).unwrap().lines().next().unwrap(), + ) + .unwrap(); + assert_eq!(first_line["payload"]["model_provider"], "custom"); +} + +#[test] +fn provider_sync_accepts_recorded_external_restore_after_a_second_crash() { + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + fs::create_dir(&home).unwrap(); + fs::write(home.join("config.toml"), "model_provider = \"custom\"\n").unwrap(); + let rollout = home.join("sessions/rollout-external-restore.jsonl"); + write_rollout(&rollout, "openai", "thread-1", "C:/workspace"); + let original = fs::read(&rollout).unwrap(); + let mut external = original.clone(); + external.extend_from_slice( + format!( + "{}\n", + json!({"type": "event_msg", "payload": {"external_restore": true}}) + ) + .as_bytes(), + ); + + let first = run_provider_sync(Some(&home)); + assert_eq!(first.status, ProviderSyncStatus::Synced); + let backup_dir = first.backup_dir.unwrap(); + let transaction_path = backup_dir.join("session-transaction.json"); + let mut transaction: serde_json::Value = + serde_json::from_slice(&fs::read(&transaction_path).unwrap()).unwrap(); + transaction["status"] = json!("in_progress"); + transaction["phase"] = json!("rollouts_applied"); + transaction["entries"][0]["externalSha256"] = + json!(format!("{:x}", Sha256::digest(&external))); + transaction["entries"][0]["externalSize"] = json!(external.len()); + fs::write( + &transaction_path, + serde_json::to_vec_pretty(&transaction).unwrap(), + ) + .unwrap(); + fs::write(&rollout, &external).unwrap(); + let displaced = rollout.parent().unwrap().join(format!( + ".rollout-external-restore.jsonl.provider-sync-displaced-{}-0.tmp", + transaction["transactionId"].as_str().unwrap() + )); + fs::write(&displaced, original).unwrap(); + + let second = run_provider_sync(Some(&home)); + + assert_eq!(second.status, ProviderSyncStatus::Synced, "{}", second.message); + assert_eq!(second.changed_session_files, 1); + let recovered: serde_json::Value = + serde_json::from_slice(&fs::read(&transaction_path).unwrap()).unwrap(); + assert_eq!(recovered["status"], "rolled_back"); + assert!(!displaced.exists()); + let current = fs::read_to_string(&rollout).unwrap(); + assert!(current.contains("\"external_restore\":true")); + let first_line: serde_json::Value = + serde_json::from_str(current.lines().next().unwrap()).unwrap(); + assert_eq!(first_line["payload"]["model_provider"], "custom"); +} + +#[test] +fn provider_sync_restores_a_recorded_external_displaced_file_after_a_second_crash() { + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + fs::create_dir(&home).unwrap(); + fs::write(home.join("config.toml"), "model_provider = \"custom\"\n").unwrap(); + let rollout = home.join("sessions/rollout-external-displaced.jsonl"); + write_rollout(&rollout, "openai", "thread-1", "C:/workspace"); + let mut external = fs::read(&rollout).unwrap(); + external.extend_from_slice( + format!( + "{}\n", + json!({"type": "event_msg", "payload": {"external_displaced": true}}) + ) + .as_bytes(), + ); + + let first = run_provider_sync(Some(&home)); + assert_eq!(first.status, ProviderSyncStatus::Synced); + let backup_dir = first.backup_dir.unwrap(); + let transaction_path = backup_dir.join("session-transaction.json"); + let mut transaction: serde_json::Value = + serde_json::from_slice(&fs::read(&transaction_path).unwrap()).unwrap(); + transaction["status"] = json!("in_progress"); + transaction["phase"] = json!("rollouts_applied"); + transaction["entries"][0]["externalSha256"] = + json!(format!("{:x}", Sha256::digest(&external))); + transaction["entries"][0]["externalSize"] = json!(external.len()); + fs::write( + &transaction_path, + serde_json::to_vec_pretty(&transaction).unwrap(), + ) + .unwrap(); + let displaced = rollout.parent().unwrap().join(format!( + ".rollout-external-displaced.jsonl.provider-sync-displaced-{}-0.tmp", + transaction["transactionId"].as_str().unwrap() + )); + fs::write(&displaced, &external).unwrap(); + + let second = run_provider_sync(Some(&home)); + + assert_eq!(second.status, ProviderSyncStatus::Synced, "{}", second.message); + assert_eq!(second.changed_session_files, 1); + assert!(!displaced.exists()); + let recovered: serde_json::Value = + serde_json::from_slice(&fs::read(&transaction_path).unwrap()).unwrap(); + assert_eq!(recovered["status"], "rolled_back"); + let current = fs::read_to_string(&rollout).unwrap(); + assert!(current.contains("\"external_displaced\":true")); + let first_line: serde_json::Value = + serde_json::from_str(current.lines().next().unwrap()).unwrap(); + assert_eq!(first_line["payload"]["model_provider"], "custom"); +} + +#[test] +fn provider_sync_refuses_to_recover_over_externally_changed_rollout() { + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + fs::create_dir(&home).unwrap(); + fs::write(home.join("config.toml"), "model_provider = \"custom\"\n").unwrap(); + let rollout = home.join("sessions/rollout-conflict.jsonl"); + write_rollout(&rollout, "openai", "thread-1", "C:/workspace"); + + let first = run_provider_sync(Some(&home)); + assert_eq!(first.status, ProviderSyncStatus::Synced); + let backup_dir = first.backup_dir.unwrap(); + let transaction_path = backup_dir.join("session-transaction.json"); + let mut transaction: serde_json::Value = + serde_json::from_slice(&fs::read(&transaction_path).unwrap()).unwrap(); + transaction["status"] = json!("in_progress"); + transaction["phase"] = json!("rollouts_applied"); + fs::write( + &transaction_path, + serde_json::to_vec_pretty(&transaction).unwrap(), + ) + .unwrap(); + let mut file = fs::OpenOptions::new().append(true).open(&rollout).unwrap(); + writeln!( + file, + "{}", + json!({"type": "event_msg", "payload": {"external": true}}) + ) + .unwrap(); + + let second = run_provider_sync(Some(&home)); + + assert_eq!(second.status, ProviderSyncStatus::Skipped); + assert!(second.message.contains("refusing rollback")); + let current = fs::read_to_string(&rollout).unwrap(); + assert!(current.contains("\"external\":true")); + let first_line: serde_json::Value = + serde_json::from_str(current.lines().next().unwrap()).unwrap(); + assert_eq!(first_line["payload"]["model_provider"], "custom"); +} + #[test] fn provider_sync_never_prunes_unconfirmed_or_delayed_index_entries() { let tmp = tempdir().unwrap(); From f5495fa471fd201f53ac2c453d8e9d2acd886db9 Mon Sep 17 00:00:00 2001 From: Yuimi_chaya <124485273+Yuimi-chaya@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:20:43 +0800 Subject: [PATCH 03/12] fix(manager): restore safe restart lifecycle --- .../src-tauri/src/commands.rs | 233 ++++++- crates/codex-plus-core/Cargo.toml | 2 + crates/codex-plus-core/src/cdp.rs | 18 +- crates/codex-plus-core/src/watcher.rs | 629 +++++++++++++++++- .../src/windows_integration.rs | 275 +++++++- 5 files changed, 1107 insertions(+), 50 deletions(-) diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index c226b0528..d386d9c07 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -4,7 +4,7 @@ use std::io::{Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; use codex_plus_core::install::SILENT_BINARY; use codex_plus_core::models::{DeleteResult, SessionRef}; @@ -631,7 +631,12 @@ pub fn launch_codex_plus(request: LaunchRequest) -> CommandResult { #[tauri::command] pub async fn restart_codex_plus(request: LaunchRequest) -> CommandResult { let error_payload = request.clone(); - match tauri::async_runtime::spawn_blocking(move || restart_codex_plus_blocking(request)).await { + match tauri::async_runtime::spawn_blocking(move || { + let target_cdp_endpoint = codex_plus_core::cdp::endpoint_address(request.debug_port); + restart_codex_plus_blocking(request, target_cdp_endpoint) + }) + .await + { Ok(result) => result, Err(error) => failed( &format!("重启 Codex++ 后台任务失败:{error}"), @@ -644,11 +649,61 @@ pub async fn restart_codex_plus(request: LaunchRequest) -> CommandResult } } -fn restart_codex_plus_blocking(request: LaunchRequest) -> CommandResult { +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RestartDisposition { + LaunchOnly, + StopAndRestart, +} + +fn restart_disposition(sync_active_relay: bool, target_cdp_reachable: bool) -> RestartDisposition { + if !sync_active_relay && !target_cdp_reachable { + RestartDisposition::LaunchOnly + } else { + RestartDisposition::StopAndRestart + } +} + +fn restart_codex_plus_blocking( + request: LaunchRequest, + target_cdp_endpoint: Option, +) -> CommandResult { + let restart_started = Instant::now(); let _restart_guard = match try_acquire_restart_guard() { Ok(guard) => guard, Err(message) => return failed(message, json!({})), }; + let target_cdp_reachable = target_cdp_endpoint.is_some(); + let disposition = restart_disposition(request.sync_active_relay, target_cdp_reachable); + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "manager.restart_requested", + json!({ + "debug_port": request.debug_port, + "helper_port": request.helper_port, + "app_path": request.app_path.trim(), + "sync_active_relay": request.sync_active_relay, + "target_cdp_reachable": target_cdp_reachable, + "target_cdp_endpoint": target_cdp_endpoint.map(|endpoint| endpoint.to_string()), + "disposition": match disposition { + RestartDisposition::LaunchOnly => "launch_only", + RestartDisposition::StopAndRestart => "stop_and_restart", + }, + }), + ); + if disposition == RestartDisposition::LaunchOnly { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "manager.restart_as_launch", + json!({ + "debug_port": request.debug_port, + "elapsed_ms": restart_started.elapsed().as_millis(), + }), + ); + return spawn_codex_plus_launch( + request, + "目标 Codex App CDP 不可达,已按启动方式在后台尝试唤起。", + ); + } + + let provider_guard_started = Instant::now(); let provider_sync_guard = match ensure_provider_sync_is_idle_before_stop() { Ok(guard) => guard, Err(message) => { @@ -662,6 +717,13 @@ fn restart_codex_plus_blocking(request: LaunchRequest) -> CommandResult { ); } }; + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "manager.restart_provider_guard_acquired", + json!({ + "phase_elapsed_ms": provider_guard_started.elapsed().as_millis(), + "total_elapsed_ms": restart_started.elapsed().as_millis(), + }), + ); let Ok(_relay_guard) = relay_switch_mutex().lock() else { return failed("供应商切换锁已损坏,请重启管理器后再试。", json!({})); }; @@ -681,18 +743,59 @@ fn restart_codex_plus_blocking(request: LaunchRequest) -> CommandResult { } else { None }; - codex_plus_core::watcher::stop_launcher_processes_and_wait(); - codex_plus_core::watcher::stop_codex_processes_for_debug_port_and_wait(request.debug_port); - let home = codex_plus_core::relay_config::default_codex_home_dir(); + #[cfg(windows)] + let target_stop_plan = match codex_plus_core::watcher::prepare_windows_codex_stop_plan( + request.debug_port, + target_cdp_endpoint, + ) { + Ok(plan) => plan, + Err(message) => { + return failed(&format!("重启 Codex++ 已安全中止:{message}"), json!({})); + } + }; + if let Err(message) = codex_plus_core::watcher::stop_launcher_processes_and_wait() { + return failed(&format!("重启 Codex++ 已安全中止:{message}"), json!({})); + } let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( - "manager.restart_requested", + "manager.restart_launchers_stopped", + json!({ "total_elapsed_ms": restart_started.elapsed().as_millis() }), + ); + #[cfg(windows)] + let targeted_stop_outcome = + match codex_plus_core::watcher::execute_windows_codex_stop_plan(target_stop_plan) { + Ok(outcome) => outcome, + Err(message) => { + return failed(&format!("重启 Codex++ 已安全中止:{message}"), json!({})); + } + }; + #[cfg(not(windows))] + let targeted_stop_outcome = + match codex_plus_core::watcher::stop_codex_processes_for_debug_port_and_wait( + request.debug_port, + ) { + Ok(outcome) => outcome, + Err(message) => { + return failed(&format!("重启 Codex++ 已安全中止:{message}"), json!({})); + } + }; + #[cfg(windows)] + let targeted_stop_outcome = match targeted_stop_outcome { + codex_plus_core::watcher::TargetedStopOutcome::Stopped => "stopped", + codex_plus_core::watcher::TargetedStopOutcome::AlreadyAbsent => "already_absent", + }; + #[cfg(not(windows))] + let targeted_stop_outcome = match targeted_stop_outcome { + codex_plus_core::watcher::TargetedStopOutcome::Stopped => "stopped", + codex_plus_core::watcher::TargetedStopOutcome::AlreadyAbsent => "already_absent", + }; + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "manager.restart_codex_stopped", json!({ - "debug_port": request.debug_port, - "helper_port": request.helper_port, - "app_path": request.app_path.trim(), - "sync_active_relay": request.sync_active_relay + "total_elapsed_ms": restart_started.elapsed().as_millis(), + "outcome": targeted_stop_outcome, }), ); + let home = codex_plus_core::relay_config::default_codex_home_dir(); let launch_started_at_ms = current_timestamp_ms(); if let Err(error) = save_requested_launch_status( &request, @@ -722,16 +825,22 @@ fn restart_codex_plus_blocking(request: LaunchRequest) -> CommandResult { settings.as_ref(), spawn_after_guard_release, ) { - Ok(()) => CommandResult { - status: "accepted".to_string(), - message: "Codex 已请求重启,启动任务正在后台运行。".to_string(), - payload: json!({ - "debugPort": request.debug_port, - "helperPort": request.helper_port, - "syncActiveRelay": request.sync_active_relay, - "launchStartedAtMs": launch_started_at_ms - }), - }, + Ok(()) => { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "manager.restart_launcher_spawned", + json!({ "total_elapsed_ms": restart_started.elapsed().as_millis() }), + ); + CommandResult { + status: "accepted".to_string(), + message: "Codex 已请求重启,启动任务正在后台运行。".to_string(), + payload: json!({ + "debugPort": request.debug_port, + "helperPort": request.helper_port, + "syncActiveRelay": request.sync_active_relay, + "launchStartedAtMs": launch_started_at_ms + }), + } + } Err(error) => { let message = format!("重启 Codex++ 失败:{error}"); let _ = @@ -6232,6 +6341,88 @@ mod tests { assert_eq!(result.payload["syncStatus"], "synced"); } + #[test] + fn ordinary_restart_uses_launch_path_when_target_app_is_absent() { + assert_eq!( + restart_disposition(false, false), + RestartDisposition::LaunchOnly + ); + } + + #[test] + fn ordinary_restart_stops_and_restarts_when_target_app_is_running() { + assert_eq!( + restart_disposition(false, true), + RestartDisposition::StopAndRestart + ); + } + + #[test] + fn active_relay_restart_keeps_full_restart_when_target_app_is_absent() { + assert_eq!( + restart_disposition(true, false), + RestartDisposition::StopAndRestart + ); + } + + #[test] + fn restart_as_launch_decision_precedes_provider_sync_guard_wait() { + let source = include_str!("commands.rs"); + let async_start = source + .find("pub async fn restart_codex_plus") + .expect("async restart command"); + let start = source + .find("fn restart_codex_plus_blocking") + .expect("restart command"); + let async_body = &source[async_start..start]; + let end = source[start..] + .find("fn restart_codex_plus_after_stop") + .map(|offset| start + offset) + .expect("restart helper boundary"); + let body = &source[start..end]; + let launch_only = body + .find("if disposition == RestartDisposition::LaunchOnly") + .expect("restart-as-launch branch"); + let provider_guard = body + .find("ensure_provider_sync_is_idle_before_stop") + .expect("provider sync guard wait"); + + assert!(async_body.contains("codex_plus_core::cdp::endpoint_address(request.debug_port)")); + assert!(!async_body.contains("watcher::cdp_listening")); + assert!(launch_only < provider_guard); + assert!(body[launch_only..provider_guard].contains("spawn_codex_plus_launch")); + } + + #[test] + fn full_restart_prepares_and_revalidates_the_target_before_live_sync() { + let source = include_str!("commands.rs"); + let start = source + .find("fn restart_codex_plus_blocking") + .expect("restart command"); + let end = source[start..] + .find("fn restart_codex_plus_after_stop") + .map(|offset| start + offset) + .expect("restart helper boundary"); + let body = &source[start..end]; + let prepare = body + .find("prepare_windows_codex_stop_plan") + .expect("target identity capture"); + let stop_launcher = body + .find("stop_launcher_processes_and_wait") + .expect("launcher stop"); + let execute = body + .find("execute_windows_codex_stop_plan") + .expect("target identity recheck and stop"); + let live_sync = body + .find("restart_codex_plus_after_stop") + .unwrap_or(body.len()); + + assert!(prepare < stop_launcher); + assert!(stop_launcher < execute); + assert!(execute < live_sync); + assert!(body[execute..live_sync].contains("return failed")); + } + #[test] fn restart_does_not_wait_when_no_provider_sync_is_running() { let slept = std::cell::Cell::new(0); diff --git a/crates/codex-plus-core/Cargo.toml b/crates/codex-plus-core/Cargo.toml index 5672ba624..7b8175087 100644 --- a/crates/codex-plus-core/Cargo.toml +++ b/crates/codex-plus-core/Cargo.toml @@ -31,6 +31,8 @@ zstd.workspace = true [target.'cfg(windows)'.dependencies] windows = { version = "0.58", features = [ "Win32_Foundation", + "Win32_NetworkManagement_IpHelper", + "Win32_Networking_WinSock", "Win32_Storage_EnhancedStorage", "Win32_Storage_FileSystem", "Win32_Storage_Packaging_Appx", diff --git a/crates/codex-plus-core/src/cdp.rs b/crates/codex-plus-core/src/cdp.rs index 047d7d942..7a292956c 100644 --- a/crates/codex-plus-core/src/cdp.rs +++ b/crates/codex-plus-core/src/cdp.rs @@ -43,14 +43,19 @@ impl CdpBrowserIdentity { } } -/// Returns whether the requested loopback port exposes a CDP target list. -pub(crate) fn endpoint_available(debug_port: u16) -> bool { +/// Returns whether the requested loopback port exposes the main Codex App CDP target. +pub fn endpoint_available(debug_port: u16) -> bool { + endpoint_address(debug_port).is_some() +} + +/// Returns the concrete loopback endpoint that exposes the main Codex App CDP target. +pub fn endpoint_address(debug_port: u16) -> Option { [ SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), debug_port), SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), debug_port), ] .into_iter() - .any(|address| probe_endpoint(address, debug_port)) + .find(|address| probe_endpoint(*address, debug_port)) } fn probe_endpoint(address: SocketAddr, debug_port: u16) -> bool { @@ -379,14 +384,17 @@ mod endpoint_tests { } #[test] - fn endpoint_available_accepts_devtools_target_response() { + fn endpoint_address_returns_the_matching_loopback_family() { let (port, server) = serve_once(|port| { format!( r#"[{{"id":"codex","type":"page","title":"Codex","url":"app://-/index.html","webSocketDebuggerUrl":"ws://127.0.0.1:{port}/devtools/page/1"}}]"# ) }); - assert!(endpoint_available(port)); + assert_eq!( + endpoint_address(port), + Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port)) + ); server.join().unwrap(); } diff --git a/crates/codex-plus-core/src/watcher.rs b/crates/codex-plus-core/src/watcher.rs index 6d69873f5..7f82aad80 100644 --- a/crates/codex-plus-core/src/watcher.rs +++ b/crates/codex-plus-core/src/watcher.rs @@ -295,6 +295,140 @@ mod process_identity_tests { assert!(started_at_secs.is_some()); assert!(birth_id.is_some()); } + + #[cfg(windows)] + fn windows_process( + process_id: u32, + parent_process_id: u32, + exe_file: &str, + executable_path: &str, + ) -> WindowsProcessInfo { + WindowsProcessInfo { + process_id, + parent_process_id, + exe_file: exe_file.to_string(), + executable_path: Some(PathBuf::from(executable_path)), + } + } + + #[cfg(windows)] + #[test] + fn targeted_stop_selects_only_the_listener_codex_process_tree() { + let target_root = windows_process( + 10, + 1, + "ChatGPT.exe", + r"C:\Program Files\WindowsApps\OpenAI.Codex_26.820.7780.0_x64__2p2nqsd0c76g0\app\ChatGPT.exe", + ); + let target_child = windows_process( + 11, + 10, + "ChatGPT.exe", + r"C:\Program Files\WindowsApps\OpenAI.Codex_26.820.7780.0_x64__2p2nqsd0c76g0\app\ChatGPT.exe", + ); + let target_helper = windows_process( + 12, + 11, + "codex-code-mode-host.exe", + r"C:\Program Files\WindowsApps\OpenAI.Codex_26.820.7780.0_x64__2p2nqsd0c76g0\app\codex-code-mode-host.exe", + ); + let unrelated_root = windows_process( + 20, + 1, + "ChatGPT.exe", + r"C:\Program Files\WindowsApps\OpenAI.Codex_26.821.1.0_x64__2p2nqsd0c76g0\app\ChatGPT.exe", + ); + let unrelated_child = windows_process( + 21, + 20, + "ChatGPT.exe", + r"C:\Program Files\WindowsApps\OpenAI.Codex_26.821.1.0_x64__2p2nqsd0c76g0\app\ChatGPT.exe", + ); + + let selected = target_codex_process_tree_from_snapshot( + &[ + target_root, + target_child, + target_helper, + unrelated_root, + unrelated_child, + ], + &[11], + &HashMap::from([(10, 100), (11, 110), (12, 120), (20, 200), (21, 210)]), + ); + + assert_eq!(selected, vec![12, 11, 10]); + } + + #[cfg(windows)] + #[test] + fn targeted_stop_refuses_an_untrusted_listener_owner() { + let unrelated = windows_process(30, 1, "other.exe", r"C:\Tools\other.exe"); + + let selected = target_codex_process_tree_from_snapshot( + &[unrelated], + &[30], + &HashMap::from([(30, 300)]), + ); + + assert!(selected.is_empty()); + } + + #[cfg(windows)] + #[test] + fn targeted_stop_does_not_follow_a_newer_reused_parent() { + let parent = windows_process( + 10, + 1, + "ChatGPT.exe", + r"C:\Program Files\WindowsApps\OpenAI.Codex_26.820.7780.0_x64__2p2nqsd0c76g0\app\ChatGPT.exe", + ); + let owner = windows_process( + 11, + 10, + "ChatGPT.exe", + r"C:\Program Files\WindowsApps\OpenAI.Codex_26.820.7780.0_x64__2p2nqsd0c76g0\app\ChatGPT.exe", + ); + + let selected = target_codex_process_tree_from_snapshot( + &[parent, owner], + &[11], + &HashMap::from([(10, 120), (11, 110)]), + ); + + assert_eq!(selected, vec![11]); + } + + #[cfg(windows)] + #[test] + fn targeted_stop_refuses_incomplete_same_package_birth_identity() { + let root = windows_process( + 10, + 1, + "ChatGPT.exe", + r"C:\Program Files\WindowsApps\OpenAI.Codex_26.820.7780.0_x64__2p2nqsd0c76g0\app\ChatGPT.exe", + ); + let child = windows_process( + 11, + 10, + "ChatGPT.exe", + r"C:\Program Files\WindowsApps\OpenAI.Codex_26.820.7780.0_x64__2p2nqsd0c76g0\app\ChatGPT.exe", + ); + let identity_root = process_identity_root(root.executable_path.as_deref().unwrap()); + + let result = collect_identity_birth_ids(&[root, child], &identity_root, |process_id| { + (process_id == 10).then_some(100) + }); + + assert_eq!(result, Err(11)); + } + + #[cfg(windows)] + #[test] + fn targeted_stop_rejects_another_listener_owner_appearing() { + assert!(listener_owners_unchanged(&[10], &[10])); + assert!(!listener_owners_unchanged(&[10], &[10, 20])); + } } #[cfg(windows)] @@ -426,6 +560,204 @@ pub fn find_codex_processes_from_snapshot( ids } +#[cfg(windows)] +#[derive(Debug, Clone)] +struct WindowsTargetProcess { + process_id: u32, + birth_id: u64, + executable_path: PathBuf, +} + +#[cfg(windows)] +#[derive(Debug, Clone)] +pub struct WindowsCodexStopPlan { + debug_port: u16, + endpoint: Option, + listener_process_ids: Vec, + target_processes: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TargetedStopOutcome { + Stopped, + AlreadyAbsent, +} + +#[cfg(windows)] +fn process_identity_root(path: &Path) -> String { + let normalized = path + .to_string_lossy() + .replace('/', "\\") + .to_ascii_lowercase(); + if let Some((prefix, after_windows_apps)) = normalized.split_once("\\windowsapps\\") + && let Some((package_name, _)) = after_windows_apps.split_once('\\') + { + return format!("{prefix}\\windowsapps\\{package_name}"); + } + path.parent() + .unwrap_or(path) + .to_string_lossy() + .replace('/', "\\") + .to_ascii_lowercase() +} + +#[cfg(windows)] +fn collect_identity_birth_ids( + processes: &[crate::windows_integration::WindowsProcessInfo], + identity_root: &str, + mut birth_id_for: F, +) -> Result, u32> +where + F: FnMut(u32) -> Option, +{ + processes + .iter() + .filter(|process| { + process + .executable_path + .as_deref() + .is_some_and(|path| process_identity_root(path) == identity_root) + }) + .map(|process| { + birth_id_for(process.process_id) + .map(|birth_id| (process.process_id, birth_id)) + .ok_or(process.process_id) + }) + .collect() +} + +#[cfg(windows)] +fn process_descends_from( + process_id: u32, + ancestor_process_id: u32, + parents: &HashMap, +) -> bool { + let mut cursor = process_id; + let mut visited = HashSet::new(); + while visited.insert(cursor) { + if cursor == ancestor_process_id { + return true; + } + let Some(parent) = parents.get(&cursor).copied() else { + return false; + }; + cursor = parent; + } + false +} + +#[cfg(windows)] +fn listener_owners_unchanged(expected: &[u32], current: &[u32]) -> bool { + expected == current +} + +#[cfg(windows)] +fn target_codex_process_tree_from_snapshot( + processes: &[crate::windows_integration::WindowsProcessInfo], + listener_process_ids: &[u32], + birth_ids: &HashMap, +) -> Vec { + let supported = find_codex_processes_from_snapshot(processes) + .into_iter() + .collect::>(); + let parents = processes + .iter() + .map(|process| (process.process_id, process.parent_process_id)) + .collect::>(); + let paths = processes + .iter() + .filter_map(|process| { + process + .executable_path + .as_deref() + .map(|path| (process.process_id, path)) + }) + .collect::>(); + let mut roots = HashSet::new(); + for process_id in listener_process_ids { + if !supported.contains(process_id) || !birth_ids.contains_key(process_id) { + continue; + } + let Some(owner_path) = paths.get(process_id) else { + continue; + }; + let identity_root = process_identity_root(owner_path); + let mut root = *process_id; + let mut cursor = *process_id; + let mut visited = HashSet::new(); + while visited.insert(cursor) { + let Some(parent) = parents.get(&cursor).copied() else { + break; + }; + if !supported.contains(&parent) { + break; + } + let Some(parent_path) = paths.get(&parent) else { + break; + }; + let Some(parent_birth_id) = birth_ids.get(&parent).copied() else { + break; + }; + let Some(cursor_birth_id) = birth_ids.get(&cursor).copied() else { + break; + }; + if process_identity_root(parent_path) != identity_root + || parent_birth_id > cursor_birth_id + { + break; + } + root = parent; + cursor = parent; + } + roots.insert((root, identity_root)); + } + if roots.is_empty() { + return Vec::new(); + } + + let mut targets = Vec::new(); + for process in processes { + let Some(process_path) = process.executable_path.as_deref() else { + continue; + }; + let Some(process_birth_id) = birth_ids.get(&process.process_id).copied() else { + continue; + }; + let mut cursor = process.process_id; + let mut cursor_birth_id = process_birth_id; + let mut depth = 0usize; + let mut visited = HashSet::new(); + while visited.insert(cursor) { + if roots.contains(&(cursor, process_identity_root(process_path))) { + targets.push((depth, process.process_id)); + break; + } + let Some(parent) = parents.get(&cursor).copied() else { + break; + }; + let Some(parent_birth_id) = birth_ids.get(&parent).copied() else { + break; + }; + if parent_birth_id > cursor_birth_id { + break; + } + cursor = parent; + cursor_birth_id = parent_birth_id; + depth = depth.saturating_add(1); + } + } + targets.sort_unstable_by(|(left_depth, left_pid), (right_depth, right_pid)| { + right_depth + .cmp(left_depth) + .then_with(|| left_pid.cmp(right_pid)) + }); + targets.dedup_by_key(|(_, process_id)| *process_id); + targets + .into_iter() + .map(|(_, process_id)| process_id) + .collect() +} + /// Return desktop processes that can write Codex task state while a destructive /// session-index cleanup is running. This is intentionally stricter than the /// watcher filter: any supported ChatGPT desktop process blocks deletion, @@ -519,7 +851,7 @@ pub fn stop_launcher_processes() { pub fn stop_launcher_processes() {} #[cfg(windows)] -pub fn stop_launcher_processes_and_wait() { +pub fn stop_launcher_processes_and_wait() -> Result<(), String> { let processes = crate::windows_integration::enumerate_processes(); let killable = filter_killable_launcher_processes( processes.iter().map(|process| { @@ -531,25 +863,35 @@ pub fn stop_launcher_processes_and_wait() { }), std::process::id(), ); - terminate_and_wait_for_exit( + if terminate_and_wait_for_exit( killable, RESTART_STOP_WAIT_TIMEOUT_MS, RESTART_STOP_WAIT_INTERVAL_MS, - ); + ) { + Ok(()) + } else { + Err("等待旧 Codex++ Launcher 退出超时,已中止重启。".to_string()) + } } #[cfg(target_os = "macos")] -pub fn stop_launcher_processes_and_wait() { - terminate_macos_processes_and_wait( +pub fn stop_launcher_processes_and_wait() -> Result<(), String> { + if terminate_macos_processes_and_wait( find_launcher_processes(), || find_launcher_processes(), RESTART_STOP_WAIT_TIMEOUT_MS, RESTART_STOP_WAIT_INTERVAL_MS, - ); + ) { + Ok(()) + } else { + Err("等待旧 Codex++ Launcher 退出超时,已中止重启。".to_string()) + } } #[cfg(not(any(windows, target_os = "macos")))] -pub fn stop_launcher_processes_and_wait() {} +pub fn stop_launcher_processes_and_wait() -> Result<(), String> { + Ok(()) +} #[cfg(windows)] pub fn stop_codex_processes() { @@ -570,7 +912,7 @@ pub fn stop_codex_processes() { #[cfg(windows)] pub fn stop_codex_processes_and_wait() { - terminate_and_wait_for_exit( + let _ = terminate_and_wait_for_exit( find_codex_processes(), RESTART_STOP_WAIT_TIMEOUT_MS, RESTART_STOP_WAIT_INTERVAL_MS, @@ -579,7 +921,7 @@ pub fn stop_codex_processes_and_wait() { #[cfg(target_os = "macos")] pub fn stop_codex_processes_and_wait() { - terminate_macos_processes_and_wait( + let _ = terminate_macos_processes_and_wait( find_codex_processes(), || find_codex_processes(), RESTART_STOP_WAIT_TIMEOUT_MS, @@ -591,18 +933,262 @@ pub fn stop_codex_processes_and_wait() { pub fn stop_codex_processes_and_wait() {} #[cfg(target_os = "macos")] -pub fn stop_codex_processes_for_debug_port_and_wait(debug_port: u16) { - terminate_macos_processes_and_wait( - find_macos_codex_processes_for_debug_port(debug_port), +pub fn stop_codex_processes_for_debug_port_and_wait( + debug_port: u16, +) -> Result { + let process_ids = find_macos_codex_processes_for_debug_port(debug_port); + if process_ids.is_empty() { + return Ok(TargetedStopOutcome::AlreadyAbsent); + } + if terminate_macos_processes_and_wait( + process_ids, || find_macos_codex_processes_for_debug_port(debug_port), RESTART_STOP_WAIT_TIMEOUT_MS, RESTART_STOP_WAIT_INTERVAL_MS, + ) { + Ok(TargetedStopOutcome::Stopped) + } else { + Err("等待目标 Codex App 退出超时,已中止重启。".to_string()) + } +} + +#[cfg(windows)] +pub fn prepare_windows_codex_stop_plan( + debug_port: u16, + endpoint: Option, +) -> Result { + let listener_process_ids = if let Some(endpoint) = endpoint { + let process_ids = crate::windows_integration::tcp_listener_process_ids(endpoint) + .map_err(|error| format!("读取目标 Codex App 端口归属失败:{error}"))?; + if process_ids.is_empty() { + let remaining = + crate::windows_integration::loopback_tcp_listener_process_ids(debug_port) + .map_err(|error| format!("复核 Codex App 调试端口失败:{error}"))?; + if remaining.is_empty() { + return Ok(WindowsCodexStopPlan { + debug_port, + endpoint: None, + listener_process_ids: Vec::new(), + target_processes: Vec::new(), + }); + } + return Err("目标 Codex App 调试端口在重启前发生了归属变化,已拒绝停止。".to_string()); + } + let all_loopback_process_ids = + crate::windows_integration::loopback_tcp_listener_process_ids(debug_port) + .map_err(|error| format!("复核 Codex App 调试端口失败:{error}"))?; + if !listener_owners_unchanged(&process_ids, &all_loopback_process_ids) { + return Err( + "同一调试端口存在另一个回环监听实例,已拒绝停止任何 Codex App。".to_string(), + ); + } + process_ids + } else { + let process_ids = crate::windows_integration::loopback_tcp_listener_process_ids(debug_port) + .map_err(|error| format!("复核 Codex App 调试端口失败:{error}"))?; + if !process_ids.is_empty() { + return Err( + "目标 Codex App CDP 不可确认,但调试端口仍有监听,已拒绝停止。".to_string(), + ); + } + return Ok(WindowsCodexStopPlan { + debug_port, + endpoint: None, + listener_process_ids: Vec::new(), + target_processes: Vec::new(), + }); + }; + if listener_process_ids.len() != 1 { + return Err("目标 Codex App 调试 endpoint 对应多个进程,已拒绝停止。".to_string()); + } + + let processes = crate::windows_integration::enumerate_processes(); + let owner_process_id = listener_process_ids[0]; + let owner = processes + .iter() + .find(|process| process.process_id == owner_process_id) + .ok_or_else(|| "目标 Codex App 端口进程已退出,已拒绝按旧 PID 停止。".to_string())?; + let owner_path = owner + .executable_path + .as_deref() + .ok_or_else(|| "无法读取目标 Codex App 可执行路径,已拒绝停止。".to_string())?; + let identity_root = process_identity_root(owner_path); + let processes_by_id = processes + .iter() + .map(|process| (process.process_id, process)) + .collect::>(); + let mut ancestor_cursor = owner_process_id; + let mut visited_ancestors = HashSet::new(); + while visited_ancestors.insert(ancestor_cursor) { + let Some(process) = processes_by_id.get(&ancestor_cursor) else { + break; + }; + let Some(parent) = processes_by_id.get(&process.parent_process_id) else { + break; + }; + let Some(parent_path) = parent.executable_path.as_deref() else { + if crate::app_paths::is_supported_app_executable_name(&parent.exe_file) { + return Err(format!( + "无法读取目标候选父进程 {} 的可执行路径,已拒绝生成部分停止计划。", + parent.process_id + )); + } + break; + }; + if process_identity_root(parent_path) != identity_root { + break; + } + ancestor_cursor = parent.process_id; + } + let birth_ids = collect_identity_birth_ids(&processes, &identity_root, |process_id| { + crate::windows_integration::process_birth_id(process_id) + }) + .map_err(|process_id| { + format!("无法读取目标同包进程 {process_id} 的创建时间,已拒绝生成部分停止计划。") + })?; + let target_process_ids = + target_codex_process_tree_from_snapshot(&processes, &listener_process_ids, &birth_ids); + if target_process_ids.is_empty() { + return Err("目标调试端口不属于受支持的 Codex App 进程树,已拒绝停止。".to_string()); + } + let root_process_id = *target_process_ids + .last() + .ok_or_else(|| "目标 Codex App 停止计划缺少根进程。".to_string())?; + let parents = processes + .iter() + .map(|process| (process.process_id, process.parent_process_id)) + .collect::>(); + if let Some(process) = processes.iter().find(|process| { + process.executable_path.is_none() + && process_descends_from(process.process_id, root_process_id, &parents) + }) { + return Err(format!( + "无法读取目标进程树内 PID {} 的可执行路径,已拒绝生成部分停止计划。", + process.process_id + )); + } + let target_processes = target_process_ids + .into_iter() + .map(|process_id| { + let process = processes + .iter() + .find(|process| process.process_id == process_id) + .ok_or_else(|| format!("目标进程 {process_id} 已退出,无法建立安全停止计划。"))?; + let birth_id = birth_ids + .get(&process_id) + .copied() + .ok_or_else(|| format!("无法读取目标进程 {process_id} 的创建时间。"))?; + let executable_path = process + .executable_path + .clone() + .ok_or_else(|| format!("无法读取目标进程 {process_id} 的可执行路径。"))?; + Ok(WindowsTargetProcess { + process_id, + birth_id, + executable_path, + }) + }) + .collect::, String>>()?; + + Ok(WindowsCodexStopPlan { + debug_port, + endpoint, + listener_process_ids, + target_processes, + }) +} + +#[cfg(windows)] +pub fn execute_windows_codex_stop_plan( + plan: WindowsCodexStopPlan, +) -> Result { + if plan.target_processes.is_empty() { + let listeners = + crate::windows_integration::loopback_tcp_listener_process_ids(plan.debug_port) + .map_err(|error| format!("复核 Codex App 调试端口失败:{error}"))?; + return if listeners.is_empty() { + Ok(TargetedStopOutcome::AlreadyAbsent) + } else { + Err("Codex App 在停止 Launcher 后重新占用了调试端口,已中止重启。".to_string()) + }; + } + + let endpoint = plan + .endpoint + .ok_or_else(|| "安全停止计划缺少目标 endpoint。".to_string())?; + let current_listener_process_ids = + crate::windows_integration::loopback_tcp_listener_process_ids(plan.debug_port) + .map_err(|error| format!("停止前复核目标 Codex App 端口归属失败:{error}"))?; + if !listener_owners_unchanged(&plan.listener_process_ids, ¤t_listener_process_ids) { + return Err("目标 Codex App 端口归属在停止前发生变化,已中止重启。".to_string()); + } + + let _ = crate::diagnostic_log::append_diagnostic_log( + "watcher.targeted_stop_started", + serde_json::json!({ + "debug_port": plan.debug_port, + "endpoint": endpoint.to_string(), + "listener_process_ids": &plan.listener_process_ids, + "target_process_ids": plan.target_processes.iter().map(|process| process.process_id).collect::>(), + }), ); + for process in &plan.target_processes { + match crate::windows_integration::terminate_process_if_identity_matches( + process.process_id, + process.birth_id, + &process.executable_path, + ) { + Ok(true) => {} + Ok(false) + if crate::windows_integration::process_birth_id(process.process_id) + != Some(process.birth_id) => {} + Ok(false) => { + return Err(format!( + "目标进程 {} 的身份复核失败,已中止重启。", + process.process_id + )); + } + Err(error) => { + return Err(format!("停止目标进程 {} 失败:{error}", process.process_id)); + } + } + } + + let deadline = std::time::Instant::now() + Duration::from_millis(RESTART_STOP_WAIT_TIMEOUT_MS); + loop { + let remaining = plan + .target_processes + .iter() + .filter(|process| { + crate::windows_integration::process_birth_id(process.process_id) + == Some(process.birth_id) + }) + .map(|process| process.process_id) + .collect::>(); + if remaining.is_empty() { + break; + } + if std::time::Instant::now() >= deadline { + return Err(format!( + "等待目标 Codex App 退出超时,仍在运行的 PID:{remaining:?}" + )); + } + std::thread::sleep(Duration::from_millis(RESTART_STOP_WAIT_INTERVAL_MS)); + } + let remaining_listeners = + crate::windows_integration::loopback_tcp_listener_process_ids(plan.debug_port) + .map_err(|error| format!("停止后复核目标 Codex App 端口失败:{error}"))?; + if !remaining_listeners.is_empty() { + return Err("目标 Codex App 停止后调试 endpoint 仍被占用,已中止重启。".to_string()); + } + Ok(TargetedStopOutcome::Stopped) } -#[cfg(not(target_os = "macos"))] -pub fn stop_codex_processes_for_debug_port_and_wait(_debug_port: u16) { - stop_codex_processes_and_wait(); +#[cfg(not(any(windows, target_os = "macos")))] +pub fn stop_codex_processes_for_debug_port_and_wait( + _debug_port: u16, +) -> Result { + Ok(TargetedStopOutcome::AlreadyAbsent) } #[cfg(target_os = "macos")] @@ -611,11 +1197,12 @@ fn terminate_macos_processes_and_wait( mut find_processes: F, timeout_ms: u64, interval_ms: u64, -) where +) -> bool +where F: FnMut() -> Vec, { if process_ids.is_empty() { - return; + return true; } for process_id in &process_ids { let _ = terminate_macos_process(*process_id); @@ -634,7 +1221,7 @@ fn terminate_macos_processes_and_wait( }), ); } - break; + return remaining.is_empty(); } std::thread::sleep(Duration::from_millis(interval_ms)); } @@ -704,9 +1291,9 @@ fn macos_codex_process_ids_for_debug_port<'a>( } #[cfg(windows)] -fn terminate_and_wait_for_exit(process_ids: Vec, timeout_ms: u64, interval_ms: u64) { +fn terminate_and_wait_for_exit(process_ids: Vec, timeout_ms: u64, interval_ms: u64) -> bool { if process_ids.is_empty() { - return; + return true; } for process_id in &process_ids { let _ = crate::windows_integration::terminate_process(*process_id); @@ -727,7 +1314,7 @@ fn terminate_and_wait_for_exit(process_ids: Vec, timeout_ms: u64, interval_ }), ); } - break; + return remaining.is_empty(); } std::thread::sleep(Duration::from_millis(interval_ms)); } diff --git a/crates/codex-plus-core/src/windows_integration.rs b/crates/codex-plus-core/src/windows_integration.rs index 17921d8b8..3857bb7d6 100644 --- a/crates/codex-plus-core/src/windows_integration.rs +++ b/crates/codex-plus-core/src/windows_integration.rs @@ -3,9 +3,11 @@ use std::ffi::{OsStr, OsString}; #[cfg(windows)] use std::iter::once; #[cfg(windows)] +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; +#[cfg(windows)] use std::os::windows::ffi::{OsStrExt, OsStringExt}; #[cfg(windows)] -use std::path::PathBuf; +use std::path::{Path, PathBuf}; #[cfg(windows)] use std::sync::OnceLock; @@ -13,9 +15,17 @@ use std::sync::OnceLock; use anyhow::Context; #[cfg(windows)] use windows::Win32::Foundation::{ - BOOL, CloseHandle, FILETIME, HANDLE, HWND, LPARAM, MAX_PATH, WPARAM, + BOOL, CloseHandle, ERROR_INSUFFICIENT_BUFFER, ERROR_INVALID_PARAMETER, FILETIME, HANDLE, HWND, + LPARAM, MAX_PATH, NO_ERROR, WPARAM, +}; +#[cfg(windows)] +use windows::Win32::NetworkManagement::IpHelper::{ + GetExtendedTcpTable, MIB_TCP6ROW_OWNER_PID, MIB_TCP6TABLE_OWNER_PID, MIB_TCPROW_OWNER_PID, + MIB_TCPTABLE_OWNER_PID, TCP_TABLE_OWNER_PID_LISTENER, }; #[cfg(windows)] +use windows::Win32::Networking::WinSock::{AF_INET, AF_INET6}; +#[cfg(windows)] use windows::Win32::System::Com::{ CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED, CoCreateInstance, CoInitializeEx, CoTaskMemFree, CoUninitialize, IPersistFile, @@ -55,7 +65,7 @@ use windows::Win32::UI::WindowsAndMessaging::{ HICON, ICON_BIG, ICON_SMALL, SendMessageW, WM_SETICON, }; #[cfg(windows)] -use windows::core::{Interface, PCWSTR, PROPVARIANT, PWSTR}; +use windows::core::{HRESULT, Interface, PCWSTR, PROPVARIANT, PWSTR}; #[cfg(windows)] pub const CREATE_NO_WINDOW: u32 = 0x08000000; @@ -329,6 +339,171 @@ pub fn enumerate_processes() -> Vec { processes } +#[cfg(windows)] +pub fn tcp_listener_process_ids(address: SocketAddr) -> std::io::Result> { + let port = address.port(); + let mut process_ids = match address.ip() { + IpAddr::V4(address) => tcp4_listener_process_ids(address, port), + IpAddr::V6(address) => tcp6_listener_process_ids(address, port), + }?; + process_ids.sort_unstable(); + process_ids.dedup(); + Ok(process_ids) +} + +#[cfg(windows)] +pub fn loopback_tcp_listener_process_ids(port: u16) -> std::io::Result> { + let mut process_ids = tcp4_listener_process_ids(Ipv4Addr::LOCALHOST, port)?; + process_ids.extend(tcp6_listener_process_ids(Ipv6Addr::LOCALHOST, port)?); + process_ids.sort_unstable(); + process_ids.dedup(); + Ok(process_ids) +} + +#[cfg(windows)] +fn tcp4_listener_process_ids(address: Ipv4Addr, port: u16) -> std::io::Result> { + let buffer = tcp_listener_table_buffer(AF_INET.0 as u32)?; + if buffer.is_empty() { + return Ok(Vec::new()); + } + let table = buffer.as_ptr().cast::(); + let count = unsafe { (*table).dwNumEntries as usize }; + validate_tcp_table_size( + buffer.len(), + count, + std::mem::size_of::(), + )?; + let rows = unsafe { + std::slice::from_raw_parts( + std::ptr::addr_of!((*table).table).cast::(), + count, + ) + }; + Ok(rows + .iter() + .filter(|row| { + network_port(row.dwLocalPort) == port && ipv4_listener_matches(row.dwLocalAddr, address) + }) + .map(|row| row.dwOwningPid) + .collect()) +} + +#[cfg(windows)] +fn tcp6_listener_process_ids(address: Ipv6Addr, port: u16) -> std::io::Result> { + let buffer = tcp_listener_table_buffer(AF_INET6.0 as u32)?; + if buffer.is_empty() { + return Ok(Vec::new()); + } + let table = buffer.as_ptr().cast::(); + let count = unsafe { (*table).dwNumEntries as usize }; + validate_tcp_table_size( + buffer.len(), + count, + std::mem::size_of::(), + )?; + let rows = unsafe { + std::slice::from_raw_parts( + std::ptr::addr_of!((*table).table).cast::(), + count, + ) + }; + Ok(rows + .iter() + .filter(|row| { + network_port(row.dwLocalPort) == port && ipv6_listener_matches(row.ucLocalAddr, address) + }) + .map(|row| row.dwOwningPid) + .collect()) +} + +#[cfg(windows)] +fn tcp_listener_table_buffer(address_family: u32) -> std::io::Result> { + let mut byte_len = 0u32; + let status = unsafe { + GetExtendedTcpTable( + None, + &mut byte_len, + false, + address_family, + TCP_TABLE_OWNER_PID_LISTENER, + 0, + ) + }; + if status == NO_ERROR.0 && byte_len == 0 { + return Ok(Vec::new()); + } + if status != ERROR_INSUFFICIENT_BUFFER.0 && status != NO_ERROR.0 { + return Err(std::io::Error::from_raw_os_error(status as i32)); + } + + for _ in 0..3 { + let word_len = (byte_len as usize).div_ceil(std::mem::size_of::()); + let mut buffer = vec![0u32; word_len]; + let status = unsafe { + GetExtendedTcpTable( + Some(buffer.as_mut_ptr().cast()), + &mut byte_len, + false, + address_family, + TCP_TABLE_OWNER_PID_LISTENER, + 0, + ) + }; + if status == NO_ERROR.0 { + return Ok(buffer); + } + if status != ERROR_INSUFFICIENT_BUFFER.0 { + return Err(std::io::Error::from_raw_os_error(status as i32)); + } + } + Err(std::io::Error::new( + std::io::ErrorKind::Other, + "TCP listener table kept changing while being read", + )) +} + +#[cfg(windows)] +fn validate_tcp_table_size( + buffer_words: usize, + entry_count: usize, + entry_size: usize, +) -> std::io::Result<()> { + let required = std::mem::size_of::() + .checked_add(entry_count.checked_mul(entry_size).ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidData, "TCP table size overflow") + })?) + .ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidData, "TCP table size overflow") + })?; + let available = buffer_words + .checked_mul(std::mem::size_of::()) + .unwrap_or(usize::MAX); + if required > available { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "TCP listener table is truncated", + )); + } + Ok(()) +} + +#[cfg(windows)] +fn network_port(value: u32) -> u16 { + u16::from_be(value as u16) +} + +#[cfg(windows)] +fn ipv4_listener_matches(value: u32, requested: Ipv4Addr) -> bool { + let local = Ipv4Addr::from(u32::from_be(value)); + local.is_unspecified() || local == requested +} + +#[cfg(windows)] +fn ipv6_listener_matches(value: [u8; 16], requested: Ipv6Addr) -> bool { + let local = Ipv6Addr::from(value); + local.is_unspecified() || local == requested +} + #[cfg(windows)] pub fn terminate_process(process_id: u32) -> bool { let Ok(handle) = (unsafe { @@ -347,6 +522,43 @@ pub fn terminate_process(process_id: u32) -> bool { unsafe { TerminateProcess(handle, 0) }.is_ok() } +#[cfg(windows)] +pub fn terminate_process_if_identity_matches( + process_id: u32, + expected_birth_id: u64, + expected_path: &Path, +) -> std::io::Result { + let handle = match unsafe { + OpenProcess( + PROCESS_TERMINATE | PROCESS_QUERY_LIMITED_INFORMATION, + false, + process_id, + ) + } { + Ok(handle) => handle, + Err(error) if error.code() == HRESULT::from_win32(ERROR_INVALID_PARAMETER.0) => { + return Ok(false); + } + Err(error) => return Err(std::io::Error::other(error.to_string())), + }; + if handle.is_invalid() { + return Ok(false); + } + let _guard = HandleGuard(handle); + let Some(birth_id) = process_birth_id_from_handle(handle) else { + return Ok(false); + }; + let Some(path) = query_process_image_path_from_handle(handle) else { + return Ok(false); + }; + if birth_id != expected_birth_id || !paths_equal_case_insensitive(&path, expected_path) { + return Ok(false); + } + unsafe { TerminateProcess(handle, 0) } + .map(|_| true) + .map_err(|error| std::io::Error::other(error.to_string())) +} + #[cfg(windows)] pub fn process_birth_id(process_id: u32) -> Option { let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, process_id).ok()? }; @@ -354,6 +566,11 @@ pub fn process_birth_id(process_id: u32) -> Option { return None; } let _guard = HandleGuard(handle); + process_birth_id_from_handle(handle) +} + +#[cfg(windows)] +fn process_birth_id_from_handle(handle: HANDLE) -> Option { let mut creation_time = FILETIME::default(); let mut exit_time = FILETIME::default(); let mut kernel_time = FILETIME::default(); @@ -421,6 +638,11 @@ fn query_process_image_path(process_id: u32) -> Option { return None; } let _guard = HandleGuard(handle); + query_process_image_path_from_handle(handle) +} + +#[cfg(windows)] +fn query_process_image_path_from_handle(handle: HANDLE) -> Option { let mut buffer = vec![0u16; MAX_PATH as usize * 4]; let mut len = buffer.len() as u32; unsafe { @@ -435,6 +657,12 @@ fn query_process_image_path(process_id: u32) -> Option { Some(PathBuf::from(OsString::from_wide(&buffer[..len as usize]))) } +#[cfg(windows)] +fn paths_equal_case_insensitive(left: &Path, right: &Path) -> bool { + left.to_string_lossy() + .eq_ignore_ascii_case(&right.to_string_lossy()) +} + #[cfg(windows)] fn visible_window_for_process(process_id: u32) -> Option { process_window(process_id, true) @@ -702,6 +930,7 @@ impl Drop for RegistryKeyGuard { #[cfg(all(test, windows))] mod tests { use super::*; + use std::net::TcpListener; #[test] fn application_window_outranks_titled_ime_and_tool_windows() { @@ -716,4 +945,44 @@ mod tests { assert_eq!(ime_score, tool_score); assert_eq!(auxiliary_app_score, ProcessWindowScore::Fallback); } + + #[test] + fn network_port_decodes_the_ip_helper_byte_order() { + assert_eq!(network_port(u16::to_be(9229) as u32), 9229); + } + + #[test] + fn tcp_listener_process_ids_finds_the_current_ipv4_listener() { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let address = listener.local_addr().unwrap(); + + let process_ids = tcp_listener_process_ids(address).unwrap(); + + assert!(process_ids.contains(&std::process::id())); + } + + #[test] + fn listener_address_matching_rejects_other_local_addresses() { + assert!(ipv4_listener_matches( + u32::from(Ipv4Addr::LOCALHOST).to_be(), + Ipv4Addr::LOCALHOST + )); + assert!(!ipv4_listener_matches( + u32::from(Ipv4Addr::new(127, 0, 0, 2)).to_be(), + Ipv4Addr::LOCALHOST + )); + assert!(ipv6_listener_matches( + Ipv6Addr::LOCALHOST.octets(), + Ipv6Addr::LOCALHOST + )); + } + + #[test] + fn identity_checked_termination_treats_a_missing_process_as_gone() { + let result = + terminate_process_if_identity_matches(u32::MAX, 1, Path::new(r"C:\missing\Codex.exe")) + .unwrap(); + + assert!(!result); + } } From 1c086f4dbd0150a03a4200cd75f833d7fc47d725 Mon Sep 17 00:00:00 2001 From: Yuimi_chaya <124485273+Yuimi-chaya@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:52:37 +0800 Subject: [PATCH 04/12] fix(core): recognize ChatGPT app CDP target --- crates/codex-plus-core/src/cdp.rs | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/crates/codex-plus-core/src/cdp.rs b/crates/codex-plus-core/src/cdp.rs index 7a292956c..46ab6e071 100644 --- a/crates/codex-plus-core/src/cdp.rs +++ b/crates/codex-plus-core/src/cdp.rs @@ -109,12 +109,8 @@ fn response_contains_codex_target(response: &[u8], debug_port: u16) -> bool { return false; }; targets.iter().any(|target| { - is_primary_codex_page_target(target) - && target - .url - .trim() - .to_ascii_lowercase() - .starts_with("app://-/") + is_injectable_page_target(target) + && is_exact_codex_app_main_target(target) && target .web_socket_debugger_url .as_deref() @@ -384,10 +380,10 @@ mod endpoint_tests { } #[test] - fn endpoint_address_returns_the_matching_loopback_family() { + fn endpoint_address_accepts_the_current_chatgpt_titled_app_main_target() { let (port, server) = serve_once(|port| { format!( - r#"[{{"id":"codex","type":"page","title":"Codex","url":"app://-/index.html","webSocketDebuggerUrl":"ws://127.0.0.1:{port}/devtools/page/1"}}]"# + r#"[{{"id":"codex","type":"page","title":"ChatGPT","url":"app://-/index.html","webSocketDebuggerUrl":"ws://127.0.0.1:{port}/devtools/page/1"}}]"# ) }); @@ -441,4 +437,16 @@ mod endpoint_tests { assert!(!endpoint_available(port)); server.join().unwrap(); } + + #[test] + fn endpoint_available_rejects_avatar_overlay_only_target() { + let (port, server) = serve_once(|port| { + format!( + r#"[{{"id":"avatar","type":"page","title":"ChatGPT","url":"app://-/index.html?initialRoute=%2Favatar-overlay","webSocketDebuggerUrl":"ws://127.0.0.1:{port}/devtools/page/1"}}]"# + ) + }); + + assert!(!endpoint_available(port)); + server.join().unwrap(); + } } From 6bdb240eefad44d6ebbef41d7302243f388c29f3 Mon Sep 17 00:00:00 2001 From: Yuimi_chaya <124485273+Yuimi-chaya@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:18:14 +0800 Subject: [PATCH 05/12] fix(core): stop legacy macOS launchers --- crates/codex-plus-core/src/watcher.rs | 66 ++++++++++++++++++++++----- 1 file changed, 54 insertions(+), 12 deletions(-) diff --git a/crates/codex-plus-core/src/watcher.rs b/crates/codex-plus-core/src/watcher.rs index 7f82aad80..6436f542d 100644 --- a/crates/codex-plus-core/src/watcher.rs +++ b/crates/codex-plus-core/src/watcher.rs @@ -16,6 +16,31 @@ const RESTART_STOP_WAIT_INTERVAL_MS: u64 = 100; pub const WATCHER_RUN_NAME: &str = "CodexPlusPlusWatcher"; pub const WATCHER_RUN_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Run"; pub const WATCHER_STARTUP_SHORTCUT_NAME: &str = "CodexPlusPlusWatcher.lnk"; +#[cfg(any(test, target_os = "macos"))] +const LEGACY_MACOS_LAUNCHER_BINARY: &str = "CodexPlusPlus"; + +#[cfg(any(test, target_os = "macos"))] +fn macos_launcher_binary_names() -> [&'static str; 2] { + [crate::install::SILENT_BINARY, LEGACY_MACOS_LAUNCHER_BINARY] +} + +#[cfg(any(test, target_os = "macos"))] +fn macos_launcher_process_ids_from_pgrep_outputs( + outputs: impl IntoIterator>, +) -> Vec { + let mut process_ids = outputs + .into_iter() + .flat_map(|output| { + String::from_utf8_lossy(&output) + .lines() + .filter_map(|value| value.trim().parse::().ok()) + .collect::>() + }) + .collect::>(); + process_ids.sort_unstable(); + process_ids.dedup(); + process_ids +} #[derive(Debug, Clone, PartialEq, Eq)] pub struct WatcherInstallPlan { @@ -281,6 +306,24 @@ mod process_identity_tests { assert_eq!(parse_ps_elapsed_seconds("invalid"), None); } + #[test] + fn macos_launcher_names_preserve_current_and_legacy_binaries() { + assert_eq!( + macos_launcher_binary_names(), + ["codex-plus-plus", "CodexPlusPlus"] + ); + } + + #[test] + fn macos_launcher_process_ids_merge_names_and_deduplicate() { + let process_ids = macos_launcher_process_ids_from_pgrep_outputs([ + b"20\n10\n".to_vec(), + b"30\n20\ninvalid\n".to_vec(), + ]); + + assert_eq!(process_ids, vec![10, 20, 30]); + } + #[cfg(windows)] #[test] fn current_windows_process_has_a_stable_birth_identity() { @@ -1239,18 +1282,17 @@ fn terminate_macos_process(process_id: u32) -> std::io::Result<()> { #[cfg(target_os = "macos")] fn find_launcher_processes() -> Vec { - std::process::Command::new("pgrep") - .args(["-x", crate::install::SILENT_BINARY]) - .output() - .ok() - .into_iter() - .flat_map(|output| { - String::from_utf8_lossy(&output.stdout) - .lines() - .filter_map(|value| value.trim().parse::().ok()) - .collect::>() - }) - .collect() + macos_launcher_process_ids_from_pgrep_outputs( + macos_launcher_binary_names() + .into_iter() + .filter_map(|binary| { + std::process::Command::new("pgrep") + .args(["-x", binary]) + .output() + .ok() + .map(|output| output.stdout) + }), + ) } #[cfg(target_os = "macos")] From d18a7edfc2db38731559af4c666e8f24d34e2eeb Mon Sep 17 00:00:00 2001 From: Yuimi_chaya <124485273+Yuimi-chaya@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:52:10 +0800 Subject: [PATCH 06/12] fix(manager): restart apps without cdp --- .../src-tauri/src/commands.rs | 75 ++++--- crates/codex-plus-core/src/watcher.rs | 201 ++++++++++++++---- 2 files changed, 197 insertions(+), 79 deletions(-) diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index d386d9c07..a5c23f03f 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -633,7 +633,8 @@ pub async fn restart_codex_plus(request: LaunchRequest) -> CommandResult let error_payload = request.clone(); match tauri::async_runtime::spawn_blocking(move || { let target_cdp_endpoint = codex_plus_core::cdp::endpoint_address(request.debug_port); - restart_codex_plus_blocking(request, target_cdp_endpoint) + let target_app_running = !codex_plus_core::watcher::find_codex_processes().is_empty(); + restart_codex_plus_blocking(request, target_cdp_endpoint, target_app_running) }) .await { @@ -655,8 +656,12 @@ enum RestartDisposition { StopAndRestart, } -fn restart_disposition(sync_active_relay: bool, target_cdp_reachable: bool) -> RestartDisposition { - if !sync_active_relay && !target_cdp_reachable { +fn restart_disposition( + sync_active_relay: bool, + target_cdp_reachable: bool, + target_app_running: bool, +) -> RestartDisposition { + if !sync_active_relay && !target_cdp_reachable && !target_app_running { RestartDisposition::LaunchOnly } else { RestartDisposition::StopAndRestart @@ -666,6 +671,7 @@ fn restart_disposition(sync_active_relay: bool, target_cdp_reachable: bool) -> R fn restart_codex_plus_blocking( request: LaunchRequest, target_cdp_endpoint: Option, + target_app_running: bool, ) -> CommandResult { let restart_started = Instant::now(); let _restart_guard = match try_acquire_restart_guard() { @@ -673,7 +679,11 @@ fn restart_codex_plus_blocking( Err(message) => return failed(message, json!({})), }; let target_cdp_reachable = target_cdp_endpoint.is_some(); - let disposition = restart_disposition(request.sync_active_relay, target_cdp_reachable); + let disposition = restart_disposition( + request.sync_active_relay, + target_cdp_reachable, + target_app_running, + ); let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( "manager.restart_requested", json!({ @@ -681,6 +691,7 @@ fn restart_codex_plus_blocking( "helper_port": request.helper_port, "app_path": request.app_path.trim(), "sync_active_relay": request.sync_active_relay, + "target_app_running": target_app_running, "target_cdp_reachable": target_cdp_reachable, "target_cdp_endpoint": target_cdp_endpoint.map(|endpoint| endpoint.to_string()), "disposition": match disposition { @@ -699,7 +710,7 @@ fn restart_codex_plus_blocking( ); return spawn_codex_plus_launch( request, - "目标 Codex App CDP 不可达,已按启动方式在后台尝试唤起。", + "未发现正在运行的目标 Codex App,已按启动方式在后台尝试唤起。", ); } @@ -772,6 +783,7 @@ fn restart_codex_plus_blocking( let targeted_stop_outcome = match codex_plus_core::watcher::stop_codex_processes_for_debug_port_and_wait( request.debug_port, + target_cdp_reachable, ) { Ok(outcome) => outcome, Err(message) => { @@ -813,11 +825,7 @@ fn restart_codex_plus_blocking( ); } let spawn_after_guard_release = move |request: &LaunchRequest| { - spawn_after_provider_sync_guard_release( - provider_sync_guard, - request, - spawn_silent_launcher, - ) + spawn_after_provider_sync_guard_release(provider_sync_guard, request, spawn_silent_launcher) }; match restart_codex_plus_after_stop( &request, @@ -5675,9 +5683,7 @@ fn try_acquire_restart_guard() -> Result, &'s Err(std::sync::TryLockError::WouldBlock) => { Err("已有 Codex++ 重启任务正在进行,请稍后再试。") } - Err(std::sync::TryLockError::Poisoned(_)) => { - Err("重启任务锁已损坏,请重启管理器后再试。") - } + Err(std::sync::TryLockError::Poisoned(_)) => Err("重启任务锁已损坏,请重启管理器后再试。"), } } @@ -6133,10 +6139,8 @@ fn wait_for_idle_provider_sync( } /// 在强杀 launcher 前放行或拦截本次重启,并把判定结果写进诊断日志。 -fn ensure_provider_sync_is_idle_before_stop() -> Result< - codex_plus_data::ProviderSyncLifecycleGuard, - String, -> { +fn ensure_provider_sync_is_idle_before_stop() +-> Result { let outcome = wait_for_idle_provider_sync( || codex_plus_data::try_acquire_provider_sync_lifecycle_guard(None), || codex_plus_data::inspect_provider_sync_lock(None), @@ -6344,15 +6348,23 @@ mod tests { #[test] fn ordinary_restart_uses_launch_path_when_target_app_is_absent() { assert_eq!( - restart_disposition(false, false), + restart_disposition(false, false, false), RestartDisposition::LaunchOnly ); } #[test] - fn ordinary_restart_stops_and_restarts_when_target_app_is_running() { + fn ordinary_restart_stops_and_restarts_when_target_cdp_is_reachable() { + assert_eq!( + restart_disposition(false, true, false), + RestartDisposition::StopAndRestart + ); + } + + #[test] + fn ordinary_restart_stops_and_restarts_when_native_app_is_running_without_cdp() { assert_eq!( - restart_disposition(false, true), + restart_disposition(false, false, true), RestartDisposition::StopAndRestart ); } @@ -6360,7 +6372,7 @@ mod tests { #[test] fn active_relay_restart_keeps_full_restart_when_target_app_is_absent() { assert_eq!( - restart_disposition(true, false), + restart_disposition(true, false, false), RestartDisposition::StopAndRestart ); } @@ -6388,6 +6400,7 @@ mod tests { .expect("provider sync guard wait"); assert!(async_body.contains("codex_plus_core::cdp::endpoint_address(request.debug_port)")); + assert!(async_body.contains("codex_plus_core::watcher::find_codex_processes()")); assert!(!async_body.contains("watcher::cdp_listening")); assert!(launch_only < provider_guard); assert!(body[launch_only..provider_guard].contains("spawn_codex_plus_launch")); @@ -6548,8 +6561,8 @@ mod tests { #[test] fn restart_releases_provider_guard_before_spawning() { let temp = tempfile::tempdir().unwrap(); - let guard = codex_plus_data::try_acquire_provider_sync_lifecycle_guard(Some(temp.path())) - .unwrap(); + let guard = + codex_plus_data::try_acquire_provider_sync_lifecycle_guard(Some(temp.path())).unwrap(); let spawned = std::cell::Cell::new(false); spawn_after_provider_sync_guard_release(guard, &launch_request(false), |_| { @@ -6571,8 +6584,8 @@ mod tests { #[test] fn restart_does_not_spawn_when_provider_guard_release_fails() { let temp = tempfile::tempdir().unwrap(); - let guard = codex_plus_data::try_acquire_provider_sync_lifecycle_guard(Some(temp.path())) - .unwrap(); + let guard = + codex_plus_data::try_acquire_provider_sync_lifecycle_guard(Some(temp.path())).unwrap(); std::fs::write( temp.path().join("tmp/provider-sync.lock/owner.json"), json!({ @@ -6585,14 +6598,10 @@ mod tests { .unwrap(); let spawned = std::cell::Cell::new(false); - let error = spawn_after_provider_sync_guard_release( - guard, - &launch_request(false), - |_| { - spawned.set(true); - Ok(()) - }, - ) + let error = spawn_after_provider_sync_guard_release(guard, &launch_request(false), |_| { + spawned.set(true); + Ok(()) + }) .unwrap_err(); assert!(!spawned.get()); diff --git a/crates/codex-plus-core/src/watcher.rs b/crates/codex-plus-core/src/watcher.rs index 6436f542d..3f602db78 100644 --- a/crates/codex-plus-core/src/watcher.rs +++ b/crates/codex-plus-core/src/watcher.rs @@ -403,6 +403,40 @@ mod process_identity_tests { assert_eq!(selected, vec![12, 11, 10]); } + #[cfg(windows)] + #[test] + fn native_stop_selects_a_directly_started_chatgpt_tree_without_cdp() { + let root = windows_process( + 10, + 1, + "ChatGPT.exe", + r"C:\Program Files\WindowsApps\OpenAI.Codex_26.820.7780.0_x64__2p2nqsd0c76g0\app\ChatGPT.exe", + ); + let renderer = windows_process( + 11, + 10, + "ChatGPT.exe", + r"C:\Program Files\WindowsApps\OpenAI.Codex_26.820.7780.0_x64__2p2nqsd0c76g0\app\ChatGPT.exe", + ); + let cli = windows_process( + 20, + 10, + "codex.exe", + r"C:\Users\test\AppData\Local\OpenAI\Codex\bin\build\codex.exe", + ); + let processes = [root, renderer, cli]; + let native_process_ids = find_codex_processes_from_snapshot(&processes); + + let selected = target_codex_process_tree_from_snapshot( + &processes, + &native_process_ids, + &HashMap::from([(10, 100), (11, 110), (20, 120)]), + ); + + assert_eq!(native_process_ids, vec![10, 11]); + assert_eq!(selected, vec![11, 10]); + } + #[cfg(windows)] #[test] fn targeted_stop_refuses_an_untrusted_listener_owner() { @@ -617,6 +651,7 @@ pub struct WindowsCodexStopPlan { debug_port: u16, endpoint: Option, listener_process_ids: Vec, + native_app_process_ids: Vec, target_processes: Vec, } @@ -978,14 +1013,25 @@ pub fn stop_codex_processes_and_wait() {} #[cfg(target_os = "macos")] pub fn stop_codex_processes_for_debug_port_and_wait( debug_port: u16, + target_cdp_reachable: bool, ) -> Result { - let process_ids = find_macos_codex_processes_for_debug_port(debug_port); + let process_ids = if target_cdp_reachable { + find_macos_codex_processes_for_debug_port(debug_port) + } else { + find_codex_processes() + }; if process_ids.is_empty() { return Ok(TargetedStopOutcome::AlreadyAbsent); } if terminate_macos_processes_and_wait( process_ids, - || find_macos_codex_processes_for_debug_port(debug_port), + || { + if target_cdp_reachable { + find_macos_codex_processes_for_debug_port(debug_port) + } else { + find_codex_processes() + } + }, RESTART_STOP_WAIT_TIMEOUT_MS, RESTART_STOP_WAIT_INTERVAL_MS, ) { @@ -1012,6 +1058,7 @@ pub fn prepare_windows_codex_stop_plan( debug_port, endpoint: None, listener_process_ids: Vec::new(), + native_app_process_ids: Vec::new(), target_processes: Vec::new(), }); } @@ -1034,54 +1081,90 @@ pub fn prepare_windows_codex_stop_plan( "目标 Codex App CDP 不可确认,但调试端口仍有监听,已拒绝停止。".to_string(), ); } + Vec::new() + }; + if endpoint.is_some() && listener_process_ids.len() != 1 { + return Err("目标 Codex App 调试 endpoint 对应多个进程,已拒绝停止。".to_string()); + } + + let processes = crate::windows_integration::enumerate_processes(); + let native_app_process_ids = if endpoint.is_none() { + find_codex_processes_from_snapshot(&processes) + } else { + Vec::new() + }; + if endpoint.is_none() && native_app_process_ids.is_empty() { return Ok(WindowsCodexStopPlan { debug_port, endpoint: None, listener_process_ids: Vec::new(), + native_app_process_ids, target_processes: Vec::new(), }); - }; - if listener_process_ids.len() != 1 { - return Err("目标 Codex App 调试 endpoint 对应多个进程,已拒绝停止。".to_string()); } - - let processes = crate::windows_integration::enumerate_processes(); - let owner_process_id = listener_process_ids[0]; + let target_seed_process_ids = if endpoint.is_some() { + &listener_process_ids + } else { + &native_app_process_ids + }; + let owner_process_id = target_seed_process_ids[0]; let owner = processes .iter() .find(|process| process.process_id == owner_process_id) - .ok_or_else(|| "目标 Codex App 端口进程已退出,已拒绝按旧 PID 停止。".to_string())?; + .ok_or_else(|| "目标 Codex App 进程已退出,已拒绝按旧 PID 停止。".to_string())?; let owner_path = owner .executable_path .as_deref() .ok_or_else(|| "无法读取目标 Codex App 可执行路径,已拒绝停止。".to_string())?; let identity_root = process_identity_root(owner_path); + if endpoint.is_none() { + let identity_roots = native_app_process_ids + .iter() + .map(|process_id| { + processes + .iter() + .find(|process| process.process_id == *process_id) + .and_then(|process| process.executable_path.as_deref()) + .map(process_identity_root) + .ok_or_else(|| { + format!( + "无法读取目标 Codex App 进程 {process_id} 的可执行路径,已拒绝停止。" + ) + }) + }) + .collect::, String>>()?; + if identity_roots.len() != 1 || !identity_roots.contains(&identity_root) { + return Err("发现多个 Codex App 安装实例,无法安全判断应重启哪一个。".to_string()); + } + } let processes_by_id = processes .iter() .map(|process| (process.process_id, process)) .collect::>(); - let mut ancestor_cursor = owner_process_id; - let mut visited_ancestors = HashSet::new(); - while visited_ancestors.insert(ancestor_cursor) { - let Some(process) = processes_by_id.get(&ancestor_cursor) else { - break; - }; - let Some(parent) = processes_by_id.get(&process.parent_process_id) else { - break; - }; - let Some(parent_path) = parent.executable_path.as_deref() else { - if crate::app_paths::is_supported_app_executable_name(&parent.exe_file) { - return Err(format!( - "无法读取目标候选父进程 {} 的可执行路径,已拒绝生成部分停止计划。", - parent.process_id - )); + for target_seed_process_id in target_seed_process_ids { + let mut ancestor_cursor = *target_seed_process_id; + let mut visited_ancestors = HashSet::new(); + while visited_ancestors.insert(ancestor_cursor) { + let Some(process) = processes_by_id.get(&ancestor_cursor) else { + break; + }; + let Some(parent) = processes_by_id.get(&process.parent_process_id) else { + break; + }; + let Some(parent_path) = parent.executable_path.as_deref() else { + if crate::app_paths::is_supported_app_executable_name(&parent.exe_file) { + return Err(format!( + "无法读取目标候选父进程 {} 的可执行路径,已拒绝生成部分停止计划。", + parent.process_id + )); + } + break; + }; + if process_identity_root(parent_path) != identity_root { + break; } - break; - }; - if process_identity_root(parent_path) != identity_root { - break; + ancestor_cursor = parent.process_id; } - ancestor_cursor = parent.process_id; } let birth_ids = collect_identity_birth_ids(&processes, &identity_root, |process_id| { crate::windows_integration::process_birth_id(process_id) @@ -1090,20 +1173,29 @@ pub fn prepare_windows_codex_stop_plan( format!("无法读取目标同包进程 {process_id} 的创建时间,已拒绝生成部分停止计划。") })?; let target_process_ids = - target_codex_process_tree_from_snapshot(&processes, &listener_process_ids, &birth_ids); + target_codex_process_tree_from_snapshot(&processes, target_seed_process_ids, &birth_ids); if target_process_ids.is_empty() { - return Err("目标调试端口不属于受支持的 Codex App 进程树,已拒绝停止。".to_string()); + return Err("目标不属于受支持的 Codex App 进程树,已拒绝停止。".to_string()); } - let root_process_id = *target_process_ids - .last() - .ok_or_else(|| "目标 Codex App 停止计划缺少根进程。".to_string())?; let parents = processes .iter() .map(|process| (process.process_id, process.parent_process_id)) .collect::>(); + let target_process_id_set = target_process_ids.iter().copied().collect::>(); + let root_process_ids = target_process_ids + .iter() + .copied() + .filter(|process_id| { + parents + .get(process_id) + .is_none_or(|parent| !target_process_id_set.contains(parent)) + }) + .collect::>(); if let Some(process) = processes.iter().find(|process| { process.executable_path.is_none() - && process_descends_from(process.process_id, root_process_id, &parents) + && root_process_ids.iter().any(|root_process_id| { + process_descends_from(process.process_id, *root_process_id, &parents) + }) }) { return Err(format!( "无法读取目标进程树内 PID {} 的可执行路径,已拒绝生成部分停止计划。", @@ -1137,6 +1229,7 @@ pub fn prepare_windows_codex_stop_plan( debug_port, endpoint, listener_process_ids, + native_app_process_ids, target_processes, }) } @@ -1149,29 +1242,41 @@ pub fn execute_windows_codex_stop_plan( let listeners = crate::windows_integration::loopback_tcp_listener_process_ids(plan.debug_port) .map_err(|error| format!("复核 Codex App 调试端口失败:{error}"))?; - return if listeners.is_empty() { + let native_app_process_ids = find_codex_processes(); + return if listeners.is_empty() && native_app_process_ids.is_empty() { Ok(TargetedStopOutcome::AlreadyAbsent) } else { - Err("Codex App 在停止 Launcher 后重新占用了调试端口,已中止重启。".to_string()) + Err("Codex App 在停止 Launcher 后重新出现,已中止重启。".to_string()) }; } - let endpoint = plan - .endpoint - .ok_or_else(|| "安全停止计划缺少目标 endpoint。".to_string())?; - let current_listener_process_ids = - crate::windows_integration::loopback_tcp_listener_process_ids(plan.debug_port) - .map_err(|error| format!("停止前复核目标 Codex App 端口归属失败:{error}"))?; - if !listener_owners_unchanged(&plan.listener_process_ids, ¤t_listener_process_ids) { - return Err("目标 Codex App 端口归属在停止前发生变化,已中止重启。".to_string()); + if plan.endpoint.is_some() { + let current_listener_process_ids = + crate::windows_integration::loopback_tcp_listener_process_ids(plan.debug_port) + .map_err(|error| format!("停止前复核目标 Codex App 端口归属失败:{error}"))?; + if !listener_owners_unchanged(&plan.listener_process_ids, ¤t_listener_process_ids) { + return Err("目标 Codex App 端口归属在停止前发生变化,已中止重启。".to_string()); + } + } else { + let current_native_app_process_ids = find_codex_processes(); + if current_native_app_process_ids != plan.native_app_process_ids { + return Err("目标 Codex App 进程集合在停止前发生变化,已中止重启。".to_string()); + } + let current_listener_process_ids = + crate::windows_integration::loopback_tcp_listener_process_ids(plan.debug_port) + .map_err(|error| format!("停止前复核 Codex App 调试端口失败:{error}"))?; + if !current_listener_process_ids.is_empty() { + return Err("目标 Codex App 在停止前新建了调试端口,已中止重启。".to_string()); + } } let _ = crate::diagnostic_log::append_diagnostic_log( "watcher.targeted_stop_started", serde_json::json!({ "debug_port": plan.debug_port, - "endpoint": endpoint.to_string(), + "endpoint": plan.endpoint.map(|endpoint| endpoint.to_string()), "listener_process_ids": &plan.listener_process_ids, + "native_app_process_ids": &plan.native_app_process_ids, "target_process_ids": plan.target_processes.iter().map(|process| process.process_id).collect::>(), }), ); @@ -1224,12 +1329,16 @@ pub fn execute_windows_codex_stop_plan( if !remaining_listeners.is_empty() { return Err("目标 Codex App 停止后调试 endpoint 仍被占用,已中止重启。".to_string()); } + if plan.endpoint.is_none() && !find_codex_processes().is_empty() { + return Err("目标 Codex App 停止后仍有受支持的 App 进程,已中止重启。".to_string()); + } Ok(TargetedStopOutcome::Stopped) } #[cfg(not(any(windows, target_os = "macos")))] pub fn stop_codex_processes_for_debug_port_and_wait( _debug_port: u16, + _target_cdp_reachable: bool, ) -> Result { Ok(TargetedStopOutcome::AlreadyAbsent) } From 7e40c00fc58cce185a5bfc9b9def0d8f513602c5 Mon Sep 17 00:00:00 2001 From: Yuimi_chaya <124485273+Yuimi-chaya@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:44:54 +0800 Subject: [PATCH 07/12] refactor(manager): simplify native restart lifecycle --- .../src-tauri/src/commands.rs | 305 ++----- crates/codex-plus-core/Cargo.toml | 2 - crates/codex-plus-core/src/cdp.rs | 26 +- crates/codex-plus-core/src/watcher.rs | 765 +----------------- .../src/windows_integration.rs | 275 +------ 5 files changed, 90 insertions(+), 1283 deletions(-) diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index a5c23f03f..7a1405ea2 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -631,13 +631,7 @@ pub fn launch_codex_plus(request: LaunchRequest) -> CommandResult { #[tauri::command] pub async fn restart_codex_plus(request: LaunchRequest) -> CommandResult { let error_payload = request.clone(); - match tauri::async_runtime::spawn_blocking(move || { - let target_cdp_endpoint = codex_plus_core::cdp::endpoint_address(request.debug_port); - let target_app_running = !codex_plus_core::watcher::find_codex_processes().is_empty(); - restart_codex_plus_blocking(request, target_cdp_endpoint, target_app_running) - }) - .await - { + match tauri::async_runtime::spawn_blocking(move || restart_codex_plus_blocking(request)).await { Ok(result) => result, Err(error) => failed( &format!("重启 Codex++ 后台任务失败:{error}"), @@ -656,34 +650,22 @@ enum RestartDisposition { StopAndRestart, } -fn restart_disposition( - sync_active_relay: bool, - target_cdp_reachable: bool, - target_app_running: bool, -) -> RestartDisposition { - if !sync_active_relay && !target_cdp_reachable && !target_app_running { +fn restart_disposition(sync_active_relay: bool, target_app_running: bool) -> RestartDisposition { + if !sync_active_relay && !target_app_running { RestartDisposition::LaunchOnly } else { RestartDisposition::StopAndRestart } } -fn restart_codex_plus_blocking( - request: LaunchRequest, - target_cdp_endpoint: Option, - target_app_running: bool, -) -> CommandResult { +fn restart_codex_plus_blocking(request: LaunchRequest) -> CommandResult { let restart_started = Instant::now(); let _restart_guard = match try_acquire_restart_guard() { Ok(guard) => guard, Err(message) => return failed(message, json!({})), }; - let target_cdp_reachable = target_cdp_endpoint.is_some(); - let disposition = restart_disposition( - request.sync_active_relay, - target_cdp_reachable, - target_app_running, - ); + let target_app_running = !codex_plus_core::watcher::find_codex_processes().is_empty(); + let disposition = restart_disposition(request.sync_active_relay, target_app_running); let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( "manager.restart_requested", json!({ @@ -692,8 +674,6 @@ fn restart_codex_plus_blocking( "app_path": request.app_path.trim(), "sync_active_relay": request.sync_active_relay, "target_app_running": target_app_running, - "target_cdp_reachable": target_cdp_reachable, - "target_cdp_endpoint": target_cdp_endpoint.map(|endpoint| endpoint.to_string()), "disposition": match disposition { RestartDisposition::LaunchOnly => "launch_only", RestartDisposition::StopAndRestart => "stop_and_restart", @@ -754,16 +734,6 @@ fn restart_codex_plus_blocking( } else { None }; - #[cfg(windows)] - let target_stop_plan = match codex_plus_core::watcher::prepare_windows_codex_stop_plan( - request.debug_port, - target_cdp_endpoint, - ) { - Ok(plan) => plan, - Err(message) => { - return failed(&format!("重启 Codex++ 已安全中止:{message}"), json!({})); - } - }; if let Err(message) = codex_plus_core::watcher::stop_launcher_processes_and_wait() { return failed(&format!("重启 Codex++ 已安全中止:{message}"), json!({})); } @@ -771,34 +741,16 @@ fn restart_codex_plus_blocking( "manager.restart_launchers_stopped", json!({ "total_elapsed_ms": restart_started.elapsed().as_millis() }), ); - #[cfg(windows)] let targeted_stop_outcome = - match codex_plus_core::watcher::execute_windows_codex_stop_plan(target_stop_plan) { + match codex_plus_core::watcher::stop_codex_processes_for_restart_and_wait() { Ok(outcome) => outcome, Err(message) => { return failed(&format!("重启 Codex++ 已安全中止:{message}"), json!({})); } }; - #[cfg(not(windows))] - let targeted_stop_outcome = - match codex_plus_core::watcher::stop_codex_processes_for_debug_port_and_wait( - request.debug_port, - target_cdp_reachable, - ) { - Ok(outcome) => outcome, - Err(message) => { - return failed(&format!("重启 Codex++ 已安全中止:{message}"), json!({})); - } - }; - #[cfg(windows)] - let targeted_stop_outcome = match targeted_stop_outcome { - codex_plus_core::watcher::TargetedStopOutcome::Stopped => "stopped", - codex_plus_core::watcher::TargetedStopOutcome::AlreadyAbsent => "already_absent", - }; - #[cfg(not(windows))] let targeted_stop_outcome = match targeted_stop_outcome { - codex_plus_core::watcher::TargetedStopOutcome::Stopped => "stopped", - codex_plus_core::watcher::TargetedStopOutcome::AlreadyAbsent => "already_absent", + codex_plus_core::watcher::CodexStopOutcome::Stopped => "stopped", + codex_plus_core::watcher::CodexStopOutcome::AlreadyAbsent => "already_absent", }; let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( "manager.restart_codex_stopped", @@ -6090,79 +6042,27 @@ 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; - -#[derive(Debug)] -enum ProviderSyncWaitFailure { - Busy(codex_plus_data::ProviderSyncLockState), - Acquire(std::io::Error), -} - -/// 等待并原子取得 provider-sync 生命周期 guard。 -/// -/// guard 同时持有 OS 文件锁与兼容旧版本的目录锁。取得后,旧/新 launcher 都不能在 -/// 最终检查和停止进程之间启动新的 provider sync,关闭先检查后强杀的竞态(issue #1901)。 -/// 等不到就拒绝本次重启,而不是把同步中的 launcher 打断。 -fn wait_for_idle_provider_sync( - try_acquire: impl Fn() -> std::io::Result, - inspect: impl Fn() -> codex_plus_data::ProviderSyncLockState, - sleep: impl Fn(u64), - timeout_ms: u64, -) -> Result { - use codex_plus_data::ProviderSyncLockState; - - let mut waited_ms = 0; - loop { - match try_acquire() { - Ok(guard) => return Ok(guard), - Err(error) - if matches!( - error.kind(), - std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::WouldBlock - ) => - { - if waited_ms >= timeout_ms { - let state = match inspect() { - ProviderSyncLockState::Free => ProviderSyncLockState::Indeterminate, - state => state, - }; - return Err(ProviderSyncWaitFailure::Busy(state)); - } - } - Err(error) => return Err(ProviderSyncWaitFailure::Acquire(error)), - } - sleep(PROVIDER_SYNC_WAIT_INTERVAL_MS); - waited_ms += PROVIDER_SYNC_WAIT_INTERVAL_MS; - } -} - -/// 在强杀 launcher 前放行或拦截本次重启,并把判定结果写进诊断日志。 +/// 原子取得 provider-sync 生命周期 guard;同步正忙时立即拒绝重启。 fn ensure_provider_sync_is_idle_before_stop() -> Result { - let outcome = wait_for_idle_provider_sync( - || codex_plus_data::try_acquire_provider_sync_lifecycle_guard(None), - || 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 { + match codex_plus_data::try_acquire_provider_sync_lifecycle_guard(None) { Ok(guard) => Ok(guard), - Err(ProviderSyncWaitFailure::Busy(state)) => { + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::WouldBlock + ) => + { + let state = codex_plus_data::inspect_provider_sync_lock(None); 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, - }), + json!({ "state": state }), ); Err(format!( - "历史会话同步正在进行中(已等待 {} 秒)。为避免中断同步导致会话丢失,本次重启未执行;请等待同步完成后重试。", - PROVIDER_SYNC_WAIT_TIMEOUT_MS / 1000 + "历史会话同步正在进行中。为避免中断同步导致会话丢失,本次重启未执行;请稍后重试。" )) } - Err(ProviderSyncWaitFailure::Acquire(error)) => { + Err(error) => { let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( "manager.restart_provider_sync_guard_failed", json!({ "message": error.to_string() }), @@ -6348,23 +6248,15 @@ mod tests { #[test] fn ordinary_restart_uses_launch_path_when_target_app_is_absent() { assert_eq!( - restart_disposition(false, false, false), + restart_disposition(false, false), RestartDisposition::LaunchOnly ); } #[test] - fn ordinary_restart_stops_and_restarts_when_target_cdp_is_reachable() { + fn ordinary_restart_stops_and_restarts_when_target_app_is_running() { assert_eq!( - restart_disposition(false, true, false), - RestartDisposition::StopAndRestart - ); - } - - #[test] - fn ordinary_restart_stops_and_restarts_when_native_app_is_running_without_cdp() { - assert_eq!( - restart_disposition(false, false, true), + restart_disposition(false, true), RestartDisposition::StopAndRestart ); } @@ -6372,7 +6264,7 @@ mod tests { #[test] fn active_relay_restart_keeps_full_restart_when_target_app_is_absent() { assert_eq!( - restart_disposition(true, false, false), + restart_disposition(true, false), RestartDisposition::StopAndRestart ); } @@ -6397,17 +6289,17 @@ mod tests { .expect("restart-as-launch branch"); let provider_guard = body .find("ensure_provider_sync_is_idle_before_stop") - .expect("provider sync guard wait"); + .expect("provider sync guard"); - assert!(async_body.contains("codex_plus_core::cdp::endpoint_address(request.debug_port)")); - assert!(async_body.contains("codex_plus_core::watcher::find_codex_processes()")); - assert!(!async_body.contains("watcher::cdp_listening")); + assert!(async_body.contains("spawn_blocking")); + assert!(body.contains("codex_plus_core::watcher::find_codex_processes()")); + assert!(!body.contains("codex_plus_core::cdp")); assert!(launch_only < provider_guard); assert!(body[launch_only..provider_guard].contains("spawn_codex_plus_launch")); } #[test] - fn full_restart_prepares_and_revalidates_the_target_before_live_sync() { + fn full_restart_stops_native_app_before_live_sync() { let source = include_str!("commands.rs"); let start = source .find("fn restart_codex_plus_blocking") @@ -6417,38 +6309,37 @@ mod tests { .map(|offset| start + offset) .expect("restart helper boundary"); let body = &source[start..end]; - let prepare = body - .find("prepare_windows_codex_stop_plan") - .expect("target identity capture"); let stop_launcher = body .find("stop_launcher_processes_and_wait") .expect("launcher stop"); - let execute = body - .find("execute_windows_codex_stop_plan") - .expect("target identity recheck and stop"); + let stop_app = body + .find("stop_codex_processes_for_restart_and_wait") + .expect("native app stop"); let live_sync = body .find("restart_codex_plus_after_stop") .unwrap_or(body.len()); - assert!(prepare < stop_launcher); - assert!(stop_launcher < execute); - assert!(execute < live_sync); - assert!(body[execute..live_sync].contains("return failed")); + assert!(stop_launcher < stop_app); + assert!(stop_app < live_sync); + assert!(body[stop_app..live_sync].contains("return failed")); } #[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( - || Ok(()), - || codex_plus_data::ProviderSyncLockState::Free, - |ms| slept.set(slept.get() + ms), - PROVIDER_SYNC_WAIT_TIMEOUT_MS, - ); + fn restart_provider_sync_guard_is_fail_fast() { + let source = include_str!("commands.rs"); + let start = source + .find("fn ensure_provider_sync_is_idle_before_stop") + .expect("provider sync guard"); + let end = source[start..] + .find("fn default_debug_port") + .map(|offset| start + offset) + .expect("provider sync guard boundary"); + let body = &source[start..end]; - assert!(outcome.is_ok()); - assert_eq!(slept.get(), 0); + assert!(body.contains("try_acquire_provider_sync_lifecycle_guard")); + assert!(!body.contains("sleep")); + assert!(!body.contains("WAIT_TIMEOUT")); + assert!(!body.contains("wait_for_idle_provider_sync")); } #[test] @@ -6462,102 +6353,6 @@ mod tests { assert!(try_acquire_restart_guard().is_ok()); } - #[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( - || Ok(()), - || 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); - } - - #[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 { - Err(std::io::Error::from(std::io::ErrorKind::WouldBlock)) - } else { - Ok(()) - } - }, - || codex_plus_data::ProviderSyncLockState::Held { - pid: 4321, - started_at: 1234, - }, - |_| {}, - PROVIDER_SYNC_WAIT_TIMEOUT_MS, - ); - - assert!(outcome.is_ok()); - assert_eq!(polls.get(), 3); - } - - /// 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( - || Err::<(), _>(std::io::Error::from(std::io::ErrorKind::WouldBlock)), - || held.clone(), - |_| {}, - PROVIDER_SYNC_WAIT_TIMEOUT_MS, - ); - - assert!(matches!( - outcome, - Err(ProviderSyncWaitFailure::Busy(state)) if state == held - )); - } - - #[test] - fn restart_is_refused_while_the_lock_owner_cannot_be_determined() { - let outcome = wait_for_idle_provider_sync( - || Err::<(), _>(std::io::Error::from(std::io::ErrorKind::AlreadyExists)), - || codex_plus_data::ProviderSyncLockState::Indeterminate, - |_| {}, - PROVIDER_SYNC_WAIT_TIMEOUT_MS, - ); - - assert!(matches!( - outcome, - Err(ProviderSyncWaitFailure::Busy( - codex_plus_data::ProviderSyncLockState::Indeterminate - )) - )); - } - - #[test] - fn restart_reports_non_contention_guard_errors_without_waiting() { - let slept = std::cell::Cell::new(0); - let outcome = wait_for_idle_provider_sync( - || Err::<(), _>(std::io::Error::from(std::io::ErrorKind::PermissionDenied)), - || codex_plus_data::ProviderSyncLockState::Free, - |ms| slept.set(slept.get() + ms), - PROVIDER_SYNC_WAIT_TIMEOUT_MS, - ); - - assert!(matches!( - outcome, - Err(ProviderSyncWaitFailure::Acquire(error)) - if error.kind() == std::io::ErrorKind::PermissionDenied - )); - assert_eq!(slept.get(), 0); - } - #[test] fn restart_releases_provider_guard_before_spawning() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/codex-plus-core/Cargo.toml b/crates/codex-plus-core/Cargo.toml index 7b8175087..5672ba624 100644 --- a/crates/codex-plus-core/Cargo.toml +++ b/crates/codex-plus-core/Cargo.toml @@ -31,8 +31,6 @@ zstd.workspace = true [target.'cfg(windows)'.dependencies] windows = { version = "0.58", features = [ "Win32_Foundation", - "Win32_NetworkManagement_IpHelper", - "Win32_Networking_WinSock", "Win32_Storage_EnhancedStorage", "Win32_Storage_FileSystem", "Win32_Storage_Packaging_Appx", diff --git a/crates/codex-plus-core/src/cdp.rs b/crates/codex-plus-core/src/cdp.rs index 46ab6e071..5a1d17035 100644 --- a/crates/codex-plus-core/src/cdp.rs +++ b/crates/codex-plus-core/src/cdp.rs @@ -44,18 +44,13 @@ impl CdpBrowserIdentity { } /// Returns whether the requested loopback port exposes the main Codex App CDP target. -pub fn endpoint_available(debug_port: u16) -> bool { - endpoint_address(debug_port).is_some() -} - -/// Returns the concrete loopback endpoint that exposes the main Codex App CDP target. -pub fn endpoint_address(debug_port: u16) -> Option { +pub(crate) fn endpoint_available(debug_port: u16) -> bool { [ SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), debug_port), SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), debug_port), ] .into_iter() - .find(|address| probe_endpoint(*address, debug_port)) + .any(|address| probe_endpoint(address, debug_port)) } fn probe_endpoint(address: SocketAddr, debug_port: u16) -> bool { @@ -110,7 +105,7 @@ fn response_contains_codex_target(response: &[u8], debug_port: u16) -> bool { }; targets.iter().any(|target| { is_injectable_page_target(target) - && is_exact_codex_app_main_target(target) + && is_codex_app_page_target(target) && target .web_socket_debugger_url .as_deref() @@ -380,17 +375,14 @@ mod endpoint_tests { } #[test] - fn endpoint_address_accepts_the_current_chatgpt_titled_app_main_target() { + fn endpoint_available_accepts_the_current_chatgpt_titled_app_main_target() { let (port, server) = serve_once(|port| { format!( r#"[{{"id":"codex","type":"page","title":"ChatGPT","url":"app://-/index.html","webSocketDebuggerUrl":"ws://127.0.0.1:{port}/devtools/page/1"}}]"# ) }); - assert_eq!( - endpoint_address(port), - Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port)) - ); + assert!(endpoint_available(port)); server.join().unwrap(); } @@ -427,26 +419,26 @@ mod endpoint_tests { } #[test] - fn endpoint_available_rejects_quick_chat_only_target() { + fn endpoint_available_accepts_quick_chat_app_target() { let (port, server) = serve_once(|port| { format!( r#"[{{"id":"quick-chat","type":"page","title":"Codex","url":"app://-/index.html?initialRoute=%2Fchatgpt%2Fquick-chat-prewarm","webSocketDebuggerUrl":"ws://127.0.0.1:{port}/devtools/page/1"}}]"# ) }); - assert!(!endpoint_available(port)); + assert!(endpoint_available(port)); server.join().unwrap(); } #[test] - fn endpoint_available_rejects_avatar_overlay_only_target() { + fn endpoint_available_accepts_avatar_overlay_app_target() { let (port, server) = serve_once(|port| { format!( r#"[{{"id":"avatar","type":"page","title":"ChatGPT","url":"app://-/index.html?initialRoute=%2Favatar-overlay","webSocketDebuggerUrl":"ws://127.0.0.1:{port}/devtools/page/1"}}]"# ) }); - assert!(!endpoint_available(port)); + assert!(endpoint_available(port)); server.join().unwrap(); } } diff --git a/crates/codex-plus-core/src/watcher.rs b/crates/codex-plus-core/src/watcher.rs index 3f602db78..d4a018ede 100644 --- a/crates/codex-plus-core/src/watcher.rs +++ b/crates/codex-plus-core/src/watcher.rs @@ -338,174 +338,6 @@ mod process_identity_tests { assert!(started_at_secs.is_some()); assert!(birth_id.is_some()); } - - #[cfg(windows)] - fn windows_process( - process_id: u32, - parent_process_id: u32, - exe_file: &str, - executable_path: &str, - ) -> WindowsProcessInfo { - WindowsProcessInfo { - process_id, - parent_process_id, - exe_file: exe_file.to_string(), - executable_path: Some(PathBuf::from(executable_path)), - } - } - - #[cfg(windows)] - #[test] - fn targeted_stop_selects_only_the_listener_codex_process_tree() { - let target_root = windows_process( - 10, - 1, - "ChatGPT.exe", - r"C:\Program Files\WindowsApps\OpenAI.Codex_26.820.7780.0_x64__2p2nqsd0c76g0\app\ChatGPT.exe", - ); - let target_child = windows_process( - 11, - 10, - "ChatGPT.exe", - r"C:\Program Files\WindowsApps\OpenAI.Codex_26.820.7780.0_x64__2p2nqsd0c76g0\app\ChatGPT.exe", - ); - let target_helper = windows_process( - 12, - 11, - "codex-code-mode-host.exe", - r"C:\Program Files\WindowsApps\OpenAI.Codex_26.820.7780.0_x64__2p2nqsd0c76g0\app\codex-code-mode-host.exe", - ); - let unrelated_root = windows_process( - 20, - 1, - "ChatGPT.exe", - r"C:\Program Files\WindowsApps\OpenAI.Codex_26.821.1.0_x64__2p2nqsd0c76g0\app\ChatGPT.exe", - ); - let unrelated_child = windows_process( - 21, - 20, - "ChatGPT.exe", - r"C:\Program Files\WindowsApps\OpenAI.Codex_26.821.1.0_x64__2p2nqsd0c76g0\app\ChatGPT.exe", - ); - - let selected = target_codex_process_tree_from_snapshot( - &[ - target_root, - target_child, - target_helper, - unrelated_root, - unrelated_child, - ], - &[11], - &HashMap::from([(10, 100), (11, 110), (12, 120), (20, 200), (21, 210)]), - ); - - assert_eq!(selected, vec![12, 11, 10]); - } - - #[cfg(windows)] - #[test] - fn native_stop_selects_a_directly_started_chatgpt_tree_without_cdp() { - let root = windows_process( - 10, - 1, - "ChatGPT.exe", - r"C:\Program Files\WindowsApps\OpenAI.Codex_26.820.7780.0_x64__2p2nqsd0c76g0\app\ChatGPT.exe", - ); - let renderer = windows_process( - 11, - 10, - "ChatGPT.exe", - r"C:\Program Files\WindowsApps\OpenAI.Codex_26.820.7780.0_x64__2p2nqsd0c76g0\app\ChatGPT.exe", - ); - let cli = windows_process( - 20, - 10, - "codex.exe", - r"C:\Users\test\AppData\Local\OpenAI\Codex\bin\build\codex.exe", - ); - let processes = [root, renderer, cli]; - let native_process_ids = find_codex_processes_from_snapshot(&processes); - - let selected = target_codex_process_tree_from_snapshot( - &processes, - &native_process_ids, - &HashMap::from([(10, 100), (11, 110), (20, 120)]), - ); - - assert_eq!(native_process_ids, vec![10, 11]); - assert_eq!(selected, vec![11, 10]); - } - - #[cfg(windows)] - #[test] - fn targeted_stop_refuses_an_untrusted_listener_owner() { - let unrelated = windows_process(30, 1, "other.exe", r"C:\Tools\other.exe"); - - let selected = target_codex_process_tree_from_snapshot( - &[unrelated], - &[30], - &HashMap::from([(30, 300)]), - ); - - assert!(selected.is_empty()); - } - - #[cfg(windows)] - #[test] - fn targeted_stop_does_not_follow_a_newer_reused_parent() { - let parent = windows_process( - 10, - 1, - "ChatGPT.exe", - r"C:\Program Files\WindowsApps\OpenAI.Codex_26.820.7780.0_x64__2p2nqsd0c76g0\app\ChatGPT.exe", - ); - let owner = windows_process( - 11, - 10, - "ChatGPT.exe", - r"C:\Program Files\WindowsApps\OpenAI.Codex_26.820.7780.0_x64__2p2nqsd0c76g0\app\ChatGPT.exe", - ); - - let selected = target_codex_process_tree_from_snapshot( - &[parent, owner], - &[11], - &HashMap::from([(10, 120), (11, 110)]), - ); - - assert_eq!(selected, vec![11]); - } - - #[cfg(windows)] - #[test] - fn targeted_stop_refuses_incomplete_same_package_birth_identity() { - let root = windows_process( - 10, - 1, - "ChatGPT.exe", - r"C:\Program Files\WindowsApps\OpenAI.Codex_26.820.7780.0_x64__2p2nqsd0c76g0\app\ChatGPT.exe", - ); - let child = windows_process( - 11, - 10, - "ChatGPT.exe", - r"C:\Program Files\WindowsApps\OpenAI.Codex_26.820.7780.0_x64__2p2nqsd0c76g0\app\ChatGPT.exe", - ); - let identity_root = process_identity_root(root.executable_path.as_deref().unwrap()); - - let result = collect_identity_birth_ids(&[root, child], &identity_root, |process_id| { - (process_id == 10).then_some(100) - }); - - assert_eq!(result, Err(11)); - } - - #[cfg(windows)] - #[test] - fn targeted_stop_rejects_another_listener_owner_appearing() { - assert!(listener_owners_unchanged(&[10], &[10])); - assert!(!listener_owners_unchanged(&[10], &[10, 20])); - } } #[cfg(windows)] @@ -637,205 +469,6 @@ pub fn find_codex_processes_from_snapshot( ids } -#[cfg(windows)] -#[derive(Debug, Clone)] -struct WindowsTargetProcess { - process_id: u32, - birth_id: u64, - executable_path: PathBuf, -} - -#[cfg(windows)] -#[derive(Debug, Clone)] -pub struct WindowsCodexStopPlan { - debug_port: u16, - endpoint: Option, - listener_process_ids: Vec, - native_app_process_ids: Vec, - target_processes: Vec, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TargetedStopOutcome { - Stopped, - AlreadyAbsent, -} - -#[cfg(windows)] -fn process_identity_root(path: &Path) -> String { - let normalized = path - .to_string_lossy() - .replace('/', "\\") - .to_ascii_lowercase(); - if let Some((prefix, after_windows_apps)) = normalized.split_once("\\windowsapps\\") - && let Some((package_name, _)) = after_windows_apps.split_once('\\') - { - return format!("{prefix}\\windowsapps\\{package_name}"); - } - path.parent() - .unwrap_or(path) - .to_string_lossy() - .replace('/', "\\") - .to_ascii_lowercase() -} - -#[cfg(windows)] -fn collect_identity_birth_ids( - processes: &[crate::windows_integration::WindowsProcessInfo], - identity_root: &str, - mut birth_id_for: F, -) -> Result, u32> -where - F: FnMut(u32) -> Option, -{ - processes - .iter() - .filter(|process| { - process - .executable_path - .as_deref() - .is_some_and(|path| process_identity_root(path) == identity_root) - }) - .map(|process| { - birth_id_for(process.process_id) - .map(|birth_id| (process.process_id, birth_id)) - .ok_or(process.process_id) - }) - .collect() -} - -#[cfg(windows)] -fn process_descends_from( - process_id: u32, - ancestor_process_id: u32, - parents: &HashMap, -) -> bool { - let mut cursor = process_id; - let mut visited = HashSet::new(); - while visited.insert(cursor) { - if cursor == ancestor_process_id { - return true; - } - let Some(parent) = parents.get(&cursor).copied() else { - return false; - }; - cursor = parent; - } - false -} - -#[cfg(windows)] -fn listener_owners_unchanged(expected: &[u32], current: &[u32]) -> bool { - expected == current -} - -#[cfg(windows)] -fn target_codex_process_tree_from_snapshot( - processes: &[crate::windows_integration::WindowsProcessInfo], - listener_process_ids: &[u32], - birth_ids: &HashMap, -) -> Vec { - let supported = find_codex_processes_from_snapshot(processes) - .into_iter() - .collect::>(); - let parents = processes - .iter() - .map(|process| (process.process_id, process.parent_process_id)) - .collect::>(); - let paths = processes - .iter() - .filter_map(|process| { - process - .executable_path - .as_deref() - .map(|path| (process.process_id, path)) - }) - .collect::>(); - let mut roots = HashSet::new(); - for process_id in listener_process_ids { - if !supported.contains(process_id) || !birth_ids.contains_key(process_id) { - continue; - } - let Some(owner_path) = paths.get(process_id) else { - continue; - }; - let identity_root = process_identity_root(owner_path); - let mut root = *process_id; - let mut cursor = *process_id; - let mut visited = HashSet::new(); - while visited.insert(cursor) { - let Some(parent) = parents.get(&cursor).copied() else { - break; - }; - if !supported.contains(&parent) { - break; - } - let Some(parent_path) = paths.get(&parent) else { - break; - }; - let Some(parent_birth_id) = birth_ids.get(&parent).copied() else { - break; - }; - let Some(cursor_birth_id) = birth_ids.get(&cursor).copied() else { - break; - }; - if process_identity_root(parent_path) != identity_root - || parent_birth_id > cursor_birth_id - { - break; - } - root = parent; - cursor = parent; - } - roots.insert((root, identity_root)); - } - if roots.is_empty() { - return Vec::new(); - } - - let mut targets = Vec::new(); - for process in processes { - let Some(process_path) = process.executable_path.as_deref() else { - continue; - }; - let Some(process_birth_id) = birth_ids.get(&process.process_id).copied() else { - continue; - }; - let mut cursor = process.process_id; - let mut cursor_birth_id = process_birth_id; - let mut depth = 0usize; - let mut visited = HashSet::new(); - while visited.insert(cursor) { - if roots.contains(&(cursor, process_identity_root(process_path))) { - targets.push((depth, process.process_id)); - break; - } - let Some(parent) = parents.get(&cursor).copied() else { - break; - }; - let Some(parent_birth_id) = birth_ids.get(&parent).copied() else { - break; - }; - if parent_birth_id > cursor_birth_id { - break; - } - cursor = parent; - cursor_birth_id = parent_birth_id; - depth = depth.saturating_add(1); - } - } - targets.sort_unstable_by(|(left_depth, left_pid), (right_depth, right_pid)| { - right_depth - .cmp(left_depth) - .then_with(|| left_pid.cmp(right_pid)) - }); - targets.dedup_by_key(|(_, process_id)| *process_id); - targets - .into_iter() - .map(|(_, process_id)| process_id) - .collect() -} - /// Return desktop processes that can write Codex task state while a destructive /// session-index cleanup is running. This is intentionally stricter than the /// watcher filter: any supported ChatGPT desktop process blocks deletion, @@ -988,359 +621,54 @@ pub fn stop_codex_processes() { } } -#[cfg(windows)] -pub fn stop_codex_processes_and_wait() { - let _ = terminate_and_wait_for_exit( - find_codex_processes(), - RESTART_STOP_WAIT_TIMEOUT_MS, - RESTART_STOP_WAIT_INTERVAL_MS, - ); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CodexStopOutcome { + Stopped, + AlreadyAbsent, } -#[cfg(target_os = "macos")] -pub fn stop_codex_processes_and_wait() { - let _ = terminate_macos_processes_and_wait( - find_codex_processes(), - || find_codex_processes(), +#[cfg(windows)] +pub fn stop_codex_processes_for_restart_and_wait() -> Result { + let process_ids = find_codex_processes(); + if process_ids.is_empty() { + return Ok(CodexStopOutcome::AlreadyAbsent); + } + if terminate_and_wait_for_exit( + process_ids, RESTART_STOP_WAIT_TIMEOUT_MS, RESTART_STOP_WAIT_INTERVAL_MS, - ); + ) { + Ok(CodexStopOutcome::Stopped) + } else { + Err("等待 Codex App 退出超时,已中止重启。".to_string()) + } } -#[cfg(not(any(windows, target_os = "macos")))] -pub fn stop_codex_processes_and_wait() {} - #[cfg(target_os = "macos")] -pub fn stop_codex_processes_for_debug_port_and_wait( - debug_port: u16, - target_cdp_reachable: bool, -) -> Result { - let process_ids = if target_cdp_reachable { - find_macos_codex_processes_for_debug_port(debug_port) - } else { - find_codex_processes() - }; +pub fn stop_codex_processes_for_restart_and_wait() -> Result { + let process_ids = find_codex_processes(); if process_ids.is_empty() { - return Ok(TargetedStopOutcome::AlreadyAbsent); + return Ok(CodexStopOutcome::AlreadyAbsent); } if terminate_macos_processes_and_wait( process_ids, - || { - if target_cdp_reachable { - find_macos_codex_processes_for_debug_port(debug_port) - } else { - find_codex_processes() - } - }, + || find_codex_processes(), RESTART_STOP_WAIT_TIMEOUT_MS, RESTART_STOP_WAIT_INTERVAL_MS, ) { - Ok(TargetedStopOutcome::Stopped) - } else { - Err("等待目标 Codex App 退出超时,已中止重启。".to_string()) - } -} - -#[cfg(windows)] -pub fn prepare_windows_codex_stop_plan( - debug_port: u16, - endpoint: Option, -) -> Result { - let listener_process_ids = if let Some(endpoint) = endpoint { - let process_ids = crate::windows_integration::tcp_listener_process_ids(endpoint) - .map_err(|error| format!("读取目标 Codex App 端口归属失败:{error}"))?; - if process_ids.is_empty() { - let remaining = - crate::windows_integration::loopback_tcp_listener_process_ids(debug_port) - .map_err(|error| format!("复核 Codex App 调试端口失败:{error}"))?; - if remaining.is_empty() { - return Ok(WindowsCodexStopPlan { - debug_port, - endpoint: None, - listener_process_ids: Vec::new(), - native_app_process_ids: Vec::new(), - target_processes: Vec::new(), - }); - } - return Err("目标 Codex App 调试端口在重启前发生了归属变化,已拒绝停止。".to_string()); - } - let all_loopback_process_ids = - crate::windows_integration::loopback_tcp_listener_process_ids(debug_port) - .map_err(|error| format!("复核 Codex App 调试端口失败:{error}"))?; - if !listener_owners_unchanged(&process_ids, &all_loopback_process_ids) { - return Err( - "同一调试端口存在另一个回环监听实例,已拒绝停止任何 Codex App。".to_string(), - ); - } - process_ids - } else { - let process_ids = crate::windows_integration::loopback_tcp_listener_process_ids(debug_port) - .map_err(|error| format!("复核 Codex App 调试端口失败:{error}"))?; - if !process_ids.is_empty() { - return Err( - "目标 Codex App CDP 不可确认,但调试端口仍有监听,已拒绝停止。".to_string(), - ); - } - Vec::new() - }; - if endpoint.is_some() && listener_process_ids.len() != 1 { - return Err("目标 Codex App 调试 endpoint 对应多个进程,已拒绝停止。".to_string()); - } - - let processes = crate::windows_integration::enumerate_processes(); - let native_app_process_ids = if endpoint.is_none() { - find_codex_processes_from_snapshot(&processes) + Ok(CodexStopOutcome::Stopped) } else { - Vec::new() - }; - if endpoint.is_none() && native_app_process_ids.is_empty() { - return Ok(WindowsCodexStopPlan { - debug_port, - endpoint: None, - listener_process_ids: Vec::new(), - native_app_process_ids, - target_processes: Vec::new(), - }); - } - let target_seed_process_ids = if endpoint.is_some() { - &listener_process_ids - } else { - &native_app_process_ids - }; - let owner_process_id = target_seed_process_ids[0]; - let owner = processes - .iter() - .find(|process| process.process_id == owner_process_id) - .ok_or_else(|| "目标 Codex App 进程已退出,已拒绝按旧 PID 停止。".to_string())?; - let owner_path = owner - .executable_path - .as_deref() - .ok_or_else(|| "无法读取目标 Codex App 可执行路径,已拒绝停止。".to_string())?; - let identity_root = process_identity_root(owner_path); - if endpoint.is_none() { - let identity_roots = native_app_process_ids - .iter() - .map(|process_id| { - processes - .iter() - .find(|process| process.process_id == *process_id) - .and_then(|process| process.executable_path.as_deref()) - .map(process_identity_root) - .ok_or_else(|| { - format!( - "无法读取目标 Codex App 进程 {process_id} 的可执行路径,已拒绝停止。" - ) - }) - }) - .collect::, String>>()?; - if identity_roots.len() != 1 || !identity_roots.contains(&identity_root) { - return Err("发现多个 Codex App 安装实例,无法安全判断应重启哪一个。".to_string()); - } - } - let processes_by_id = processes - .iter() - .map(|process| (process.process_id, process)) - .collect::>(); - for target_seed_process_id in target_seed_process_ids { - let mut ancestor_cursor = *target_seed_process_id; - let mut visited_ancestors = HashSet::new(); - while visited_ancestors.insert(ancestor_cursor) { - let Some(process) = processes_by_id.get(&ancestor_cursor) else { - break; - }; - let Some(parent) = processes_by_id.get(&process.parent_process_id) else { - break; - }; - let Some(parent_path) = parent.executable_path.as_deref() else { - if crate::app_paths::is_supported_app_executable_name(&parent.exe_file) { - return Err(format!( - "无法读取目标候选父进程 {} 的可执行路径,已拒绝生成部分停止计划。", - parent.process_id - )); - } - break; - }; - if process_identity_root(parent_path) != identity_root { - break; - } - ancestor_cursor = parent.process_id; - } - } - let birth_ids = collect_identity_birth_ids(&processes, &identity_root, |process_id| { - crate::windows_integration::process_birth_id(process_id) - }) - .map_err(|process_id| { - format!("无法读取目标同包进程 {process_id} 的创建时间,已拒绝生成部分停止计划。") - })?; - let target_process_ids = - target_codex_process_tree_from_snapshot(&processes, target_seed_process_ids, &birth_ids); - if target_process_ids.is_empty() { - return Err("目标不属于受支持的 Codex App 进程树,已拒绝停止。".to_string()); + Err("等待 Codex App 退出超时,已中止重启。".to_string()) } - let parents = processes - .iter() - .map(|process| (process.process_id, process.parent_process_id)) - .collect::>(); - let target_process_id_set = target_process_ids.iter().copied().collect::>(); - let root_process_ids = target_process_ids - .iter() - .copied() - .filter(|process_id| { - parents - .get(process_id) - .is_none_or(|parent| !target_process_id_set.contains(parent)) - }) - .collect::>(); - if let Some(process) = processes.iter().find(|process| { - process.executable_path.is_none() - && root_process_ids.iter().any(|root_process_id| { - process_descends_from(process.process_id, *root_process_id, &parents) - }) - }) { - return Err(format!( - "无法读取目标进程树内 PID {} 的可执行路径,已拒绝生成部分停止计划。", - process.process_id - )); - } - let target_processes = target_process_ids - .into_iter() - .map(|process_id| { - let process = processes - .iter() - .find(|process| process.process_id == process_id) - .ok_or_else(|| format!("目标进程 {process_id} 已退出,无法建立安全停止计划。"))?; - let birth_id = birth_ids - .get(&process_id) - .copied() - .ok_or_else(|| format!("无法读取目标进程 {process_id} 的创建时间。"))?; - let executable_path = process - .executable_path - .clone() - .ok_or_else(|| format!("无法读取目标进程 {process_id} 的可执行路径。"))?; - Ok(WindowsTargetProcess { - process_id, - birth_id, - executable_path, - }) - }) - .collect::, String>>()?; - - Ok(WindowsCodexStopPlan { - debug_port, - endpoint, - listener_process_ids, - native_app_process_ids, - target_processes, - }) } -#[cfg(windows)] -pub fn execute_windows_codex_stop_plan( - plan: WindowsCodexStopPlan, -) -> Result { - if plan.target_processes.is_empty() { - let listeners = - crate::windows_integration::loopback_tcp_listener_process_ids(plan.debug_port) - .map_err(|error| format!("复核 Codex App 调试端口失败:{error}"))?; - let native_app_process_ids = find_codex_processes(); - return if listeners.is_empty() && native_app_process_ids.is_empty() { - Ok(TargetedStopOutcome::AlreadyAbsent) - } else { - Err("Codex App 在停止 Launcher 后重新出现,已中止重启。".to_string()) - }; - } - - if plan.endpoint.is_some() { - let current_listener_process_ids = - crate::windows_integration::loopback_tcp_listener_process_ids(plan.debug_port) - .map_err(|error| format!("停止前复核目标 Codex App 端口归属失败:{error}"))?; - if !listener_owners_unchanged(&plan.listener_process_ids, ¤t_listener_process_ids) { - return Err("目标 Codex App 端口归属在停止前发生变化,已中止重启。".to_string()); - } - } else { - let current_native_app_process_ids = find_codex_processes(); - if current_native_app_process_ids != plan.native_app_process_ids { - return Err("目标 Codex App 进程集合在停止前发生变化,已中止重启。".to_string()); - } - let current_listener_process_ids = - crate::windows_integration::loopback_tcp_listener_process_ids(plan.debug_port) - .map_err(|error| format!("停止前复核 Codex App 调试端口失败:{error}"))?; - if !current_listener_process_ids.is_empty() { - return Err("目标 Codex App 在停止前新建了调试端口,已中止重启。".to_string()); - } - } - - let _ = crate::diagnostic_log::append_diagnostic_log( - "watcher.targeted_stop_started", - serde_json::json!({ - "debug_port": plan.debug_port, - "endpoint": plan.endpoint.map(|endpoint| endpoint.to_string()), - "listener_process_ids": &plan.listener_process_ids, - "native_app_process_ids": &plan.native_app_process_ids, - "target_process_ids": plan.target_processes.iter().map(|process| process.process_id).collect::>(), - }), - ); - for process in &plan.target_processes { - match crate::windows_integration::terminate_process_if_identity_matches( - process.process_id, - process.birth_id, - &process.executable_path, - ) { - Ok(true) => {} - Ok(false) - if crate::windows_integration::process_birth_id(process.process_id) - != Some(process.birth_id) => {} - Ok(false) => { - return Err(format!( - "目标进程 {} 的身份复核失败,已中止重启。", - process.process_id - )); - } - Err(error) => { - return Err(format!("停止目标进程 {} 失败:{error}", process.process_id)); - } - } - } - - let deadline = std::time::Instant::now() + Duration::from_millis(RESTART_STOP_WAIT_TIMEOUT_MS); - loop { - let remaining = plan - .target_processes - .iter() - .filter(|process| { - crate::windows_integration::process_birth_id(process.process_id) - == Some(process.birth_id) - }) - .map(|process| process.process_id) - .collect::>(); - if remaining.is_empty() { - break; - } - if std::time::Instant::now() >= deadline { - return Err(format!( - "等待目标 Codex App 退出超时,仍在运行的 PID:{remaining:?}" - )); - } - std::thread::sleep(Duration::from_millis(RESTART_STOP_WAIT_INTERVAL_MS)); - } - let remaining_listeners = - crate::windows_integration::loopback_tcp_listener_process_ids(plan.debug_port) - .map_err(|error| format!("停止后复核目标 Codex App 端口失败:{error}"))?; - if !remaining_listeners.is_empty() { - return Err("目标 Codex App 停止后调试 endpoint 仍被占用,已中止重启。".to_string()); - } - if plan.endpoint.is_none() && !find_codex_processes().is_empty() { - return Err("目标 Codex App 停止后仍有受支持的 App 进程,已中止重启。".to_string()); - } - Ok(TargetedStopOutcome::Stopped) +#[cfg(not(any(windows, target_os = "macos")))] +pub fn stop_codex_processes_for_restart_and_wait() -> Result { + Ok(CodexStopOutcome::AlreadyAbsent) } -#[cfg(not(any(windows, target_os = "macos")))] -pub fn stop_codex_processes_for_debug_port_and_wait( - _debug_port: u16, - _target_cdp_reachable: bool, -) -> Result { - Ok(TargetedStopOutcome::AlreadyAbsent) +pub fn stop_codex_processes_and_wait() { + let _ = stop_codex_processes_for_restart_and_wait(); } #[cfg(target_os = "macos")] @@ -1404,43 +732,6 @@ fn find_launcher_processes() -> Vec { ) } -#[cfg(target_os = "macos")] -fn find_macos_codex_processes_for_debug_port(debug_port: u16) -> Vec { - let Ok(output) = std::process::Command::new("ps") - .args(["-axo", "pid=,args="]) - .output() - else { - return Vec::new(); - }; - macos_codex_process_ids_for_debug_port( - String::from_utf8_lossy(&output.stdout).lines(), - debug_port, - ) -} - -#[cfg(target_os = "macos")] -fn macos_codex_process_ids_for_debug_port<'a>( - process_lines: impl IntoIterator, - debug_port: u16, -) -> Vec { - let debug_flag = format!("remote-debugging-port={debug_port}"); - let mut ids = process_lines - .into_iter() - .filter_map(|line| { - let trimmed = line.trim_start(); - let (pid, args) = trimmed.split_once(char::is_whitespace)?; - let process_id = pid.parse::().ok()?; - let is_desktop_main = (args.contains(".app/Contents/MacOS/ChatGPT") - || args.contains(".app/Contents/MacOS/Codex")) - && !args.contains("/Helpers/"); - (is_desktop_main && args.contains(&debug_flag)).then_some(process_id) - }) - .collect::>(); - ids.sort_unstable(); - ids.dedup(); - ids -} - #[cfg(windows)] fn terminate_and_wait_for_exit(process_ids: Vec, timeout_ms: u64, interval_ms: u64) -> bool { if process_ids.is_empty() { diff --git a/crates/codex-plus-core/src/windows_integration.rs b/crates/codex-plus-core/src/windows_integration.rs index 3857bb7d6..17921d8b8 100644 --- a/crates/codex-plus-core/src/windows_integration.rs +++ b/crates/codex-plus-core/src/windows_integration.rs @@ -3,11 +3,9 @@ use std::ffi::{OsStr, OsString}; #[cfg(windows)] use std::iter::once; #[cfg(windows)] -use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; -#[cfg(windows)] use std::os::windows::ffi::{OsStrExt, OsStringExt}; #[cfg(windows)] -use std::path::{Path, PathBuf}; +use std::path::PathBuf; #[cfg(windows)] use std::sync::OnceLock; @@ -15,17 +13,9 @@ use std::sync::OnceLock; use anyhow::Context; #[cfg(windows)] use windows::Win32::Foundation::{ - BOOL, CloseHandle, ERROR_INSUFFICIENT_BUFFER, ERROR_INVALID_PARAMETER, FILETIME, HANDLE, HWND, - LPARAM, MAX_PATH, NO_ERROR, WPARAM, -}; -#[cfg(windows)] -use windows::Win32::NetworkManagement::IpHelper::{ - GetExtendedTcpTable, MIB_TCP6ROW_OWNER_PID, MIB_TCP6TABLE_OWNER_PID, MIB_TCPROW_OWNER_PID, - MIB_TCPTABLE_OWNER_PID, TCP_TABLE_OWNER_PID_LISTENER, + BOOL, CloseHandle, FILETIME, HANDLE, HWND, LPARAM, MAX_PATH, WPARAM, }; #[cfg(windows)] -use windows::Win32::Networking::WinSock::{AF_INET, AF_INET6}; -#[cfg(windows)] use windows::Win32::System::Com::{ CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED, CoCreateInstance, CoInitializeEx, CoTaskMemFree, CoUninitialize, IPersistFile, @@ -65,7 +55,7 @@ use windows::Win32::UI::WindowsAndMessaging::{ HICON, ICON_BIG, ICON_SMALL, SendMessageW, WM_SETICON, }; #[cfg(windows)] -use windows::core::{HRESULT, Interface, PCWSTR, PROPVARIANT, PWSTR}; +use windows::core::{Interface, PCWSTR, PROPVARIANT, PWSTR}; #[cfg(windows)] pub const CREATE_NO_WINDOW: u32 = 0x08000000; @@ -339,171 +329,6 @@ pub fn enumerate_processes() -> Vec { processes } -#[cfg(windows)] -pub fn tcp_listener_process_ids(address: SocketAddr) -> std::io::Result> { - let port = address.port(); - let mut process_ids = match address.ip() { - IpAddr::V4(address) => tcp4_listener_process_ids(address, port), - IpAddr::V6(address) => tcp6_listener_process_ids(address, port), - }?; - process_ids.sort_unstable(); - process_ids.dedup(); - Ok(process_ids) -} - -#[cfg(windows)] -pub fn loopback_tcp_listener_process_ids(port: u16) -> std::io::Result> { - let mut process_ids = tcp4_listener_process_ids(Ipv4Addr::LOCALHOST, port)?; - process_ids.extend(tcp6_listener_process_ids(Ipv6Addr::LOCALHOST, port)?); - process_ids.sort_unstable(); - process_ids.dedup(); - Ok(process_ids) -} - -#[cfg(windows)] -fn tcp4_listener_process_ids(address: Ipv4Addr, port: u16) -> std::io::Result> { - let buffer = tcp_listener_table_buffer(AF_INET.0 as u32)?; - if buffer.is_empty() { - return Ok(Vec::new()); - } - let table = buffer.as_ptr().cast::(); - let count = unsafe { (*table).dwNumEntries as usize }; - validate_tcp_table_size( - buffer.len(), - count, - std::mem::size_of::(), - )?; - let rows = unsafe { - std::slice::from_raw_parts( - std::ptr::addr_of!((*table).table).cast::(), - count, - ) - }; - Ok(rows - .iter() - .filter(|row| { - network_port(row.dwLocalPort) == port && ipv4_listener_matches(row.dwLocalAddr, address) - }) - .map(|row| row.dwOwningPid) - .collect()) -} - -#[cfg(windows)] -fn tcp6_listener_process_ids(address: Ipv6Addr, port: u16) -> std::io::Result> { - let buffer = tcp_listener_table_buffer(AF_INET6.0 as u32)?; - if buffer.is_empty() { - return Ok(Vec::new()); - } - let table = buffer.as_ptr().cast::(); - let count = unsafe { (*table).dwNumEntries as usize }; - validate_tcp_table_size( - buffer.len(), - count, - std::mem::size_of::(), - )?; - let rows = unsafe { - std::slice::from_raw_parts( - std::ptr::addr_of!((*table).table).cast::(), - count, - ) - }; - Ok(rows - .iter() - .filter(|row| { - network_port(row.dwLocalPort) == port && ipv6_listener_matches(row.ucLocalAddr, address) - }) - .map(|row| row.dwOwningPid) - .collect()) -} - -#[cfg(windows)] -fn tcp_listener_table_buffer(address_family: u32) -> std::io::Result> { - let mut byte_len = 0u32; - let status = unsafe { - GetExtendedTcpTable( - None, - &mut byte_len, - false, - address_family, - TCP_TABLE_OWNER_PID_LISTENER, - 0, - ) - }; - if status == NO_ERROR.0 && byte_len == 0 { - return Ok(Vec::new()); - } - if status != ERROR_INSUFFICIENT_BUFFER.0 && status != NO_ERROR.0 { - return Err(std::io::Error::from_raw_os_error(status as i32)); - } - - for _ in 0..3 { - let word_len = (byte_len as usize).div_ceil(std::mem::size_of::()); - let mut buffer = vec![0u32; word_len]; - let status = unsafe { - GetExtendedTcpTable( - Some(buffer.as_mut_ptr().cast()), - &mut byte_len, - false, - address_family, - TCP_TABLE_OWNER_PID_LISTENER, - 0, - ) - }; - if status == NO_ERROR.0 { - return Ok(buffer); - } - if status != ERROR_INSUFFICIENT_BUFFER.0 { - return Err(std::io::Error::from_raw_os_error(status as i32)); - } - } - Err(std::io::Error::new( - std::io::ErrorKind::Other, - "TCP listener table kept changing while being read", - )) -} - -#[cfg(windows)] -fn validate_tcp_table_size( - buffer_words: usize, - entry_count: usize, - entry_size: usize, -) -> std::io::Result<()> { - let required = std::mem::size_of::() - .checked_add(entry_count.checked_mul(entry_size).ok_or_else(|| { - std::io::Error::new(std::io::ErrorKind::InvalidData, "TCP table size overflow") - })?) - .ok_or_else(|| { - std::io::Error::new(std::io::ErrorKind::InvalidData, "TCP table size overflow") - })?; - let available = buffer_words - .checked_mul(std::mem::size_of::()) - .unwrap_or(usize::MAX); - if required > available { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "TCP listener table is truncated", - )); - } - Ok(()) -} - -#[cfg(windows)] -fn network_port(value: u32) -> u16 { - u16::from_be(value as u16) -} - -#[cfg(windows)] -fn ipv4_listener_matches(value: u32, requested: Ipv4Addr) -> bool { - let local = Ipv4Addr::from(u32::from_be(value)); - local.is_unspecified() || local == requested -} - -#[cfg(windows)] -fn ipv6_listener_matches(value: [u8; 16], requested: Ipv6Addr) -> bool { - let local = Ipv6Addr::from(value); - local.is_unspecified() || local == requested -} - #[cfg(windows)] pub fn terminate_process(process_id: u32) -> bool { let Ok(handle) = (unsafe { @@ -522,43 +347,6 @@ pub fn terminate_process(process_id: u32) -> bool { unsafe { TerminateProcess(handle, 0) }.is_ok() } -#[cfg(windows)] -pub fn terminate_process_if_identity_matches( - process_id: u32, - expected_birth_id: u64, - expected_path: &Path, -) -> std::io::Result { - let handle = match unsafe { - OpenProcess( - PROCESS_TERMINATE | PROCESS_QUERY_LIMITED_INFORMATION, - false, - process_id, - ) - } { - Ok(handle) => handle, - Err(error) if error.code() == HRESULT::from_win32(ERROR_INVALID_PARAMETER.0) => { - return Ok(false); - } - Err(error) => return Err(std::io::Error::other(error.to_string())), - }; - if handle.is_invalid() { - return Ok(false); - } - let _guard = HandleGuard(handle); - let Some(birth_id) = process_birth_id_from_handle(handle) else { - return Ok(false); - }; - let Some(path) = query_process_image_path_from_handle(handle) else { - return Ok(false); - }; - if birth_id != expected_birth_id || !paths_equal_case_insensitive(&path, expected_path) { - return Ok(false); - } - unsafe { TerminateProcess(handle, 0) } - .map(|_| true) - .map_err(|error| std::io::Error::other(error.to_string())) -} - #[cfg(windows)] pub fn process_birth_id(process_id: u32) -> Option { let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, process_id).ok()? }; @@ -566,11 +354,6 @@ pub fn process_birth_id(process_id: u32) -> Option { return None; } let _guard = HandleGuard(handle); - process_birth_id_from_handle(handle) -} - -#[cfg(windows)] -fn process_birth_id_from_handle(handle: HANDLE) -> Option { let mut creation_time = FILETIME::default(); let mut exit_time = FILETIME::default(); let mut kernel_time = FILETIME::default(); @@ -638,11 +421,6 @@ fn query_process_image_path(process_id: u32) -> Option { return None; } let _guard = HandleGuard(handle); - query_process_image_path_from_handle(handle) -} - -#[cfg(windows)] -fn query_process_image_path_from_handle(handle: HANDLE) -> Option { let mut buffer = vec![0u16; MAX_PATH as usize * 4]; let mut len = buffer.len() as u32; unsafe { @@ -657,12 +435,6 @@ fn query_process_image_path_from_handle(handle: HANDLE) -> Option { Some(PathBuf::from(OsString::from_wide(&buffer[..len as usize]))) } -#[cfg(windows)] -fn paths_equal_case_insensitive(left: &Path, right: &Path) -> bool { - left.to_string_lossy() - .eq_ignore_ascii_case(&right.to_string_lossy()) -} - #[cfg(windows)] fn visible_window_for_process(process_id: u32) -> Option { process_window(process_id, true) @@ -930,7 +702,6 @@ impl Drop for RegistryKeyGuard { #[cfg(all(test, windows))] mod tests { use super::*; - use std::net::TcpListener; #[test] fn application_window_outranks_titled_ime_and_tool_windows() { @@ -945,44 +716,4 @@ mod tests { assert_eq!(ime_score, tool_score); assert_eq!(auxiliary_app_score, ProcessWindowScore::Fallback); } - - #[test] - fn network_port_decodes_the_ip_helper_byte_order() { - assert_eq!(network_port(u16::to_be(9229) as u32), 9229); - } - - #[test] - fn tcp_listener_process_ids_finds_the_current_ipv4_listener() { - let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); - let address = listener.local_addr().unwrap(); - - let process_ids = tcp_listener_process_ids(address).unwrap(); - - assert!(process_ids.contains(&std::process::id())); - } - - #[test] - fn listener_address_matching_rejects_other_local_addresses() { - assert!(ipv4_listener_matches( - u32::from(Ipv4Addr::LOCALHOST).to_be(), - Ipv4Addr::LOCALHOST - )); - assert!(!ipv4_listener_matches( - u32::from(Ipv4Addr::new(127, 0, 0, 2)).to_be(), - Ipv4Addr::LOCALHOST - )); - assert!(ipv6_listener_matches( - Ipv6Addr::LOCALHOST.octets(), - Ipv6Addr::LOCALHOST - )); - } - - #[test] - fn identity_checked_termination_treats_a_missing_process_as_gone() { - let result = - terminate_process_if_identity_matches(u32::MAX, 1, Path::new(r"C:\missing\Codex.exe")) - .unwrap(); - - assert!(!result); - } } From 529a7b52b481ccb642f20e0b7c2acf1f5193a4b3 Mon Sep 17 00:00:00 2001 From: Yuimi_chaya <124485273+Yuimi-chaya@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:59:20 +0800 Subject: [PATCH 08/12] perf(data): skip unchanged provider-sync rollouts --- crates/codex-plus-data/src/provider_sync.rs | 802 +++++++++++++++++- crates/codex-plus-data/tests/provider_sync.rs | 156 ++++ 2 files changed, 946 insertions(+), 12 deletions(-) diff --git a/crates/codex-plus-data/src/provider_sync.rs b/crates/codex-plus-data/src/provider_sync.rs index 066f8ee3f..8e897ace7 100644 --- a/crates/codex-plus-data/src/provider_sync.rs +++ b/crates/codex-plus-data/src/provider_sync.rs @@ -18,6 +18,17 @@ const SESSION_TRANSACTION_NAMESPACE: &str = "provider-sync-rollout-transaction"; const SESSION_TRANSACTION_IN_PROGRESS: &str = "in_progress"; const SESSION_TRANSACTION_COMMITTED: &str = "committed"; const SESSION_TRANSACTION_ROLLED_BACK: &str = "rolled_back"; +const PROVIDER_SYNC_SCAN_STATE_FILE: &str = "rollout-scan-state.json"; +const PROVIDER_SYNC_SCAN_STATE_NAMESPACE: &str = "provider-sync-rollout-scan-state"; +const PROVIDER_SYNC_SCAN_STATE_VERSION: u32 = 1; +const PROVIDER_SYNC_SCAN_STATE_MAX_BYTES: u64 = 32 * 1024 * 1024; +const PROVIDER_SYNC_SCAN_STATE_MAX_ENTRIES: usize = 100_000; +const PROVIDER_SYNC_SCAN_RULES_V1: &str = concat!( + "provider-sync-rollout-scan/v1;", + "utf8-jsonl;session_meta-payload-id-cwd-model_provider;", + "user_message-or-user_input;encrypted_content-byte-marker;", + "non-root-agent-source;missing-provider-sentinel" +); /// `create_lock` 先建目录再写 `owner.json`,两步之间被强杀会留下没有 owner 的锁目录。 /// 该窗口只有几毫秒,因此超过这个时长仍缺 owner 的锁一定是中断残留,可以安全回收; @@ -343,12 +354,41 @@ struct RolloutRewrite { original_size: u64, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProviderSyncRolloutScanState { + relative_path: String, + size: u64, + modified_secs: Option, + modified_nanos: Option, + file_identity: String, + sha256: String, + thread_id: Option, + cwd: Option, + has_user_event: bool, + has_encrypted_content: bool, + marks_non_root_agent: bool, + session_meta_count: usize, + providers: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProviderSyncRolloutScanStateManifest { + version: u32, + namespace: String, + rules_sha256: String, + rollout_roots: HashMap, + entries: Vec, +} + #[derive(Debug, Default)] struct SessionChanges { changes: Vec, skipped_locked_rollout_files: Vec, encrypted_content_counts: HashMap, subagent_thread_ids: HashSet, + scan_state_entries: Vec, } #[derive(Debug, Default)] @@ -1057,6 +1097,16 @@ pub fn run_provider_sync_with_target( && catalog_repair_count == 0 && global_state_update_count == 0 { + if require_stopped_app { + let running_processes = + codex_plus_core::watcher::find_session_index_cleanup_blocking_processes(); + if !running_processes.is_empty() { + anyhow::bail!( + "Codex App / ChatGPT started while provider sync was scanning rollouts" + ); + } + } + persist_provider_sync_scan_state_best_effort(&home, &collected.scan_state_entries); let mut synced = result( ProviderSyncStatus::Synced, "Provider sync already up to date", @@ -1124,6 +1174,12 @@ pub fn run_provider_sync_with_target( }; set_session_transaction_phase(&backup_dir, SessionTransactionPhase::CommitDecided)?; commit_session_transaction(&backup_dir)?; + persist_committed_provider_sync_scan_state_best_effort( + &home, + &backup_dir, + &target_provider, + collected.scan_state_entries.clone(), + ); prune_backups(&home)?; let mut synced = result( ProviderSyncStatus::Synced, @@ -1642,26 +1698,463 @@ fn release_owned_lock(path: &Path, lock_id: &str) -> std::io::Result { Ok(false) } +fn provider_sync_scan_rules_sha256() -> String { + let mut hasher = Sha256::new(); + hasher.update(PROVIDER_SYNC_SCAN_RULES_V1.as_bytes()); + hasher.update([0]); + hasher.update(env!("CARGO_PKG_VERSION").as_bytes()); + format!("{:x}", hasher.finalize()) +} + +fn provider_sync_scan_state_path(home: &Path) -> PathBuf { + home.join("backups_state/provider-sync") + .join(PROVIDER_SYNC_SCAN_STATE_FILE) +} + +fn load_provider_sync_scan_state( + home: &Path, +) -> anyhow::Result> { + let path = provider_sync_scan_state_path(home); + let path_metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(HashMap::new()), + Err(error) => return Err(error.into()), + }; + if !path_metadata.is_file() { + anyhow::bail!("provider-sync rollout scan state is not a bounded regular file"); + } + if let Some(parent) = path.parent() { + ensure_path_components_not_reparse(home, parent)?; + } + ensure_not_reparse_or_symlink(&path)?; + let mut file = File::open(&path)?; + let metadata = file.metadata()?; + if !metadata.is_file() || metadata.len() > PROVIDER_SYNC_SCAN_STATE_MAX_BYTES { + anyhow::bail!("provider-sync rollout scan state is not a bounded regular file"); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + std::io::Read::take(&mut file, PROVIDER_SYNC_SCAN_STATE_MAX_BYTES + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > PROVIDER_SYNC_SCAN_STATE_MAX_BYTES { + anyhow::bail!("provider-sync rollout scan state exceeded the read limit"); + } + let manifest: ProviderSyncRolloutScanStateManifest = serde_json::from_slice(&bytes)?; + if manifest.version != PROVIDER_SYNC_SCAN_STATE_VERSION + || manifest.namespace != PROVIDER_SYNC_SCAN_STATE_NAMESPACE + || manifest.rules_sha256 != provider_sync_scan_rules_sha256() + || manifest.rollout_roots != session_transaction_rollout_roots(home)? + || manifest.entries.len() > PROVIDER_SYNC_SCAN_STATE_MAX_ENTRIES + { + anyhow::bail!("unsupported provider-sync rollout scan state"); + } + let mut entries = HashMap::new(); + for entry in manifest.entries { + validated_rollout_relative_path(&entry.relative_path)?; + if entry.relative_path.len() > 32 * 1024 + || entry.file_identity.is_empty() + || entry.file_identity.len() > 512 + || entry.sha256.len() != 64 + || !entry.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) + || entry.modified_secs.is_some() != entry.modified_nanos.is_some() + || entry + .thread_id + .as_ref() + .is_some_and(|value| value.len() > 512) + || entry + .cwd + .as_ref() + .is_some_and(|value| value.len() > 32 * 1024) + || entry.providers.len() > 1024 + || entry.providers.iter().any(|value| value.len() > 512) + || !entry.providers.windows(2).all(|pair| pair[0] < pair[1]) + || entries.insert(entry.relative_path.clone(), entry).is_some() + { + anyhow::bail!("invalid provider-sync rollout scan state entry"); + } + } + Ok(entries) +} + +fn load_provider_sync_scan_state_best_effort( + home: &Path, +) -> HashMap { + match load_provider_sync_scan_state(home) { + Ok(entries) => entries, + Err(error) => { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "provider_sync.scan_state_ignored", + json!({ + "error": error.to_string(), + "path": provider_sync_scan_state_path(home).to_string_lossy(), + }), + ); + HashMap::new() + } + } +} + +fn persist_provider_sync_scan_state( + home: &Path, + entries: &[ProviderSyncRolloutScanState], +) -> anyhow::Result<()> { + if entries.len() > PROVIDER_SYNC_SCAN_STATE_MAX_ENTRIES { + anyhow::bail!("provider-sync rollout scan state has too many entries"); + } + let mut entries = entries.to_vec(); + entries.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); + let path = provider_sync_scan_state_path(home); + if let Some(parent) = path.parent() { + create_validated_directory_path(home, parent)?; + } + match fs::symlink_metadata(&path) { + Ok(_) => ensure_not_reparse_or_symlink(&path)?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + let bytes = serde_json::to_vec_pretty(&ProviderSyncRolloutScanStateManifest { + version: PROVIDER_SYNC_SCAN_STATE_VERSION, + namespace: PROVIDER_SYNC_SCAN_STATE_NAMESPACE.to_string(), + rules_sha256: provider_sync_scan_rules_sha256(), + rollout_roots: session_transaction_rollout_roots(home)?, + entries, + })?; + if bytes.len() as u64 > PROVIDER_SYNC_SCAN_STATE_MAX_BYTES { + anyhow::bail!("provider-sync rollout scan state is too large"); + } + codex_plus_core::settings::atomic_write(&path, &bytes) +} + +fn persist_provider_sync_scan_state_best_effort( + home: &Path, + entries: &[ProviderSyncRolloutScanState], +) { + if let Err(error) = persist_provider_sync_scan_state(home, entries) { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "provider_sync.scan_state_write_failed", + json!({ + "error": error.to_string(), + "path": provider_sync_scan_state_path(home).to_string_lossy(), + }), + ); + } +} + +fn invalidate_provider_sync_scan_state(home: &Path) -> anyhow::Result<()> { + let path = provider_sync_scan_state_path(home); + match fs::symlink_metadata(&path) { + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + } + if let Some(parent) = path.parent() { + ensure_path_components_not_reparse(home, parent)?; + } + ensure_not_reparse_or_symlink(&path)?; + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } +} + +fn committed_provider_sync_scan_state( + home: &Path, + backup_dir: &Path, + target_provider: &str, + mut entries: Vec, +) -> anyhow::Result> { + let transaction = read_session_transaction(backup_dir)?; + if transaction.status != SESSION_TRANSACTION_COMMITTED + || transaction.mode != SessionTransactionMode::Full + { + anyhow::bail!("provider-sync rollout scan state requires a committed full transaction"); + } + let committed = transaction + .entries + .iter() + .map(|entry| (entry.relative_path.as_str(), entry)) + .collect::>(); + let mut next_entries = Vec::with_capacity(entries.len()); + for mut state in entries.drain(..) { + let relative = validated_rollout_relative_path(&state.relative_path)?; + let path = home.join(relative); + if let Some(entry) = committed.get(state.relative_path.as_str()) { + state.size = entry.next_size; + state.sha256 = entry.next_sha256.clone(); + if state.session_meta_count > 0 { + state.providers = vec![target_provider.to_string()]; + } + let Ok(file) = File::open(&path) else { + continue; + }; + let Ok(metadata) = file.metadata() else { + continue; + }; + let Ok(file_identity) = codex_plus_core::settings::file_instance_identity(&file) else { + continue; + }; + let modified = metadata.modified().ok(); + if !metadata.is_file() || metadata.len() != entry.next_size || modified.is_none() { + continue; + } + state.file_identity = file_identity; + (state.modified_secs, state.modified_nanos) = system_time_parts(modified); + next_entries.push(state); + } else { + let providers = state.providers.iter().cloned().collect::>(); + let still_needs_rewrite = state.session_meta_count > 0 + && !state.marks_non_root_agent + && rewrite_needed_for_providers( + &providers, + target_provider, + &SessionRewriteMode::AllProviders, + ); + if !still_needs_rewrite && provider_sync_scan_state_matches(&path, &state) { + next_entries.push(state); + } + } + } + Ok(next_entries) +} + +fn persist_committed_provider_sync_scan_state_best_effort( + home: &Path, + backup_dir: &Path, + target_provider: &str, + entries: Vec, +) { + match committed_provider_sync_scan_state(home, backup_dir, target_provider, entries) { + Ok(entries) => persist_provider_sync_scan_state_best_effort(home, &entries), + Err(error) => { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "provider_sync.scan_state_commit_failed", + json!({ + "error": error.to_string(), + "backup_dir": backup_dir.to_string_lossy(), + }), + ); + } + } +} + +fn provider_sync_scan_state_matches(path: &Path, state: &ProviderSyncRolloutScanState) -> bool { + let Ok(file) = File::open(path) else { + return false; + }; + let Ok(metadata) = file.metadata() else { + return false; + }; + let Ok(file_identity) = codex_plus_core::settings::file_instance_identity(&file) else { + return false; + }; + let (modified_secs, modified_nanos) = system_time_parts(metadata.modified().ok()); + state.modified_secs.is_some() + && metadata.is_file() + && metadata.len() == state.size + && file_identity == state.file_identity + && modified_secs == state.modified_secs + && modified_nanos == state.modified_nanos +} + +fn rewrite_needed_for_providers( + providers: &HashSet, + target_provider: &str, + rewrite_mode: &SessionRewriteMode, +) -> bool { + match rewrite_mode { + SessionRewriteMode::AllProviders => { + providers.iter().any(|provider| provider != target_provider) + } + SessionRewriteMode::SourceProvider { source_provider } => providers + .iter() + .any(|provider| provider == "(missing)" || provider == source_provider), + } +} + +fn rollout_rewrite_from_scan_state( + state: &ProviderSyncRolloutScanState, + target_provider: &str, + rewrite_mode: &SessionRewriteMode, +) -> RolloutRewrite { + let providers = state.providers.iter().cloned().collect::>(); + RolloutRewrite { + rewrite_needed: rewrite_needed_for_providers(&providers, target_provider, rewrite_mode), + thread_id: state.thread_id.clone(), + cwd: state.cwd.clone(), + providers, + session_meta_count: state.session_meta_count, + has_user_event: state.has_user_event, + has_encrypted_content: state.has_encrypted_content, + marks_non_root_agent: state.marks_non_root_agent, + original_sha256: state.sha256.clone(), + original_size: state.size, + } +} + +fn provider_sync_cached_state_can_skip_body( + state: &ProviderSyncRolloutScanState, + target_provider: &str, + excluded_thread_ids: &HashSet, + explicit_user_thread_ids: &HashSet, +) -> bool { + if state.session_meta_count == 0 || state.marks_non_root_agent { + return true; + } + let is_explicit_user = state + .thread_id + .as_ref() + .is_some_and(|thread_id| explicit_user_thread_ids.contains(thread_id)); + if !is_explicit_user + && state + .thread_id + .as_ref() + .is_some_and(|thread_id| excluded_thread_ids.contains(thread_id)) + { + return true; + } + let providers = state.providers.iter().cloned().collect::>(); + !rewrite_needed_for_providers( + &providers, + target_provider, + &SessionRewriteMode::AllProviders, + ) +} + +fn scan_rollout_for_provider_sync_state( + home: &Path, + path: &Path, + target_provider: &str, + rewrite_mode: &SessionRewriteMode, +) -> std::io::Result<( + RolloutRewrite, + Option, + Option, +)> { + let mut file = File::open(path)?; + let before = file.metadata()?; + let before_modified = before.modified().ok(); + let before_identity = codex_plus_core::settings::file_instance_identity(&file).ok(); + let rewrite = + scan_rollout_session_meta_providers_from_file(&mut file, target_provider, rewrite_mode)?; + let after = file.metadata()?; + let after_modified = after.modified().ok(); + let after_identity = codex_plus_core::settings::file_instance_identity(&file).ok(); + let current = File::open(path)?; + let current_metadata = current.metadata()?; + let current_modified = current_metadata.modified().ok(); + let current_identity = codex_plus_core::settings::file_instance_identity(¤t).ok(); + let stable = before.len() == after.len() + && after.len() == rewrite.original_size + && after.len() == current_metadata.len() + && before_modified.is_some() + && before_modified == after_modified + && after_modified == current_modified + && before_identity.is_some() + && before_identity == after_identity + && after_identity == current_identity; + let mut providers = rewrite.providers.iter().cloned().collect::>(); + providers.sort(); + let relative_path = rollout_relative_path(home, path) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?; + let (modified_secs, modified_nanos) = system_time_parts(after_modified); + let state = stable.then(|| ProviderSyncRolloutScanState { + relative_path, + size: rewrite.original_size, + modified_secs, + modified_nanos, + file_identity: after_identity.expect("stable rollout scan has file identity"), + sha256: rewrite.original_sha256.clone(), + thread_id: rewrite.thread_id.clone(), + cwd: rewrite.cwd.clone(), + has_user_event: rewrite.has_user_event, + has_encrypted_content: rewrite.has_encrypted_content, + marks_non_root_agent: rewrite.marks_non_root_agent, + session_meta_count: rewrite.session_meta_count, + providers, + }); + Ok((rewrite, after_modified, state)) +} + fn collect_session_changes( home: &Path, target_provider: &str, excluded_thread_ids: &HashSet, explicit_user_thread_ids: &HashSet, ) -> anyhow::Result { + collect_session_changes_with_scanner( + home, + target_provider, + excluded_thread_ids, + explicit_user_thread_ids, + load_provider_sync_scan_state_best_effort(home), + scan_rollout_for_provider_sync_state, + ) +} + +fn collect_session_changes_with_scanner( + home: &Path, + target_provider: &str, + excluded_thread_ids: &HashSet, + explicit_user_thread_ids: &HashSet, + mut scan_state: HashMap, + mut scanner: F, +) -> anyhow::Result +where + F: FnMut( + &Path, + &Path, + &str, + &SessionRewriteMode, + ) -> std::io::Result<( + RolloutRewrite, + Option, + Option, + )>, +{ let mut collected = SessionChanges::default(); for path in rollout_files(home)? { - let rewrite = match scan_rollout_session_meta_providers( - &path, - target_provider, - &SessionRewriteMode::AllProviders, - ) { - Ok(rewrite) => rewrite, - Err(error) if is_locked_io_error(&error) => { - collected.skipped_locked_rollout_files.push(path); - continue; + let relative_path = rollout_relative_path(home, &path)?; + let cached = scan_state + .remove(&relative_path) + .filter(|state| provider_sync_scan_state_matches(&path, state)) + .filter(|state| { + provider_sync_cached_state_can_skip_body( + state, + target_provider, + excluded_thread_ids, + explicit_user_thread_ids, + ) + }); + let (rewrite, original_mtime, scan_state_entry) = match cached { + Some(state) => { + let original_mtime = fs::metadata(&path).and_then(|value| value.modified()).ok(); + ( + rollout_rewrite_from_scan_state( + &state, + target_provider, + &SessionRewriteMode::AllProviders, + ), + original_mtime, + Some(state), + ) } - Err(error) => return Err(error.into()), + None => match scanner( + home, + &path, + target_provider, + &SessionRewriteMode::AllProviders, + ) { + Ok(scanned) => scanned, + Err(error) if is_locked_io_error(&error) => { + collected.skipped_locked_rollout_files.push(path); + continue; + } + Err(error) => return Err(error.into()), + }, }; + if let Some(scan_state_entry) = scan_state_entry { + collected.scan_state_entries.push(scan_state_entry); + } if rewrite.session_meta_count == 0 { continue; } @@ -1691,7 +2184,6 @@ fn collect_session_changes( .or_insert(0) += 1; } } - let original_mtime = fs::metadata(&path).and_then(|m| m.modified()).ok(); collected.changes.push(SessionChange { path, original_sha256: rewrite.original_sha256, @@ -1898,9 +2390,18 @@ fn scan_rollout_session_meta_providers( path: &Path, target_provider: &str, rewrite_mode: &SessionRewriteMode, +) -> std::io::Result { + let mut file = File::open(path)?; + scan_rollout_session_meta_providers_from_file(&mut file, target_provider, rewrite_mode) +} + +fn scan_rollout_session_meta_providers_from_file( + file: &mut File, + target_provider: &str, + rewrite_mode: &SessionRewriteMode, ) -> std::io::Result { let mut rewrite = RolloutRewrite::default(); - let file = File::open(path)?; + file.seek(SeekFrom::Start(0))?; let mut reader = BufReader::new(file); let mut line = Vec::new(); let mut hasher = Sha256::new(); @@ -2671,6 +3172,9 @@ fn apply_session_changes( changes: &[SessionChange], ) -> anyhow::Result { let apply_result = (|| -> anyhow::Result { + if !changes.is_empty() { + invalidate_provider_sync_scan_state(home)?; + } let mut applied = AppliedSessionChanges::default(); let mut transaction = read_session_transaction(backup_dir)?; for change in changes { @@ -5654,6 +6158,7 @@ fn now_secs() -> u64 { #[cfg(test)] mod bounded_memory_tests { use super::*; + use std::cell::Cell; use std::io::{BufWriter, Write}; use tempfile::tempdir; @@ -5740,6 +6245,279 @@ mod bounded_memory_tests { assert_eq!(rewrite.providers, HashSet::from(["openai".to_string()])); } + #[test] + fn unchanged_rollout_reuses_committed_scan_state_without_reading_the_body() { + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + let rollout = home.join("sessions/rollout-cached.jsonl"); + fs::create_dir_all(rollout.parent().unwrap()).unwrap(); + fs::write( + &rollout, + format!( + "{}\n{}\n", + json!({ + "type": "session_meta", + "payload": { + "id": "thread-cached", + "model_provider": "openai", + "cwd": "C:/cached" + } + }), + json!({"type": "event_msg", "payload": {"type": "user_message"}}) + ), + ) + .unwrap(); + let (_, _, state) = scan_rollout_for_provider_sync_state( + &home, + &rollout, + "openai", + &SessionRewriteMode::AllProviders, + ) + .unwrap(); + let state = state.unwrap(); + persist_provider_sync_scan_state(&home, std::slice::from_ref(&state)).unwrap(); + + let collected = collect_session_changes_with_scanner( + &home, + "openai", + &HashSet::new(), + &HashSet::new(), + load_provider_sync_scan_state(&home).unwrap(), + |_, _, _, _| panic!("unchanged rollout body should not be opened"), + ) + .unwrap(); + + assert_eq!(collected.changes.len(), 1); + assert!(!collected.changes[0].rewrite_needed); + assert!(collected.changes[0].has_user_event); + assert_eq!(collected.changes[0].cwd.as_deref(), Some("C:/cached")); + assert_eq!(collected.scan_state_entries, vec![state]); + } + + #[test] + fn changed_target_or_missing_mtime_forces_a_full_rollout_scan() { + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + let rollout = home.join("sessions/rollout-rescan.jsonl"); + fs::create_dir_all(rollout.parent().unwrap()).unwrap(); + fs::write( + &rollout, + format!( + "{}\n", + json!({ + "type": "session_meta", + "payload": { + "id": "thread-rescan", + "model_provider": "openai" + } + }) + ), + ) + .unwrap(); + let (_, _, state) = scan_rollout_for_provider_sync_state( + &home, + &rollout, + "openai", + &SessionRewriteMode::AllProviders, + ) + .unwrap(); + let state = state.unwrap(); + let scans = Cell::new(0); + let collected = collect_session_changes_with_scanner( + &home, + "custom", + &HashSet::new(), + &HashSet::new(), + HashMap::from([(state.relative_path.clone(), state.clone())]), + |home, path, target, mode| { + scans.set(scans.get() + 1); + scan_rollout_for_provider_sync_state(home, path, target, mode) + }, + ) + .unwrap(); + assert_eq!(scans.get(), 1); + assert!(collected.changes[0].rewrite_needed); + + let mut legacy = state; + legacy.modified_secs = None; + legacy.modified_nanos = None; + let scans = Cell::new(0); + collect_session_changes_with_scanner( + &home, + "openai", + &HashSet::new(), + &HashSet::new(), + HashMap::from([(legacy.relative_path.clone(), legacy)]), + |home, path, target, mode| { + scans.set(scans.get() + 1); + scan_rollout_for_provider_sync_state(home, path, target, mode) + }, + ) + .unwrap(); + assert_eq!(scans.get(), 1); + } + + #[test] + fn same_path_size_and_mtime_still_reject_a_replaced_file_instance() { + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + let rollout = home.join("sessions/rollout-replaced.jsonl"); + fs::create_dir_all(rollout.parent().unwrap()).unwrap(); + let bytes = format!( + "{}\n", + json!({ + "type": "session_meta", + "payload": {"id": "thread-replaced", "model_provider": "openai"} + }) + ); + fs::write(&rollout, &bytes).unwrap(); + let (_, _, state) = scan_rollout_for_provider_sync_state( + &home, + &rollout, + "openai", + &SessionRewriteMode::AllProviders, + ) + .unwrap(); + let state = state.unwrap(); + let original_mtime = fs::metadata(&rollout).unwrap().modified().unwrap(); + let original_identity = state.file_identity.clone(); + let replacement = rollout.with_extension("replacement"); + fs::write(&replacement, &bytes).unwrap(); + fs::File::options() + .write(true) + .open(&replacement) + .unwrap() + .set_times(std::fs::FileTimes::new().set_modified(original_mtime)) + .unwrap(); + fs::remove_file(&rollout).unwrap(); + fs::rename(&replacement, &rollout).unwrap(); + let replacement_file = File::open(&rollout).unwrap(); + assert_ne!( + codex_plus_core::settings::file_instance_identity(&replacement_file).unwrap(), + original_identity + ); + assert_eq!(fs::metadata(&rollout).unwrap().len(), state.size); + assert_eq!( + system_time_parts(fs::metadata(&rollout).unwrap().modified().ok()), + (state.modified_secs, state.modified_nanos) + ); + assert!(!provider_sync_scan_state_matches(&rollout, &state)); + } + + #[test] + fn scan_rule_digest_or_corrupt_state_invalidates_the_entire_cache() { + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + let path = provider_sync_scan_state_path(&home); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + &path, + serde_json::to_vec_pretty(&ProviderSyncRolloutScanStateManifest { + version: PROVIDER_SYNC_SCAN_STATE_VERSION, + namespace: PROVIDER_SYNC_SCAN_STATE_NAMESPACE.to_string(), + rules_sha256: "0".repeat(64), + rollout_roots: session_transaction_rollout_roots(&home).unwrap(), + entries: Vec::new(), + }) + .unwrap(), + ) + .unwrap(); + assert!(load_provider_sync_scan_state(&home).is_err()); + + fs::write(&path, b"not-json").unwrap(); + assert!(load_provider_sync_scan_state(&home).is_err()); + assert!(load_provider_sync_scan_state_best_effort(&home).is_empty()); + + let oversized = File::create(&path).unwrap(); + oversized + .set_len(PROVIDER_SYNC_SCAN_STATE_MAX_BYTES + 1) + .unwrap(); + drop(oversized); + assert!(load_provider_sync_scan_state(&home).is_err()); + } + + #[test] + fn replacing_a_rollout_root_invalidates_the_entire_cache() { + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + let sessions = home.join("sessions"); + let rollout = sessions.join("rollout-root.jsonl"); + fs::create_dir_all(&sessions).unwrap(); + fs::write( + &rollout, + format!( + "{}\n", + json!({ + "type": "session_meta", + "payload": {"id": "thread-root", "model_provider": "openai"} + }) + ), + ) + .unwrap(); + let (_, _, state) = scan_rollout_for_provider_sync_state( + &home, + &rollout, + "openai", + &SessionRewriteMode::AllProviders, + ) + .unwrap(); + persist_provider_sync_scan_state(&home, &[state.unwrap()]).unwrap(); + + fs::rename(&sessions, home.join("sessions-old")).unwrap(); + fs::create_dir(&sessions).unwrap(); + + assert!(load_provider_sync_scan_state(&home).is_err()); + } + + #[cfg(unix)] + #[test] + fn scan_state_parent_creation_rejects_a_symlink_before_touching_its_target() { + use std::os::unix::fs::symlink; + + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + let outside = tmp.path().join("outside"); + fs::create_dir(&home).unwrap(); + fs::create_dir(&outside).unwrap(); + symlink(&outside, home.join("backups_state")).unwrap(); + + assert!(persist_provider_sync_scan_state(&home, &[]).is_err()); + assert!(!outside.join("provider-sync").exists()); + } + + #[cfg(windows)] + #[test] + fn scan_state_parent_creation_rejects_a_junction_before_touching_its_target() { + use std::process::Command; + + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + let outside = tmp.path().join("outside"); + fs::create_dir(&home).unwrap(); + fs::create_dir(&outside).unwrap(); + let link = home.join("backups_state"); + let output = Command::new("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "New-Item -ItemType Junction -Path $env:CODEXPP_TEST_LINK -Target $env:CODEXPP_TEST_TARGET | Out-Null", + ]) + .env("CODEXPP_TEST_LINK", &link) + .env("CODEXPP_TEST_TARGET", &outside) + .output() + .unwrap(); + assert!( + output.status.success(), + "junction creation failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + assert!(persist_provider_sync_scan_state(&home, &[]).is_err()); + assert!(!outside.join("provider-sync").exists()); + fs::remove_dir(link).unwrap(); + } + #[test] fn prepared_backup_handle_keeps_the_verified_file_instance() { let tmp = tempdir().unwrap(); diff --git a/crates/codex-plus-data/tests/provider_sync.rs b/crates/codex-plus-data/tests/provider_sync.rs index ddeff2312..a1ec29dec 100644 --- a/crates/codex-plus-data/tests/provider_sync.rs +++ b/crates/codex-plus-data/tests/provider_sync.rs @@ -1990,6 +1990,10 @@ fn remote_control_finalization_uses_only_recorded_rollout_and_preserves_full_syn let other_rollout = home.join("sessions/rollout-other.jsonl"); write_rollout(&target_rollout, "openai", "mobile", "C:/workspace"); write_rollout(&other_rollout, "openai", "other", "C:/workspace"); + let seeded = run_provider_sync_with_target(Some(&home), Some("openai")); + assert_eq!(seeded.status, ProviderSyncStatus::Synced, "{}", seeded.message); + let scan_state_path = home.join("backups_state/provider-sync/rollout-scan-state.json"); + assert!(scan_state_path.is_file()); create_remote_control_state_db( &home.join("state_5.sqlite"), &[ @@ -2025,6 +2029,7 @@ fn remote_control_finalization_uses_only_recorded_rollout_and_preserves_full_syn assert_eq!(result.status, ProviderSyncStatus::Synced); assert_eq!(result.changed_session_files, 1); assert_eq!(result.sqlite_catalog_rows_inserted, 1); + assert!(!scan_state_path.exists()); let target_first: serde_json::Value = serde_json::from_str( fs::read_to_string(&target_rollout) .unwrap() @@ -2553,6 +2558,10 @@ fn provider_sync_restores_rollout_first_line_when_later_step_fails() { fs::write(home.join("config.toml"), "model_provider = \"apigather\"\n").unwrap(); let rollout = home.join("sessions/rollout-needs-rewrite.jsonl"); write_rollout(&rollout, "openai", "thread-1", "C:/workspace"); + let seeded = run_provider_sync_with_target(Some(&home), Some("openai")); + assert_eq!(seeded.status, ProviderSyncStatus::Synced, "{}", seeded.message); + let scan_state_path = home.join("backups_state/provider-sync/rollout-scan-state.json"); + assert!(scan_state_path.is_file()); let original_first_line = fs::read_to_string(&rollout) .unwrap() .lines() @@ -2588,6 +2597,7 @@ fn provider_sync_restores_rollout_first_line_when_later_step_fails() { .unwrap() .to_string(); assert_eq!(restored_first_line, original_first_line); + assert!(!scan_state_path.exists()); } #[test] @@ -2812,6 +2822,99 @@ fn provider_sync_preserves_rollout_mtime() { ); } +#[test] +fn provider_sync_commits_scan_state_for_the_rewritten_file_instance() { + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + fs::create_dir(&home).unwrap(); + fs::write(home.join("config.toml"), "model_provider = \"custom\"\n").unwrap(); + let rollout = home.join("sessions/rollout-state.jsonl"); + write_rollout(&rollout, "openai", "thread-state", "C:/workspace"); + + let first = run_provider_sync(Some(&home)); + + assert_eq!(first.status, ProviderSyncStatus::Synced, "{}", first.message); + assert_eq!(first.changed_session_files, 1); + let state_path = home.join("backups_state/provider-sync/rollout-scan-state.json"); + let state: serde_json::Value = + serde_json::from_slice(&fs::read(&state_path).unwrap()).unwrap(); + let entries = state["entries"].as_array().unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0]["providers"], json!(["custom"])); + assert!(entries[0]["fileIdentity"].as_str().unwrap().contains(':')); + let bytes = fs::read(&rollout).unwrap(); + assert_eq!(entries[0]["size"], bytes.len() as u64); + assert_eq!( + entries[0]["sha256"], + format!("{:x}", Sha256::digest(&bytes)) + ); + + let second = run_provider_sync(Some(&home)); + + assert_eq!(second.status, ProviderSyncStatus::Synced, "{}", second.message); + assert_eq!(second.changed_session_files, 0); + assert!(second.backup_dir.is_none()); + assert!(second.message.contains("already up to date")); +} + +#[test] +fn provider_sync_cache_write_failure_does_not_fail_an_up_to_date_sync() { + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + fs::create_dir_all(home.join("sessions")).unwrap(); + fs::write(home.join("config.toml"), "model_provider = \"openai\"\n").unwrap(); + write_rollout( + &home.join("sessions/rollout-cache-write.jsonl"), + "openai", + "thread-cache-write", + "C:/workspace", + ); + fs::create_dir_all(home.join("backups_state")).unwrap(); + fs::write(home.join("backups_state/provider-sync"), b"block-cache-directory").unwrap(); + + let result = run_provider_sync(Some(&home)); + + assert_eq!(result.status, ProviderSyncStatus::Synced, "{}", result.message); + assert_eq!(result.changed_session_files, 0); + assert!(result.message.contains("already up to date")); +} + +#[test] +fn provider_sync_refuses_rollout_mutation_when_scan_state_cannot_be_invalidated() { + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + fs::create_dir(&home).unwrap(); + fs::write(home.join("config.toml"), "model_provider = \"custom\"\n").unwrap(); + let rollout = home.join("sessions/rollout-invalidation.jsonl"); + write_rollout( + &rollout, + "openai", + "thread-invalidation", + "C:/workspace", + ); + let scan_state_path = home.join("backups_state/provider-sync/rollout-scan-state.json"); + fs::create_dir_all(&scan_state_path).unwrap(); + + let result = run_provider_sync(Some(&home)); + + assert_eq!(result.status, ProviderSyncStatus::Skipped); + let first: serde_json::Value = serde_json::from_str( + fs::read_to_string(&rollout).unwrap().lines().next().unwrap(), + ) + .unwrap(); + assert_eq!(first["payload"]["model_provider"], "openai"); + let backup_root = home.join("backups_state/provider-sync"); + let transaction = fs::read_dir(&backup_root) + .unwrap() + .filter_map(Result::ok) + .map(|entry| entry.path().join("session-transaction.json")) + .find(|path| path.is_file()) + .unwrap(); + let transaction: serde_json::Value = + serde_json::from_slice(&fs::read(transaction).unwrap()).unwrap(); + assert_eq!(transaction["status"], "rolled_back"); +} + #[test] fn provider_sync_streams_large_payloads_without_copying_them_into_the_journal() { let tmp = tempdir().unwrap(); @@ -2910,6 +3013,19 @@ fn provider_sync_manifest_indexes_only_rollouts_that_enter_the_transaction() { .unwrap() .ends_with("rollout-b-applied.jsonl")); assert_eq!(manifest[0]["sessionMetaBackup"], "session-meta/0.jsonl"); + let scan_state: serde_json::Value = serde_json::from_slice( + &fs::read(home.join("backups_state/provider-sync/rollout-scan-state.json")).unwrap(), + ) + .unwrap(); + let entries = scan_state["entries"].as_array().unwrap(); + assert_eq!(entries.len(), 1); + assert!( + entries[0]["relativePath"] + .as_str() + .unwrap() + .ends_with("rollout-b-applied.jsonl") + ); + assert_eq!(entries[0]["providers"], json!(["custom"])); } #[test] @@ -2948,6 +3064,46 @@ fn provider_sync_bounded_memory_benchmark() { } } +#[test] +#[ignore = "synthetic unchanged-rollout prefilter benchmark"] +fn provider_sync_unchanged_rollout_prefilter_benchmark() { + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + fs::create_dir(&home).unwrap(); + fs::write(home.join("config.toml"), "model_provider = \"custom\"\n").unwrap(); + let file_count = 118_usize; + let payload_bytes = 1024 * 1024; + for index in 0..file_count { + write_large_rollout( + &home.join(format!("sessions/rollout-prefilter-{index}.jsonl")), + "custom", + &format!("prefilter-{index}"), + payload_bytes, + ); + } + + let full_started = Instant::now(); + let first = run_provider_sync(Some(&home)); + let full_elapsed = full_started.elapsed(); + let unchanged_started = Instant::now(); + let second = run_provider_sync(Some(&home)); + let unchanged_elapsed = unchanged_started.elapsed(); + + assert_eq!(first.status, ProviderSyncStatus::Synced, "{}", first.message); + assert_eq!(second.status, ProviderSyncStatus::Synced, "{}", second.message); + assert_eq!(first.changed_session_files, 0); + assert_eq!(second.changed_session_files, 0); + assert!(first.backup_dir.is_none()); + assert!(second.backup_dir.is_none()); + eprintln!( + "provider-sync prefilter {} files / {} MiB: full {:?}, unchanged {:?}", + file_count, + file_count * payload_bytes / (1024 * 1024), + full_elapsed, + unchanged_elapsed + ); +} + #[test] fn provider_sync_recovers_an_interrupted_rollout_transaction_before_resuming() { let tmp = tempdir().unwrap(); From f7fce438b21bca81578459bdf9f311e55807c7f7 Mon Sep 17 00:00:00 2001 From: Yuimi_chaya <124485273+Yuimi-chaya@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:21:51 +0800 Subject: [PATCH 09/12] fix(launcher): restore protocol proxy startup invariants --- apps/codex-plus-launcher/src/main.rs | 71 ++++++-- crates/codex-plus-core/src/launcher.rs | 16 ++ crates/codex-plus-core/src/relay_config.rs | 75 +++++++- crates/codex-plus-core/tests/launcher.rs | 18 ++ crates/codex-plus-core/tests/relay_config.rs | 169 ++++++++++++++++++- 5 files changed, 330 insertions(+), 19 deletions(-) diff --git a/apps/codex-plus-launcher/src/main.rs b/apps/codex-plus-launcher/src/main.rs index d018be40f..130edd496 100644 --- a/apps/codex-plus-launcher/src/main.rs +++ b/apps/codex-plus-launcher/src/main.rs @@ -73,11 +73,7 @@ async fn main() -> Result<()> { Ok(()) } -async fn launcher_main( - args: Vec, - helper_only: bool, - options: LaunchOptions, -) -> Result<()> { +async fn launcher_main(args: Vec, helper_only: bool, options: LaunchOptions) -> Result<()> { if helper_only { let hooks = LauncherHooks::default(); hooks.start_helper(options.helper_port).await?; @@ -132,13 +128,15 @@ fn acquire_single_instance_guard_with_retry( } Ok(Some(guard)) } - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { - log_launcher_already_running(debug_port); - Ok(None) - } - Err(error) if error.kind() == std::io::ErrorKind::AddrInUse => { + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::AddrInUse + ) => + { log_launcher_already_running(debug_port); - if allow_stale_recovery && should_recover_stale_launcher(debug_port) { + let stale = allow_stale_recovery && should_recover_stale_launcher(debug_port); + if should_retry_stale_launcher_guard(error.kind(), allow_stale_recovery, stale) { codex_plus_core::watcher::stop_launcher_processes(); std::thread::sleep(std::time::Duration::from_millis(250)); return acquire_single_instance_guard_with_retry(debug_port, false); @@ -156,6 +154,19 @@ fn acquire_single_instance_guard_with_retry( } } +fn should_retry_stale_launcher_guard( + error_kind: std::io::ErrorKind, + allow_stale_recovery: bool, + stale_launcher: bool, +) -> bool { + allow_stale_recovery + && stale_launcher + && matches!( + error_kind, + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::AddrInUse + ) +} + fn try_acquire_single_instance_guard() -> std::io::Result { codex_plus_core::ports::acquire_resilient_loopback_port_guard( @@ -513,6 +524,15 @@ impl LaunchHooks for LauncherHooks { self.core.apply_active_relay_profile(settings).await } + async fn ensure_active_protocol_proxy_config( + &self, + settings: &codex_plus_core::settings::BackendSettings, + ) -> anyhow::Result<()> { + self.core + .ensure_active_protocol_proxy_config(settings) + .await + } + async fn ensure_plugin_marketplace_config( &self, settings: &codex_plus_core::settings::BackendSettings, @@ -1125,6 +1145,30 @@ mod tests { assert!(source.contains("status: \"failed\".to_string()")); } + #[test] + fn stale_launcher_recovery_covers_port_and_fallback_lock_conflicts() { + assert!(should_retry_stale_launcher_guard( + std::io::ErrorKind::WouldBlock, + true, + true + )); + assert!(should_retry_stale_launcher_guard( + std::io::ErrorKind::AddrInUse, + true, + true + )); + assert!(!should_retry_stale_launcher_guard( + std::io::ErrorKind::WouldBlock, + false, + true + )); + assert!(!should_retry_stale_launcher_guard( + std::io::ErrorKind::PermissionDenied, + true, + true + )); + } + #[test] fn existing_launcher_path_drains_pending_remote_control_recovery_before_activation() { let source = include_str!("main.rs"); @@ -1204,6 +1248,11 @@ mod tests { assert!(source.contains("inject_with_context(debug_port, helper_port, ctx, runtime)")); assert!(source.contains("async fn ensure_plugin_marketplace_config")); assert!(source.contains("self.core.ensure_plugin_marketplace_config(settings).await")); + assert!(source.contains("async fn ensure_active_protocol_proxy_config")); + assert!( + source + .contains("self.core\n .ensure_active_protocol_proxy_config(settings)") + ); } #[tokio::test] diff --git a/crates/codex-plus-core/src/launcher.rs b/crates/codex-plus-core/src/launcher.rs index cda4a140e..f00b2bd61 100644 --- a/crates/codex-plus-core/src/launcher.rs +++ b/crates/codex-plus-core/src/launcher.rs @@ -171,6 +171,12 @@ pub trait LaunchHooks: Send + Sync { async fn apply_active_relay_profile(&self, _settings: &BackendSettings) -> anyhow::Result<()> { Ok(()) } + async fn ensure_active_protocol_proxy_config( + &self, + _settings: &BackendSettings, + ) -> anyhow::Result<()> { + Ok(()) + } async fn ensure_plugin_marketplace_config( &self, _settings: &BackendSettings, @@ -387,6 +393,7 @@ where let protocol_proxy_enabled = relay_protocol_proxy_enabled(&settings) || remote_control_provider_proxy_enabled(&settings); if protocol_proxy_enabled { + hooks.ensure_active_protocol_proxy_config(&settings).await?; helper_port = crate::protocol_proxy::DEFAULT_PROTOCOL_PROXY_PORT; } if settings.enhancements_enabled || protocol_proxy_enabled { @@ -685,6 +692,15 @@ impl LaunchHooks for DefaultLaunchHooks { Ok(()) } + async fn ensure_active_protocol_proxy_config( + &self, + settings: &BackendSettings, + ) -> anyhow::Result<()> { + let home = crate::relay_config::default_codex_home_dir(); + crate::relay_config::ensure_active_protocol_proxy_config_in_home(&home, settings)?; + Ok(()) + } + async fn ensure_plugin_marketplace_config( &self, settings: &BackendSettings, diff --git a/crates/codex-plus-core/src/relay_config.rs b/crates/codex-plus-core/src/relay_config.rs index 63dbf72da..7a7094924 100644 --- a/crates/codex-plus-core/src/relay_config.rs +++ b/crates/codex-plus-core/src/relay_config.rs @@ -6,7 +6,7 @@ use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use toml_edit::{DocumentMut, Item, Table, TableLike}; -use crate::settings::{RelayProfile, RelayProtocol, RelaySessionProvider}; +use crate::settings::{BackendSettings, RelayProfile, RelayProtocol, RelaySessionProvider}; const RELAY_PROVIDER: &str = "custom"; /// 我们代管的 config.toml 上下文表。 @@ -297,6 +297,79 @@ pub fn responses_proxy_configured_in_home(home: &Path) -> bool { ) } +pub fn ensure_active_protocol_proxy_config_in_home( + home: &Path, + settings: &BackendSettings, +) -> anyhow::Result { + let profile = settings.active_relay_profile(); + let transport_uses_proxy = settings.active_aggregate_relay_profile().is_some() + || profile.protocol == RelayProtocol::ChatCompletions + || profile.has_model_routes(); + let openai_identity_uses_proxy = settings.active_relay_session_provider() + == RelaySessionProvider::Openai + || (profile.relay_mode == crate::settings::RelayMode::Official + && profile.official_mix_api_key); + if !transport_uses_proxy && !openai_identity_uses_proxy { + return Ok(false); + } + + let config_path = home.join("config.toml"); + let existing = std::fs::read_to_string(&config_path) + .with_context(|| format!("读取 {} 失败", config_path.display()))?; + let mut doc = parse_toml_document(&existing)?; + let managed = managed_openai_base_url(); + let mut changed = false; + + if transport_uses_proxy { + let session_provider_id = active_session_provider_id(&doc); + let transport_provider_id = if session_provider_id == "openai" { + RELAY_PROVIDER.to_string() + } else { + active_or_default_provider_id(&doc) + }; + let provider = doc + .get_mut("model_providers") + .and_then(Item::as_table_mut) + .and_then(|providers| providers.get_mut(&transport_provider_id)) + .and_then(Item::as_table_mut) + .ok_or_else(|| { + anyhow::anyhow!("活动协议代理需要现有 model_providers.{transport_provider_id} 配置") + })?; + let current = provider + .get("base_url") + .and_then(Item::as_str) + .map(str::trim); + if current != Some(managed.as_str()) { + provider["base_url"] = toml_edit::value(managed.as_str()); + changed = true; + } + } + + if openai_identity_uses_proxy { + let before = doc + .get(OPENAI_BASE_URL_KEY) + .and_then(Item::as_str) + .map(str::trim) + .map(ToString::to_string); + update_remote_control_openai_base_url(&mut doc, true); + let after = doc + .get(OPENAI_BASE_URL_KEY) + .and_then(Item::as_str) + .map(str::trim) + .map(ToString::to_string); + changed |= before != after; + } + + if !changed { + return Ok(false); + } + crate::settings::atomic_write( + &config_path, + ensure_trailing_newline(doc.to_string()).as_bytes(), + )?; + Ok(true) +} + pub fn apply_relay_config_to_home( home: &Path, base_url: &str, diff --git a/crates/codex-plus-core/tests/launcher.rs b/crates/codex-plus-core/tests/launcher.rs index ec369852c..011d1a44d 100644 --- a/crates/codex-plus-core/tests/launcher.rs +++ b/crates/codex-plus-core/tests/launcher.rs @@ -1663,7 +1663,17 @@ async fn launch_starts_helper_when_model_routing_is_enabled() { let before_stop = events.lock().unwrap().clone(); assert!(before_stop.contains(&"select-helper:58000".to_string())); + assert!(before_stop.contains(&"ensure-protocol-proxy-config".to_string())); assert!(before_stop.contains(&"start-helper:57321".to_string())); + let ensure = before_stop + .iter() + .position(|event| event == "ensure-protocol-proxy-config") + .unwrap(); + let start = before_stop + .iter() + .position(|event| event == "start-helper:57321") + .unwrap(); + assert!(ensure < start); assert!(!before_stop.contains(&"inject:9229:57321".to_string())); handle.wait_for_codex_exit().await.unwrap(); @@ -2004,6 +2014,14 @@ impl LaunchHooks for FakeHooks { Ok(()) } + async fn ensure_active_protocol_proxy_config( + &self, + _settings: &BackendSettings, + ) -> anyhow::Result<()> { + self.event("ensure-protocol-proxy-config"); + Ok(()) + } + async fn ensure_plugin_marketplace_config( &self, _settings: &BackendSettings, diff --git a/crates/codex-plus-core/tests/relay_config.rs b/crates/codex-plus-core/tests/relay_config.rs index fd6738ee6..3953d2467 100644 --- a/crates/codex-plus-core/tests/relay_config.rs +++ b/crates/codex-plus-core/tests/relay_config.rs @@ -6,14 +6,16 @@ use codex_plus_core::relay_config::{ backfill_relay_profile_from_home, backfill_relay_profile_from_home_with_common, chatgpt_auth_status_from_home, cleanup_unsupported_approval_policies_in_home, clear_relay_config_to_home, clear_relay_config_to_home_with_auth, - delete_context_entry_from_common_config, extract_common_config_from_config, - list_context_entries_from_common_config, normalize_relay_profile_for_storage, - prepare_common_config_for_apply, relay_config_status_from_home, relay_profile_api_key, - sanitize_common_config_contents, set_codex_goals_feature_in_home, - strip_common_config_from_config, sync_live_config_context_entries, - upsert_context_entry_in_common_config, + delete_context_entry_from_common_config, ensure_active_protocol_proxy_config_in_home, + extract_common_config_from_config, list_context_entries_from_common_config, + normalize_relay_profile_for_storage, prepare_common_config_for_apply, + relay_config_status_from_home, relay_profile_api_key, sanitize_common_config_contents, + set_codex_goals_feature_in_home, strip_common_config_from_config, + sync_live_config_context_entries, upsert_context_entry_in_common_config, +}; +use codex_plus_core::settings::{ + BackendSettings, RelayMode, RelayModelRoute, RelayProfile, RelayProtocol, }; -use codex_plus_core::settings::{RelayMode, RelayModelRoute, RelayProfile, RelayProtocol}; fn write_remote_plugin_marketplace_snapshot(home: &std::path::Path) { let root = home.join(".tmp").join("plugins-remote"); @@ -749,6 +751,159 @@ base_url = "https://responses.example.test/v1" assert_eq!(backfilled.model_routes, profile.model_routes); } +#[test] +fn launcher_repairs_only_the_live_model_route_proxy_endpoint() { + let temp = tempfile::tempdir().unwrap(); + let config_path = temp.path().join("config.toml"); + std::fs::write( + &config_path, + r#"model = "gpt-5.6-sol" +model_provider = "relay-source" +custom_setting = "preserve-me" + +[model_providers.relay-source] +name = "Source" +wire_api = "responses" +base_url = "https://source.example.test/v1" +experimental_bearer_token = "sk-preserve" + +[plugins.example] +enabled = true +"#, + ) + .unwrap(); + let settings = BackendSettings { + active_relay_id: "source".to_string(), + relay_profiles: vec![ + RelayProfile { + id: "source".to_string(), + config_contents: r#"model_provider = "relay-source" + +[model_providers.relay-source] +base_url = "https://source.example.test/v1" +"# + .to_string(), + model_routes: vec![RelayModelRoute { + model: "gpt-5.6-luna".to_string(), + target_relay_id: "target".to_string(), + target_model: String::new(), + }], + ..RelayProfile::default() + }, + RelayProfile { + id: "target".to_string(), + ..RelayProfile::default() + }, + ], + ..BackendSettings::default() + }; + + assert!(ensure_active_protocol_proxy_config_in_home(temp.path(), &settings).unwrap()); + let updated = std::fs::read_to_string(&config_path).unwrap(); + assert!(updated.contains(r#"base_url = "http://127.0.0.1:57321/v1""#)); + assert!(updated.contains(r#"experimental_bearer_token = "sk-preserve""#)); + assert!(updated.contains(r#"custom_setting = "preserve-me""#)); + assert!(updated.contains("[plugins.example]")); + assert!(!ensure_active_protocol_proxy_config_in_home(temp.path(), &settings).unwrap()); +} + +#[test] +fn launcher_does_not_rewrite_pure_responses_profiles_without_proxy_features() { + let temp = tempfile::tempdir().unwrap(); + let config_path = temp.path().join("config.toml"); + let original = r#"model_provider = "custom" + +[model_providers.custom] +base_url = "https://responses.example.test/v1" +"#; + std::fs::write(&config_path, original).unwrap(); + let settings = BackendSettings { + relay_profiles: vec![RelayProfile { + base_url: "https://responses.example.test/v1".to_string(), + protocol: RelayProtocol::Responses, + ..RelayProfile::default() + }], + ..BackendSettings::default() + }; + + assert!(!ensure_active_protocol_proxy_config_in_home(temp.path(), &settings).unwrap()); + assert_eq!(std::fs::read_to_string(config_path).unwrap(), original); +} + +#[test] +fn launcher_repairs_route_transport_and_openai_identity_endpoints_together() { + let temp = tempfile::tempdir().unwrap(); + let config_path = temp.path().join("config.toml"); + std::fs::write( + &config_path, + r#"model_provider = "openai" + +[model_providers.custom] +base_url = "https://source.example.test/v1" +experimental_bearer_token = "sk-preserve" +"#, + ) + .unwrap(); + let settings = BackendSettings { + active_relay_id: "source".to_string(), + relay_profiles: vec![ + RelayProfile { + id: "source".to_string(), + config_contents: "model_provider = \"openai\"\n".to_string(), + model_routes: vec![RelayModelRoute { + model: "gpt-5.6-luna".to_string(), + target_relay_id: "target".to_string(), + target_model: String::new(), + }], + ..RelayProfile::default() + }, + RelayProfile { + id: "target".to_string(), + ..RelayProfile::default() + }, + ], + ..BackendSettings::default() + }; + + assert!(ensure_active_protocol_proxy_config_in_home(temp.path(), &settings).unwrap()); + let updated = std::fs::read_to_string(config_path).unwrap(); + assert!(updated.contains(r#"openai_base_url = "http://127.0.0.1:57321/v1""#)); + assert!(updated.contains("[model_providers.custom]")); + assert!(updated.contains(r#"base_url = "http://127.0.0.1:57321/v1""#)); + assert!(updated.contains(r#"experimental_bearer_token = "sk-preserve""#)); +} + +#[test] +fn launcher_official_mix_repairs_only_managed_openai_endpoint() { + let temp = tempfile::tempdir().unwrap(); + let config_path = temp.path().join("config.toml"); + std::fs::write( + &config_path, + r#"model_provider = "custom" + +[model_providers.custom] +base_url = "https://responses.example.test/v1" +"#, + ) + .unwrap(); + let settings = BackendSettings { + active_relay_id: "official-mix".to_string(), + relay_profiles: vec![RelayProfile { + id: "official-mix".to_string(), + relay_mode: RelayMode::Official, + official_mix_api_key: true, + protocol: RelayProtocol::Responses, + ..RelayProfile::default() + }], + ..BackendSettings::default() + }; + + assert!(ensure_active_protocol_proxy_config_in_home(temp.path(), &settings).unwrap()); + let updated = std::fs::read_to_string(config_path).unwrap(); + assert!(updated.contains(r#"openai_base_url = "http://127.0.0.1:57321/v1""#)); + assert!(updated.contains(r#"base_url = "https://responses.example.test/v1""#)); +} + #[test] fn apply_aggregate_relay_points_codex_to_local_responses_proxy_without_snapshot() { let temp = tempfile::tempdir().unwrap(); From c742bce51d8eea0d157bb555d8acb0c716d6f37a Mon Sep 17 00:00:00 2001 From: Yuimi_chaya <124485273+Yuimi-chaya@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:47:56 +0800 Subject: [PATCH 10/12] fix(app): preserve typography and thread model choices --- assets/inject/renderer-inject.js | 23 +------ crates/codex-plus-core/src/assets.rs | 69 +++++++++++++++++++ crates/codex-plus-core/tests/cdp_bridge.rs | 47 +++++++++++++ .../tests/force_chinese_locale_settings.rs | 2 + 4 files changed, 119 insertions(+), 22 deletions(-) diff --git a/assets/inject/renderer-inject.js b/assets/inject/renderer-inject.js index 9a2ac85f8..4ad23642b 100644 --- a/assets/inject/renderer-inject.js +++ b/assets/inject/renderer-inject.js @@ -104,7 +104,6 @@ const installationKey = `2:${enabled ? "on" : "off"}:${locale}`; if (window.__codexPlusForceChineseLocaleInstalled === installationKey) return; window.__codexPlusForceChineseLocaleInstalled = installationKey; - const languages = [locale, "zh", "en-US", "en"]; const managedLocaleStorageKey = "codexPlus.forceChineseLocale.managed.v1"; const localeReloadStorageKey = "codexPlus.forceChineseLocale.reload.v1"; @@ -241,26 +240,6 @@ syncOfficialLocaleSetting().catch(() => {}); if (!enabled) return; - const defineNavigatorGetter = (name, value) => { - try { - Object.defineProperty(Navigator.prototype, name, { - configurable: true, - get: () => value, - }); - } catch { - try { - Object.defineProperty(navigator, name, { - configurable: true, - get: () => value, - }); - } catch { - } - } - }; - - defineNavigatorGetter("language", locale); - defineNavigatorGetter("languages", languages); - const patchI18nConfig = (dynamicConfig) => { if (!dynamicConfig || typeof dynamicConfig !== "object") return dynamicConfig; const value = dynamicConfig.value && typeof dynamicConfig.value === "object" ? dynamicConfig.value : {}; @@ -6749,7 +6728,7 @@ const nextValue = { ...value, available_models: availableModels, - default_model: names[0] || value.default_model, + default_model: value.default_model || names[0], }; if (!changed && nextValue.default_model === value.default_model) return config; try { diff --git a/crates/codex-plus-core/src/assets.rs b/crates/codex-plus-core/src/assets.rs index 0c5807f82..7197c9f29 100644 --- a/crates/codex-plus-core/src/assets.rs +++ b/crates/codex-plus-core/src/assets.rs @@ -116,6 +116,7 @@ fn dream_skin_target_runtime_script(settings: &BackendSettings, include_art: boo } let (engine, renderer, base_css) = dream_skin_target_assets(settings); + let base_css = preserve_host_typography(base_css); let managed_css = managed_dream_skin_css(settings); let css = format!("{base_css}\n{managed_css}"); let theme = serde_json::to_string(&settings.codex_app_dream_skin_theme_config) @@ -192,6 +193,27 @@ fn dream_skin_target_runtime_script(settings: &BackendSettings, include_art: boo payload } +fn preserve_host_typography(css: &str) -> String { + let mut output = String::with_capacity(css.len()); + let mut host_body_rule = false; + for line in css.split_inclusive('\n') { + let trimmed = line.trim(); + if matches!( + trimmed, + "html.codex-dream-skin body {" | "html.codex-glass-vision-skin body {" + ) { + host_body_rule = true; + } + if !(host_body_rule && trimmed.starts_with("font-family:")) { + output.push_str(line); + } + if host_body_rule && trimmed == "}" { + host_body_rule = false; + } + } + output +} + fn managed_dream_skin_css(settings: &BackendSettings) -> String { let image_path = settings.codex_app_dream_skin_image_path.trim(); if image_path.is_empty() @@ -468,6 +490,9 @@ pub fn dream_skin_art_content_signature(settings: &BackendSettings) -> String { pub fn dream_skin_runtime_content_signature(settings: &BackendSettings) -> String { let (engine, _, css) = dream_skin_target_assets(settings); + let css = preserve_host_typography(css); + let managed_css = managed_dream_skin_css(settings); + let css = format!("{css}\n{managed_css}"); let theme = serde_json::to_string(&settings.codex_app_dream_skin_theme_config) .expect("dream skin target theme should serialize"); let style_revision = dream_skin_content_signature(css.as_bytes()); @@ -740,6 +765,50 @@ mod tests { assert_eq!(config["fitMode"].as_str(), Some("fill")); } + #[test] + fn dream_skin_preserves_host_body_typography() { + let css = concat!( + "html.codex-dream-skin body {\n", + " color: white;\n", + " font-family: Example, sans-serif !important;\n", + "}\n", + "html.codex-dream-skin button {\n", + " font-family: inherit !important;\n", + "}\n", + ); + + let preserved = preserve_host_typography(css); + + assert!(!preserved.contains("font-family: Example")); + assert!(preserved.contains("font-family: inherit !important")); + } + + #[test] + fn bundled_dream_skins_drop_only_the_host_body_font_rule() { + for css in [ + DREAM_TARGET_CSS, + CIDALA_TARGET_CSS, + CODEX_SNOW_CSS, + GLASS_VISION_CSS, + ] { + let preserved = preserve_host_typography(css); + + assert_eq!(css.lines().count(), preserved.lines().count() + 1); + } + } + + #[test] + fn dream_skin_runtime_signature_matches_the_filtered_payload() { + let settings = BackendSettings { + codex_app_dream_skin_enabled: true, + ..BackendSettings::default() + }; + let signature = dream_skin_runtime_content_signature(&settings); + let script = dream_skin_live_update_script(&settings, false); + + assert!(script.contains(&serde_json::to_string(&signature).unwrap())); + } + #[test] fn local_plugin_marketplaces_includes_api_marketplace_snapshot() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/codex-plus-core/tests/cdp_bridge.rs b/crates/codex-plus-core/tests/cdp_bridge.rs index e18fda660..01201dbe8 100644 --- a/crates/codex-plus-core/tests/cdp_bridge.rs +++ b/crates/codex-plus-core/tests/cdp_bridge.rs @@ -1673,10 +1673,53 @@ fn injection_script_unlocks_custom_model_catalog() { assert!(!script.contains("function patchObjectGraphForModels")); assert!(!script.contains("window.dispatchEvent = function patchedCodexPlusDispatchEvent")); assert!(script.contains("String(name) === \"107580212\"")); + assert!(script.contains("default_model: value.default_model || names[0]")); + assert!(!script.contains("default_model: names[0] || value.default_model")); assert!(script.contains("window.addEventListener(\"codex-message-from-view\"")); assert!(!script.contains("querySelectorAll(\"button, [role='menu']")); } +#[test] +fn model_whitelist_preserves_the_app_default_model() { + let script = assets::injection_script(57321); + let start = script + .find("function patchStatsigModelDynamicConfig(config)") + .expect("Statsig model patch should exist"); + let end = script[start..] + .find("\n function statsigClients()") + .map(|offset| start + offset) + .expect("Statsig model patch should have a stable end marker"); + let function_source = &script[start..end]; + let temp = tempfile::tempdir().expect("temp dir should be created"); + let harness_path = temp.path().join("model-default-harness.cjs"); + std::fs::write( + &harness_path, + format!( + r#" +const codexPlusModelNames = () => ["supplier-default", "extra-model"]; +{function_source} +const config = {{ value: {{ available_models: ["native-model"], default_model: "thread-model" }} }}; +const patched = patchStatsigModelDynamicConfig(config); +if (patched.value.default_model !== "thread-model") process.exit(2); +if (!patched.value.available_models.includes("supplier-default")) process.exit(3); +if (!patched.value.available_models.includes("extra-model")) process.exit(4); +"# + ), + ) + .expect("model default harness should be written"); + + let output = Command::new("node") + .arg(&harness_path) + .output() + .expect("node should run model default harness"); + assert!( + output.status.success(), + "node harness failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + #[test] fn injection_script_exposes_fast_service_tier_control() { let script = assets::injection_script(57321); @@ -1971,6 +2014,7 @@ fn injection_script_applies_fast_service_tier_contract() { assert_eq!(cases["refreshedCustomProviderOverride"], "refreshed_vendor"); assert_eq!(cases["refreshedOpenAiProvider"], "openai"); assert_eq!(cases["refreshedPureApiResumeProvider"], "custom"); + assert_eq!(cases["refreshedPureApiResumeModel"], "gpt-5.6-terra"); assert_eq!(cases["failedRefreshProviderUnchanged"], "openai"); assert_eq!(cases["missingActiveProviderUnchanged"], true); assert_eq!(cases["missingActiveRecoveryUnscheduled"], true); @@ -2495,8 +2539,10 @@ window.__codexSessionDeleteBridge = async (path) => {{ await appServerClient.sendRequest("thread/resume", {{ threadId: "thread-mobile-pure-api-refresh", modelProvider: "openai", + model: "gpt-5.6-terra", }}, {{ signal: "pure-api-switch" }}); const refreshedPureApiResumeProvider = appServerCalls.at(-1)?.params?.modelProvider || ""; +const refreshedPureApiResumeModel = appServerCalls.at(-1)?.params?.model || ""; delete window.__codexSessionDeleteBridge; api.setBackendSettings({{ relayProfilesEnabled: true, @@ -2609,6 +2655,7 @@ process.stdout.write(JSON.stringify({{ refreshedCustomProviderOverride, refreshedOpenAiProvider, refreshedPureApiResumeProvider, + refreshedPureApiResumeModel, failedRefreshProviderUnchanged, missingActiveProviderUnchanged, missingActiveRecoveryUnscheduled, diff --git a/crates/codex-plus-core/tests/force_chinese_locale_settings.rs b/crates/codex-plus-core/tests/force_chinese_locale_settings.rs index 08ba6339c..2a8fea948 100644 --- a/crates/codex-plus-core/tests/force_chinese_locale_settings.rs +++ b/crates/codex-plus-core/tests/force_chinese_locale_settings.rs @@ -90,6 +90,8 @@ fn injection_script_includes_force_chinese_locale_global_and_patch() { assert!(script.contains("window.location.reload()")); assert!(script.contains("codexPlus.forceChineseLocale.managed.v1")); assert!(!script.contains("setItem(\"localeOverride\"")); + assert!(!script.contains("defineNavigatorGetter")); + assert!(!script.contains("Object.defineProperty(Navigator.prototype")); settings.codex_app_force_chinese_locale = false; let script = injection_script_with_settings(0, &settings); From 47b6e6340bd868d853111d0cdf046fc503cdf3ee Mon Sep 17 00:00:00 2001 From: Yuimi_chaya <124485273+Yuimi-chaya@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:56:06 +0800 Subject: [PATCH 11/12] test(launcher): normalize source assertion whitespace --- apps/codex-plus-launcher/src/main.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/codex-plus-launcher/src/main.rs b/apps/codex-plus-launcher/src/main.rs index 130edd496..1bbd20f57 100644 --- a/apps/codex-plus-launcher/src/main.rs +++ b/apps/codex-plus-launcher/src/main.rs @@ -1241,6 +1241,7 @@ mod tests { #[test] fn launcher_hooks_forward_runtime_watchdog_and_marketplace_methods() { let source = include_str!("main.rs"); + let compact_source = source.split_whitespace().collect::(); assert!(source.contains("async fn start_bridge_watchdog")); assert!(source.contains("self.watchdog_bridge_context()?")); @@ -1250,8 +1251,8 @@ mod tests { assert!(source.contains("self.core.ensure_plugin_marketplace_config(settings).await")); assert!(source.contains("async fn ensure_active_protocol_proxy_config")); assert!( - source - .contains("self.core\n .ensure_active_protocol_proxy_config(settings)") + compact_source + .contains("self.core.ensure_active_protocol_proxy_config(settings).await") ); } From 42298da1c7edc372cb7d4ecdc33d8f4e439d3510 Mon Sep 17 00:00:00 2001 From: Yuimi_chaya <124485273+Yuimi-chaya@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:31:20 +0800 Subject: [PATCH 12/12] fix(relay): preserve no-auth proxy startup invariant --- crates/codex-plus-core/src/relay_config.rs | 4 +-- crates/codex-plus-core/src/settings.rs | 6 +++- crates/codex-plus-core/tests/relay_config.rs | 34 ++++++++++++++++++++ 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/crates/codex-plus-core/src/relay_config.rs b/crates/codex-plus-core/src/relay_config.rs index 7a7094924..27eb47779 100644 --- a/crates/codex-plus-core/src/relay_config.rs +++ b/crates/codex-plus-core/src/relay_config.rs @@ -302,9 +302,7 @@ pub fn ensure_active_protocol_proxy_config_in_home( settings: &BackendSettings, ) -> anyhow::Result { let profile = settings.active_relay_profile(); - let transport_uses_proxy = settings.active_aggregate_relay_profile().is_some() - || profile.protocol == RelayProtocol::ChatCompletions - || profile.has_model_routes(); + let transport_uses_proxy = settings.active_relay_transport_uses_protocol_proxy(); let openai_identity_uses_proxy = settings.active_relay_session_provider() == RelaySessionProvider::Openai || (profile.relay_mode == crate::settings::RelayMode::Official diff --git a/crates/codex-plus-core/src/settings.rs b/crates/codex-plus-core/src/settings.rs index cd50605d1..9bb20af34 100644 --- a/crates/codex-plus-core/src/settings.rs +++ b/crates/codex-plus-core/src/settings.rs @@ -759,11 +759,15 @@ impl BackendSettings { } } - pub fn active_relay_uses_protocol_proxy(&self) -> bool { + pub fn active_relay_transport_uses_protocol_proxy(&self) -> bool { self.active_aggregate_relay_profile().is_some() || self.active_relay_profile().protocol == RelayProtocol::ChatCompletions || self.active_relay_profile().has_model_routes() || self.active_relay_profile().uses_no_auth() + } + + pub fn active_relay_uses_protocol_proxy(&self) -> bool { + self.active_relay_transport_uses_protocol_proxy() || self.active_relay_session_provider() == RelaySessionProvider::Openai } } diff --git a/crates/codex-plus-core/tests/relay_config.rs b/crates/codex-plus-core/tests/relay_config.rs index 3953d2467..2be9b5450 100644 --- a/crates/codex-plus-core/tests/relay_config.rs +++ b/crates/codex-plus-core/tests/relay_config.rs @@ -830,6 +830,40 @@ base_url = "https://responses.example.test/v1" assert_eq!(std::fs::read_to_string(config_path).unwrap(), original); } +#[test] +fn launcher_repairs_no_auth_transport_without_rewriting_managed_credentials() { + let temp = tempfile::tempdir().unwrap(); + let config_path = temp.path().join("config.toml"); + std::fs::write( + &config_path, + r#"model_provider = "custom" + +[model_providers.custom] +base_url = "https://no-auth.example.test/v1" +experimental_bearer_token = "codex-plus-no-auth" +custom_setting = "preserve-me" +"#, + ) + .unwrap(); + let settings = BackendSettings { + relay_profiles: vec![RelayProfile { + relay_mode: RelayMode::PureApi, + no_auth: true, + base_url: "https://no-auth.example.test/v1".to_string(), + protocol: RelayProtocol::Responses, + ..RelayProfile::default() + }], + ..BackendSettings::default() + }; + + assert!(ensure_active_protocol_proxy_config_in_home(temp.path(), &settings).unwrap()); + let updated = std::fs::read_to_string(&config_path).unwrap(); + assert!(updated.contains(r#"base_url = "http://127.0.0.1:57321/v1""#)); + assert!(updated.contains(r#"experimental_bearer_token = "codex-plus-no-auth""#)); + assert!(updated.contains(r#"custom_setting = "preserve-me""#)); + assert!(!ensure_active_protocol_proxy_config_in_home(temp.path(), &settings).unwrap()); +} + #[test] fn launcher_repairs_route_transport_and_openai_identity_endpoints_together() { let temp = tempfile::tempdir().unwrap();