From b7197f6f4bac8f3c34def2a7ac0e6b8a402d3767 Mon Sep 17 00:00:00 2001 From: wonfen Date: Mon, 21 Sep 2026 21:45:43 +0800 Subject: [PATCH] fix(windows): fall back to sidecar after confirmed service failure --- Cargo.toml | 1 + src-tauri/src/core/manager/lifecycle.rs | 88 ++++++++++---- src-tauri/src/core/manager/state.rs | 26 +++++ src-tauri/src/core/service.rs | 3 + .../src/core/service/windows_fallback.rs | 109 ++++++++++++++++++ src/locales/ar/settings.json | 2 +- src/locales/de/settings.json | 2 +- src/locales/en/settings.json | 2 +- src/locales/es/settings.json | 2 +- src/locales/fa/settings.json | 2 +- src/locales/id/settings.json | 2 +- src/locales/jp/settings.json | 2 +- src/locales/ko/settings.json | 2 +- src/locales/ru/settings.json | 2 +- src/locales/tr/settings.json | 2 +- src/locales/tt/settings.json | 2 +- src/locales/zh/settings.json | 2 +- src/locales/zhtw/settings.json | 2 +- 18 files changed, 215 insertions(+), 38 deletions(-) create mode 100644 src-tauri/src/core/service/windows_fallback.rs diff --git a/Cargo.toml b/Cargo.toml index df17bbeae..74ed8f81d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -105,6 +105,7 @@ windows-sys = { version = "0.61.2", features = [ "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Threading", + "Win32_System_Diagnostics_ToolHelp", "Win32_System_JobObjects", ] } # ********************************* diff --git a/src-tauri/src/core/manager/lifecycle.rs b/src-tauri/src/core/manager/lifecycle.rs index c19f2d85b..a9716f604 100644 --- a/src-tauri/src/core/manager/lifecycle.rs +++ b/src-tauri/src/core/manager/lifecycle.rs @@ -186,39 +186,42 @@ where } #[cfg(target_os = "windows")] -async fn run_service_start_with_sidecar_fallback( - is_admin: bool, +async fn run_service_start_with_sidecar_fallback( start_service: Start, + confirm_idle: Check, start_sidecar: Fallback, ) -> Result<()> where Start: FnOnce() -> StartFuture, StartFuture: std::future::Future>, + Check: FnOnce(bool) -> CheckFuture, + CheckFuture: std::future::Future>, Fallback: FnOnce(anyhow::Error) -> FallbackFuture, FallbackFuture: std::future::Future>, { let Err(error) = start_service().await else { return Ok(()); }; - // A transport failure cannot establish whether the service already started the core. - let location_refused = error - .downcast_ref::() - .is_some_and(|refusal| { - refusal.code == clash_verge_service_ipc::ServiceErrorCode::InvalidInstallLocation as u16 - }); - if !is_admin || !location_refused { + let refusal = error.downcast_ref::(); + let location_refused = refusal.is_some_and(|refusal| { + refusal.code == clash_verge_service_ipc::ServiceErrorCode::InvalidInstallLocation as u16 + }); + if refusal.is_some() && !location_refused { return Err(error); } - + // A lost response is not a refusal: require a stopped service and no leftover core. + if let Err(probe_error) = confirm_idle(location_refused).await { + return Err(error.context(format!("Sidecar fallback was not safe: {probe_error:#}"))); + } logging!( warn, Type::Core, - "service refused the core location while app is elevated; falling back to sidecar: {error:#}" + "Service could not start the core; falling back to Sidecar: {error:#}" ); let reason = format!("{error:#}"); start_sidecar(error) .await - .map_err(|error| error.context(format!("sidecar fallback failed after service refusal: {reason}"))) + .map_err(|error| error.context(format!("Sidecar fallback failed after Service failure: {reason}"))) } async fn run_sidecar_termination_transition( @@ -578,6 +581,8 @@ impl CoreManager { tracing::Span::current().record("decision", tracing::field::debug(&startup)); if matches!(startup, StartupDecision::Wait) { self.rollback_failed_start().await; + #[cfg(target_os = "windows")] + self.try_sidecar_after_service_unavailable().await?; return Ok(()); } if Handle::global().is_exiting() { @@ -590,20 +595,9 @@ impl CoreManager { #[cfg(target_os = "windows")] { run_service_start_with_sidecar_fallback( - crate::core::runstate::RUN_STATE.state().is_admin, || self.start_core_by_service(), - |error| async move { - use crate::core::runstate::RUN_STATE; - - RUN_STATE.allow_sidecar_after_service_refusal(format!("{error:#}"))?; - let result = self.start_core_by_sidecar().await; - if result.is_err() { - SERVICE_MANAGER.withdraw_sidecar_allowance(); - } else { - crate::core::service::notify_service_fallback(); - } - result - }, + crate::core::service::windows_fallback::confirm_idle, + |error| self.start_sidecar_after_service_failure(error), ) .await } @@ -628,6 +622,50 @@ impl CoreManager { result } + #[cfg(target_os = "windows")] + async fn try_sidecar_after_service_unavailable(&self) -> Result<()> { + use crate::core::runstate::{RUN_STATE, ServiceHealth}; + + let state = RUN_STATE.state(); + if state.pending.is_some() || state.op_in_flight || Handle::global().is_exiting() { + return Ok(()); + } + let reason = match state.health { + ServiceHealth::Unavailable(reason) => reason, + ServiceHealth::VersionMismatch => "registered service is unavailable or incompatible".to_owned(), + _ => return Ok(()), + }; + if let Err(error) = crate::core::service::windows_fallback::confirm_idle(false).await { + logging!( + warn, + Type::Core, + "Service unavailable ({reason}); Sidecar fallback withheld: {error:#}" + ); + return Ok(()); + } + self.start_sidecar_after_service_failure(anyhow::anyhow!(reason)).await + } + + #[cfg(target_os = "windows")] + async fn start_sidecar_after_service_failure(&self, error: anyhow::Error) -> Result<()> { + use crate::core::runstate::RUN_STATE; + + if Handle::global().is_exiting() { + return Err(error.context("application exited before Sidecar fallback")); + } + let reason = format!("{error:#}"); + RUN_STATE.allow_sidecar_after_service_refusal(reason.clone())?; + logging!(warn, Type::Core, "Starting Sidecar with Service unavailable: {reason}"); + let result = self.start_core_by_sidecar().await; + if result.is_err() { + SERVICE_MANAGER.withdraw_sidecar_allowance(); + self.rollback_failed_start().await; + } else { + crate::core::service::notify_service_fallback(); + } + result.map_err(|error| error.context(format!("Service failure before Sidecar startup: {reason}"))) + } + #[tracing::instrument(skip_all, level = "info", fields(mode = ?*self.get_running_mode()))] pub async fn stop_core(&self) -> Result<()> { let _life = self.lifecycle_lock.lock().await; diff --git a/src-tauri/src/core/manager/state.rs b/src-tauri/src/core/manager/state.rs index 518c6caa9..a7b1b3a2c 100644 --- a/src-tauri/src/core/manager/state.rs +++ b/src-tauri/src/core/manager/state.rs @@ -16,6 +16,23 @@ use std::path::Path; use tauri_plugin_mihomo::MihomoExt as _; use tauri_plugin_shell::ShellExt as _; +#[cfg(target_os = "windows")] +fn sidecar_config_without_tun(yaml: &str) -> Result { + use serde_yaml_ng::{Mapping, Value}; + + let mut config: Mapping = serde_yaml_ng::from_str(yaml)?; + let tun = config + .entry(Value::from("tun")) + .or_insert_with(|| Value::Mapping(Mapping::new())); + if !tun.is_mapping() { + *tun = Value::Mapping(Mapping::new()); + } + tun.as_mapping_mut() + .context("invalid Sidecar TUN configuration")? + .insert(Value::from("enable"), Value::Bool(false)); + Ok(serde_yaml_ng::to_string(&config)?) +} + const SIDECAR_READINESS_ATTEMPTS: usize = 30; #[cfg(target_os = "windows")] @@ -142,6 +159,15 @@ impl CoreManager { handle::Handle::app_handle() .mihomo() .update_socket_path(dirs::path_to_str(&sidecar_ipc)?.to_owned())?; + #[cfg(target_os = "windows")] + let config_file = if crate::core::runstate::RUN_STATE.state().is_admin { + Config::generate_file().await? + } else { + // Reconciliation persists the preference later; the first spawn must already have TUN off. + let yaml = sidecar_config_without_tun(&Config::runtime_config_yaml().await?)?; + Config::write_runtime_file(&yaml).await? + }; + #[cfg(not(target_os = "windows"))] let config_file = Config::generate_file().await?; let app_handle = handle::Handle::app_handle(); let clash_core = Config::verge().await.latest_arc().get_valid_clash_core(); diff --git a/src-tauri/src/core/service.rs b/src-tauri/src/core/service.rs index 499353f51..9245c1ce2 100644 --- a/src-tauri/src/core/service.rs +++ b/src-tauri/src/core/service.rs @@ -37,6 +37,9 @@ use std::{ time::{Duration, SystemTime, UNIX_EPOCH}, }; +#[cfg(target_os = "windows")] +pub(crate) mod windows_fallback; + static OWNER_MONITOR_GENERATION: AtomicU64 = AtomicU64::new(0); static ACTIVE_SERVICE_SESSION: Lazy>> = Lazy::new(|| Mutex::new(None)); static PENDING_SERVICE_FALLBACK_NOTICE: AtomicBool = AtomicBool::new(false); diff --git a/src-tauri/src/core/service/windows_fallback.rs b/src-tauri/src/core/service/windows_fallback.rs new file mode 100644 index 000000000..0212ee103 --- /dev/null +++ b/src-tauri/src/core/service/windows_fallback.rs @@ -0,0 +1,109 @@ +use anyhow::{Context as _, Result, bail}; +use std::os::windows::io::{AsRawHandle as _, FromRawHandle as _, OwnedHandle}; +use windows_service::{ + Error as WindowsServiceError, + service::{ServiceAccess, ServiceState}, + service_manager::{ServiceManager, ServiceManagerAccess}, +}; +use windows_sys::Win32::{ + Foundation::{ERROR_NO_MORE_FILES, ERROR_SERVICE_DOES_NOT_EXIST, INVALID_HANDLE_VALUE}, + System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS, + }, +}; + +pub(crate) async fn confirm_idle(location_refused: bool) -> Result<()> { + if location_refused { + let credentials = crate::core::owner_identity::current_owner_credentials()?; + let response = clash_verge_service_ipc::get_status(&credentials).await?; + if response.code != 0 { + bail!( + "cannot confirm idle service: code {} ({})", + response.code, + response.message + ); + } + let status = response.data.context("service did not return its status")?; + if status.is_active + || status.core_pid.is_some() + || status.service_state != clash_verge_service_ipc::ServiceLifecycleState::Running + { + bail!("service still has an active or recovering core session"); + } + } + tokio::task::spawn_blocking(move || { + if !location_refused { + require_stopped_service()?; + } + require_no_core_process(!location_refused)?; + if !location_refused { + require_stopped_service()?; + } + Ok(()) + }) + .await + .context("service fallback probe did not finish")? +} + +fn require_stopped_service() -> Result<()> { + let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?; + match manager.open_service( + clash_verge_service_ipc::WINDOWS_SERVICE_NAME, + ServiceAccess::QUERY_STATUS, + ) { + Ok(service) => { + let status = service.query_status()?; + if status.current_state != ServiceState::Stopped || status.process_id.is_some_and(|pid| pid != 0) { + bail!( + "service is {:?}; Sidecar fallback requires a stopped service", + status.current_state + ); + } + Ok(()) + } + Err(WindowsServiceError::Winapi(error)) + if error.raw_os_error() == Some(ERROR_SERVICE_DOES_NOT_EXIST as i32) => + { + Ok(()) + } + Err(error) => Err(error).context("cannot confirm that the service has stopped"), + } +} + +fn require_no_core_process(include_service: bool) -> Result<()> { + let raw = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }; + if raw == INVALID_HANDLE_VALUE { + return Err(std::io::Error::last_os_error()).context("cannot inspect existing core processes"); + } + let snapshot = unsafe { OwnedHandle::from_raw_handle(raw) }; + let mut entry = PROCESSENTRY32W { + dwSize: std::mem::size_of::() as u32, + ..unsafe { std::mem::zeroed() } + }; + let mut found = unsafe { Process32FirstW(snapshot.as_raw_handle(), &mut entry) }; + while found != 0 { + let length = entry + .szExeFile + .iter() + .position(|value| *value == 0) + .unwrap_or(entry.szExeFile.len()); + let name = String::from_utf16_lossy(&entry.szExeFile[..length]); + // SCM can be stopped while a core from an earlier service process remains alive. + if crate::config::IVerge::VALID_CLASH_CORES + .iter() + .any(|core| name.eq_ignore_ascii_case(&format!("{core}.exe"))) + || (include_service && name.eq_ignore_ascii_case("clash-verge-service.exe")) + { + bail!( + "process {name} (PID {}) is still running; refusing a second core", + entry.th32ProcessID + ); + } + found = unsafe { Process32NextW(snapshot.as_raw_handle(), &mut entry) }; + } + let error = std::io::Error::last_os_error(); + if error.raw_os_error() != Some(ERROR_NO_MORE_FILES as i32) { + return Err(error).context("core process enumeration did not complete"); + } + Ok(()) +} diff --git a/src/locales/ar/settings.json b/src/locales/ar/settings.json index 4fd6792cb..412421dcb 100644 --- a/src/locales/ar/settings.json +++ b/src/locales/ar/settings.json @@ -683,7 +683,7 @@ }, "clashService": { "installSuccess": "تم تثبيت الخدمة بنجاح", - "sidecarFallback": "تعذّر على الخدمة تشغيل النواة بسبب أذونات المسار. تم التبديل إلى وضع المسؤول لمواصلة التشغيل.", + "sidecarFallback": "تعذّر على الخدمة تشغيل النواة. تم التبديل إلى وضع Sidecar؛ ولا تزال الخدمة غير متاحة.", "uninstallSuccess": "تم إلغاء تثبيت الخدمة بنجاح" }, "updater": { diff --git a/src/locales/de/settings.json b/src/locales/de/settings.json index e6d16ec5b..42d2352b2 100644 --- a/src/locales/de/settings.json +++ b/src/locales/de/settings.json @@ -683,7 +683,7 @@ }, "clashService": { "installSuccess": "Service erfolgreich installiert", - "sidecarFallback": "Der Dienst konnte den Kern aufgrund der Pfadberechtigungen nicht starten. Die Anwendung läuft im Administratormodus weiter.", + "sidecarFallback": "Der Dienst konnte den Kern nicht starten. Die Anwendung läuft im Sidecar-Modus weiter; der Dienst bleibt nicht verfügbar.", "uninstallSuccess": "Service erfolgreich deinstalliert" }, "updater": { diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index 24450ae3f..6b480e6d4 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -683,7 +683,7 @@ }, "clashService": { "installSuccess": "Service Installed Successfully", - "sidecarFallback": "The service could not start the core due to path permissions. Switched to administrator mode to continue running.", + "sidecarFallback": "The service could not start the core. Switched to Sidecar mode; the service remains unavailable.", "uninstallSuccess": "Service Uninstalled Successfully" }, "updater": { diff --git a/src/locales/es/settings.json b/src/locales/es/settings.json index c686037e3..3124e24d3 100644 --- a/src/locales/es/settings.json +++ b/src/locales/es/settings.json @@ -683,7 +683,7 @@ }, "clashService": { "installSuccess": "Servicio instalado con éxito", - "sidecarFallback": "El servicio no pudo iniciar el núcleo debido a los permisos de la ruta. Se ha cambiado al modo administrador para continuar.", + "sidecarFallback": "El servicio no pudo iniciar el núcleo. Se ha cambiado al modo Sidecar; el servicio sigue sin estar disponible.", "uninstallSuccess": "Servicio desinstalado con éxito" }, "updater": { diff --git a/src/locales/fa/settings.json b/src/locales/fa/settings.json index 32cf0e447..ebab6eba0 100644 --- a/src/locales/fa/settings.json +++ b/src/locales/fa/settings.json @@ -683,7 +683,7 @@ }, "clashService": { "installSuccess": "سرویس با موفقیت نصب شد", - "sidecarFallback": "سرویس به‌دلیل مجوزهای مسیر نتوانست هسته را اجرا کند. برای ادامهٔ اجرا به حالت مدیر تغییر یافت.", + "sidecarFallback": "سرویس نتوانست هسته را اجرا کند. اجرا در حالت Sidecar ادامه می‌یابد؛ سرویس همچنان در دسترس نیست.", "uninstallSuccess": "سرویس با موفقیت حذف نصب شد" }, "updater": { diff --git a/src/locales/id/settings.json b/src/locales/id/settings.json index aefc26171..a9fc55815 100644 --- a/src/locales/id/settings.json +++ b/src/locales/id/settings.json @@ -683,7 +683,7 @@ }, "clashService": { "installSuccess": "Layanan Berhasil Diinstal", - "sidecarFallback": "Layanan tidak dapat memulai inti karena izin jalur. Beralih ke mode administrator untuk melanjutkan.", + "sidecarFallback": "Layanan tidak dapat memulai inti. Beralih ke mode Sidecar; layanan masih tidak tersedia.", "uninstallSuccess": "Layanan Berhasil Dicopot" }, "updater": { diff --git a/src/locales/jp/settings.json b/src/locales/jp/settings.json index b39537ecf..ac871770f 100644 --- a/src/locales/jp/settings.json +++ b/src/locales/jp/settings.json @@ -683,7 +683,7 @@ }, "clashService": { "installSuccess": "サービスのインストールに成功しました。", - "sidecarFallback": "パスの権限によりサービスがコアを起動できなかったため、管理者モードに切り替えて動作を継続しています。", + "sidecarFallback": "サービスでコアを起動できなかったため、Sidecar モードで動作を継続しています。サービスは引き続き利用できません。", "uninstallSuccess": "サービスのアンインストールに成功しました。" }, "updater": { diff --git a/src/locales/ko/settings.json b/src/locales/ko/settings.json index 6aadcdc98..b43047280 100644 --- a/src/locales/ko/settings.json +++ b/src/locales/ko/settings.json @@ -683,7 +683,7 @@ }, "clashService": { "installSuccess": "서비스가 설치되었습니다", - "sidecarFallback": "경로 권한 문제로 서비스가 코어를 시작하지 못해 관리자 모드로 전환하여 계속 실행합니다.", + "sidecarFallback": "서비스가 코어를 시작하지 못해 Sidecar 모드로 계속 실행합니다. 서비스는 여전히 사용할 수 없습니다.", "uninstallSuccess": "서비스가 제거되었습니다" }, "updater": { diff --git a/src/locales/ru/settings.json b/src/locales/ru/settings.json index cf5be05dd..0699c13c3 100644 --- a/src/locales/ru/settings.json +++ b/src/locales/ru/settings.json @@ -683,7 +683,7 @@ }, "clashService": { "installSuccess": "Служба успешно установлена", - "sidecarFallback": "Служба не смогла запустить ядро из-за прав доступа к пути. Работа продолжена в режиме администратора.", + "sidecarFallback": "Служба не смогла запустить ядро. Работа продолжена в режиме Sidecar; служба по-прежнему недоступна.", "uninstallSuccess": "Служба успешно удалена" }, "updater": { diff --git a/src/locales/tr/settings.json b/src/locales/tr/settings.json index 498a1075a..0c8a12ebf 100644 --- a/src/locales/tr/settings.json +++ b/src/locales/tr/settings.json @@ -683,7 +683,7 @@ }, "clashService": { "installSuccess": "Hizmet Başarıyla Kuruldu", - "sidecarFallback": "Hizmet, yol izinleri nedeniyle çekirdeği başlatamadı. Çalışmaya devam etmek için yönetici moduna geçildi.", + "sidecarFallback": "Hizmet çekirdeği başlatamadı. Sidecar moduna geçildi; hizmet hâlâ kullanılamıyor.", "uninstallSuccess": "Hizmet Başarıyla Kaldırıldı" }, "updater": { diff --git a/src/locales/tt/settings.json b/src/locales/tt/settings.json index 97c5e3abe..cb0078d81 100644 --- a/src/locales/tt/settings.json +++ b/src/locales/tt/settings.json @@ -683,7 +683,7 @@ }, "clashService": { "installSuccess": "Сервис уңышлы урнаштырылды", - "sidecarFallback": "Хезмәт юлга керү хокуклары аркасында төшне эшләтеп җибәрә алмады. Эшне дәвам итү өчен администратор режимына күчелде.", + "sidecarFallback": "Хезмәт төшне эшләтеп җибәрә алмады. Эш Sidecar режимында дәвам итә; хезмәт һаман да эшләми.", "uninstallSuccess": "Сервис уңышлы салдырылды" }, "updater": { diff --git a/src/locales/zh/settings.json b/src/locales/zh/settings.json index 090e6fff0..d96c10705 100644 --- a/src/locales/zh/settings.json +++ b/src/locales/zh/settings.json @@ -683,7 +683,7 @@ }, "clashService": { "installSuccess": "已成功安装服务", - "sidecarFallback": "服务因路径权限问题无法启动内核,已切回管理员模式继续运行。", + "sidecarFallback": "服务无法启动内核,已降级为 Sidecar 模式运行,服务仍不可用。", "uninstallSuccess": "已成功卸载服务" }, "updater": { diff --git a/src/locales/zhtw/settings.json b/src/locales/zhtw/settings.json index 89eb9689e..b66e52f10 100644 --- a/src/locales/zhtw/settings.json +++ b/src/locales/zhtw/settings.json @@ -683,7 +683,7 @@ }, "clashService": { "installSuccess": "已成功安裝服務", - "sidecarFallback": "服務因路徑權限問題無法啟動核心,已切回管理員模式繼續執行。", + "sidecarFallback": "服務無法啟動核心,已降級為 Sidecar 模式執行,服務仍無法使用。", "uninstallSuccess": "已成功解除安裝服務" }, "updater": {