diff --git a/docs/security.md b/docs/security.md index 426c0fb..a1fcfe4 100644 --- a/docs/security.md +++ b/docs/security.md @@ -72,7 +72,9 @@ when the operation ends. GSwitch reads the installed CLI version from the same executable it uses for App Server. It updates the CLI only after a user selects **Update CLI**, with -the GSwitch operation lock held and no external Codex runtime active. The CLI +the GSwitch operation lock held and no external Codex runtime active. When +GSwitch's own work, such as a quota refresh, holds that lock, the update waits +for it to finish instead of failing. The CLI itself chooses its supported installation method; GSwitch does not download or replace CLI files or change account credentials. A completed update is followed by a fresh version read. Missing or unsupported update commands use official diff --git a/src-tauri/src/accounts.rs b/src-tauri/src/accounts.rs index 031c6d5..d5d499b 100644 --- a/src-tauri/src/accounts.rs +++ b/src-tauri/src/accounts.rs @@ -778,6 +778,10 @@ impl AppState { /// the short quota commit still uses the cross-process storage boundary. pub(crate) fn acquire_quota_commit_operation(&self) -> Result, String> { self.ensure_store_ready()?; + self.wait_for_operation_lock() + } + + fn wait_for_operation_lock(&self) -> Result, String> { let in_process = self .operation_lock .lock() @@ -804,12 +808,12 @@ impl AppState { } /// Updating the installed CLI must not overlap a GSwitch operation that - /// may launch App Server. This remains available when account storage - /// needs recovery because it never reads or changes saved accounts. - pub(crate) fn acquire_cli_update_operation( - &self, - ) -> Result, OperationAcquireFailure> { - self.acquire_operation_lock() + /// may launch App Server. It waits for GSwitch's own running work, such as + /// a quota refresh, instead of reporting it as a conflict. This remains + /// available when account storage needs recovery because it never reads + /// or changes saved accounts. + pub(crate) fn acquire_cli_update_operation(&self) -> Result, String> { + self.wait_for_operation_lock() } pub(crate) fn acquire_operation_for_switch( @@ -2867,6 +2871,32 @@ mod tests { let _ = fs::remove_dir_all(path.parent().expect("parent")); } + #[test] + fn cli_update_waits_for_a_running_operation_instead_of_failing() { + use std::{sync::mpsc, thread, time::Duration}; + + let path = temp_path("cli-update-waits"); + let state = AppState::new(path.clone()).expect("state"); + let refresh = state.acquire_operation().expect("running operation"); + + let (result_tx, result_rx) = mpsc::channel(); + let waiting = state.clone(); + let worker = thread::spawn(move || { + let acquired = waiting.acquire_cli_update_operation().is_ok(); + result_tx.send(acquired).expect("send result"); + }); + assert!(matches!( + result_rx.recv_timeout(Duration::from_millis(100)), + Err(mpsc::RecvTimeoutError::Timeout) + )); + drop(refresh); + assert!(result_rx + .recv_timeout(Duration::from_secs(5)) + .expect("update proceeded after the operation finished")); + worker.join().expect("worker finished"); + let _ = fs::remove_dir_all(path.parent().expect("parent")); + } + #[test] fn secret_generation_overflow_fails_before_replacing_the_current_secret() { assert!(AppState::next_secret_generation(Some(u64::MAX), 1).is_err()); diff --git a/src-tauri/src/cli_update.rs b/src-tauri/src/cli_update.rs index 4d8b62b..f371ad6 100644 --- a/src-tauri/src/cli_update.rs +++ b/src-tauri/src/cli_update.rs @@ -6,7 +6,7 @@ use std::{ }; use crate::{ - accounts::{AppState, OperationAcquireFailure}, + accounts::AppState, app_server, runtime, types::{CodexCliInfo, CodexCliUpdateFailure, CodexCliUpdateStatus}, }; @@ -118,10 +118,7 @@ fn newer_than(latest: &str, installed: &str) -> bool { pub fn update(state: &AppState) -> Result { let _operation = state .acquire_cli_update_operation() - .map_err(|error| match error { - OperationAcquireFailure::Busy => CodexCliUpdateFailure::Busy, - OperationAcquireFailure::Failed(_) => CodexCliUpdateFailure::UpdateFailed, - })?; + .map_err(|_| CodexCliUpdateFailure::UpdateFailed)?; let before = inspect(); if before.version.is_none() { return Err(CodexCliUpdateFailure::NotInstalled); diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index d79ee85..5694c35 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -23,7 +23,6 @@ pub enum CodexCliUpdateStatus { pub enum CodexCliUpdateFailure { NotInstalled, Unsupported, - Busy, CodexOpen, UpdateFailed, VerificationFailed, diff --git a/src/App.tsx b/src/App.tsx index feedfe5..1989973 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -326,7 +326,7 @@ function accountGridHasGlobalMutation(busy: string | null) { function cliUpdateFailureCode(error: unknown): CodexCliUpdateFailure { const code = String(error); - if (["not_installed", "unsupported", "busy", "codex_open", "update_failed", "verification_failed"].includes(code)) { + if (["not_installed", "unsupported", "codex_open", "update_failed", "verification_failed"].includes(code)) { return code as CodexCliUpdateFailure; } return "update_failed"; @@ -2490,7 +2490,6 @@ export default function App() { {cliError ?

{t(({ not_installed: "cli.error.notInstalled", unsupported: "cli.error.unsupported", - busy: "cli.error.busy", codex_open: "cli.error.codexOpen", update_failed: "cli.error.updateFailed", verification_failed: "cli.error.verificationFailed", diff --git a/src/i18n.ts b/src/i18n.ts index 2540bce..fb20855 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -108,7 +108,6 @@ const en = { "cli.guide": "Official install guide", "cli.error.notInstalled": "The Codex CLI is unavailable. Install it, then check again.", "cli.error.unsupported": "This CLI cannot update itself. Use its installer or package manager.", - "cli.error.busy": "Another GSwitch operation is running. Try again when it finishes.", "cli.error.codexOpen": "Quit Codex, then try the update again.", "cli.error.updateFailed": "The official Codex update did not finish. Try updating from a terminal or use the install guide.", "cli.error.verificationFailed": "The update command finished, but GSwitch could not read the installed CLI version. Check the installation.", @@ -471,7 +470,6 @@ const zhCN: Record = { "cli.guide": "官方安装指南", "cli.error.notInstalled": "Codex CLI 不可用。请安装后重新检查。", "cli.error.unsupported": "此 CLI 不支持自行更新。请通过原安装程序或包管理器更新。", - "cli.error.busy": "另一项 GSwitch 操作正在进行,请完成后重试。", "cli.error.codexOpen": "请退出 Codex 后再更新。", "cli.error.updateFailed": "Codex 官方更新未完成。请在终端中更新,或查看安装指南。", "cli.error.verificationFailed": "更新命令已结束,但 GSwitch 无法读取已安装的 CLI 版本。请检查安装。", diff --git a/src/types.ts b/src/types.ts index e9086b6..883face 100644 --- a/src/types.ts +++ b/src/types.ts @@ -36,7 +36,6 @@ export interface CodexCliInfo { export type CodexCliUpdateFailure = | "not_installed" | "unsupported" - | "busy" | "codex_open" | "update_failed" | "verification_failed";