From 3bc828aecc40961a46c06057f4eed82c58a617a3 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 15 Jul 2026 10:59:06 +0800 Subject: [PATCH] fix(windows): eliminate console flash and UI freeze on provider switch Switching providers or toggling proxy takeover on Windows flashed a transient cmd window and froze the UI for up to a couple of seconds. Three fixes: - Spawn `codex debug models --bundled` with CREATE_NO_WINDOW so the console child of the GUI-subsystem app (npm's codex.cmd runs via cmd.exe) no longer opens a visible window. - Cache the ProxyChat model catalog template in a process-wide OnceCell: only successful loads are cached, failures stay retryable, so the Codex CLI starts at most once per app run instead of on every switch. - Run switch_provider via spawn_blocking: sync Tauri commands execute on the main thread, so the blocking catalog generation (and the block_on'd per-app switch lock) froze the UI. Concurrency is already serialized by ProxyService::lock_switch_for_app. --- src-tauri/src/codex_config.rs | 119 +++++++++++++++++++++++++++-- src-tauri/src/commands/provider.rs | 15 +++- 2 files changed, 124 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index ef8646302..56d74588b 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -7,9 +7,10 @@ use crate::config::{ }; use crate::error::AppError; use crate::model_capabilities::{image_input_capability_from_modalities, ImageInputCapability}; +use once_cell::sync::OnceCell; use serde_json::{json, Value}; use std::fs; -use std::process::Command; +use std::process::{Command, Stdio}; use toml_edit::DocumentMut; pub const CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "custom"; @@ -21,6 +22,17 @@ pub const CC_SWITCH_CODEX_OFFICIAL_PROXY_PROVIDER_ID: &str = "cc-switch-official pub const CC_SWITCH_CODEX_MODEL_CATALOG_FILENAME: &str = "cc-switch-model-catalog.json"; const CODEX_PROXY_AUTH_PLACEHOLDER: &str = "PROXY_MANAGED"; +#[cfg(target_os = "windows")] +const CREATE_NO_WINDOW: u32 = 0x08000000; + +// Generating a ProxyChat catalog only needs one stable Codex model template per +// process. Without this cache every provider switch/takeover can start the +// Codex CLI again, which is especially expensive for npm-installed `codex.cmd` +// on Windows. Tests deliberately bypass the global cache because they isolate +// CODEX_HOME and seed different model templates. +#[cfg(not(test))] +static CODEX_MODEL_CATALOG_TEMPLATE_CACHE: OnceCell = OnceCell::new(); + /// Top-level `config.toml` key that controls Codex's built-in web-search tool. pub(crate) const CODEX_WEB_SEARCH_FIELD: &str = "web_search"; /// Value that disables the web-search tool. Some native `/responses` gateways @@ -804,13 +816,28 @@ fn codex_cli_candidates() -> Vec { candidates } +fn codex_bundled_models_command(candidate: &Path) -> Command { + let mut command = Command::new(candidate); + command + .args(["debug", "models", "--bundled"]) + .stdin(Stdio::null()); + + // A release build uses the Windows GUI subsystem, so a console child that + // is created without this flag gets its own transient console window. npm + // installs Codex as `codex.cmd`, which Windows launches through cmd.exe. + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + command.creation_flags(CREATE_NO_WINDOW); + } + + command +} + fn load_codex_model_template_from_bundled() -> Result, AppError> { for candidate in codex_cli_candidates() { let candidate_label = candidate.to_string_lossy(); - let output = match Command::new(&candidate) - .args(["debug", "models", "--bundled"]) - .output() - { + let output = match codex_bundled_models_command(&candidate).output() { Ok(output) => output, Err(err) => { log::debug!("failed to run `{candidate_label} debug models --bundled`: {err}"); @@ -864,7 +891,7 @@ fn load_codex_native_responses_template() -> Value { serde_json::from_str(text).expect("bundled codex native responses template must be valid JSON") } -fn load_codex_model_catalog_template() -> Result { +fn load_codex_model_catalog_template_uncached() -> Result { // ① models_cache.json (created by Codex when it connects to OpenAI) if let Some(template) = load_codex_model_template_from_cache()? { return Ok(template); @@ -883,6 +910,29 @@ fn load_codex_model_catalog_template() -> Result { ))) } +fn get_or_load_codex_model_catalog_template( + cache: &OnceCell, + loader: F, +) -> Result +where + F: FnOnce() -> Result, +{ + cache.get_or_try_init(loader).cloned() +} + +#[cfg(not(test))] +fn load_codex_model_catalog_template() -> Result { + get_or_load_codex_model_catalog_template( + &CODEX_MODEL_CATALOG_TEMPLATE_CACHE, + load_codex_model_catalog_template_uncached, + ) +} + +#[cfg(test)] +fn load_codex_model_catalog_template() -> Result { + load_codex_model_catalog_template_uncached() +} + fn codex_model_catalog_from_specs( specs: &[CodexCatalogModelSpec], template: &Value, @@ -3309,6 +3359,63 @@ web_search = "disabled" ); } + #[test] + fn codex_bundled_models_command_uses_expected_program_and_args() { + let command = codex_bundled_models_command(Path::new("codex")); + assert_eq!(command.get_program(), "codex"); + assert_eq!( + command + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(), + ["debug", "models", "--bundled"] + ); + } + + #[test] + fn successful_model_catalog_template_load_is_cached() { + use std::cell::Cell; + + let cache = OnceCell::new(); + let calls = Cell::new(0); + let first = get_or_load_codex_model_catalog_template(&cache, || { + calls.set(calls.get() + 1); + Ok(json!({ "slug": "first" })) + }) + .expect("first template load"); + let second = get_or_load_codex_model_catalog_template(&cache, || { + calls.set(calls.get() + 1); + Ok(json!({ "slug": "second" })) + }) + .expect("cached template load"); + + assert_eq!(first, json!({ "slug": "first" })); + assert_eq!(second, first); + assert_eq!(calls.get(), 1, "successful template should load only once"); + } + + #[test] + fn failed_model_catalog_template_load_can_retry() { + use std::cell::Cell; + + let cache = OnceCell::new(); + let calls = Cell::new(0); + let first = get_or_load_codex_model_catalog_template(&cache, || { + calls.set(calls.get() + 1); + Err(AppError::Message("temporary failure".to_string())) + }); + assert!(first.is_err()); + + let second = get_or_load_codex_model_catalog_template(&cache, || { + calls.set(calls.get() + 1); + Ok(json!({ "slug": "recovered" })) + }) + .expect("retry template load"); + + assert_eq!(second, json!({ "slug": "recovered" })); + assert_eq!(calls.get(), 2, "failed loads must not poison the cache"); + } + #[test] fn codex_cli_candidates_include_user_node_manager_bins() { let temp_home = tempfile::tempdir().expect("create temp home"); diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index 6cbee9d2b..993ee80d2 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -1,5 +1,5 @@ use indexmap::IndexMap; -use tauri::{Emitter, State}; +use tauri::{Emitter, Manager, State}; use crate::app_config::AppType; use crate::commands::copilot::CopilotAuthState; @@ -100,13 +100,20 @@ pub fn switch_provider_test_hook( } #[tauri::command] -pub fn switch_provider( - state: State<'_, AppState>, +pub async fn switch_provider( + app_handle: tauri::AppHandle, app: String, id: String, ) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; - switch_provider_internal(&state, app_type, &id).map_err(|e| e.to_string()) + tauri::async_runtime::spawn_blocking(move || { + let state = app_handle + .try_state::() + .ok_or_else(|| "应用状态不可用".to_string())?; + switch_provider_internal(state.inner(), app_type, &id).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("供应商切换任务执行失败: {e}"))? } fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result {