From cfb1855ae0f4218b66e12f69296b625b560da6ae Mon Sep 17 00:00:00 2001 From: SquarePots <46488165+squarepots@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:34:36 +0800 Subject: [PATCH] Let Codex CLI update wait for GSwitch's own work Updating the CLI took the operation lock with a non-waiting try-lock, so clicking Update while a quota refresh was committing or running an official token refresh reported "Another GSwitch operation is in progress" even though the user had started nothing that conflicts. The update now waits for the lock the same way a quota commit does, then runs with it held. Every in-process holder is bounded by existing HTTP and App Server timeouts, and Wake keeps its own non-dismissible dialog open while it runs. The CLI busy failure can no longer occur, so its code and copy are removed. --- docs/security.md | 4 +++- src-tauri/src/accounts.rs | 42 +++++++++++++++++++++++++++++++------ src-tauri/src/cli_update.rs | 7 ++----- src-tauri/src/types.rs | 1 - src/App.tsx | 3 +-- src/i18n.ts | 2 -- src/types.ts | 1 - 7 files changed, 42 insertions(+), 18 deletions(-) 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";