fix(windows): fall back to sidecar after confirmed service failure

This commit is contained in:
wonfen
2026-09-21 21:48:13 +08:00
parent 7bf8e1dcf5
commit b7197f6f4b
18 changed files with 215 additions and 38 deletions
+1
View File
@@ -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",
] }
# *********************************
+63 -25
View File
@@ -186,39 +186,42 @@ where
}
#[cfg(target_os = "windows")]
async fn run_service_start_with_sidecar_fallback<Start, StartFuture, Fallback, FallbackFuture>(
is_admin: bool,
async fn run_service_start_with_sidecar_fallback<Start, StartFuture, Check, CheckFuture, Fallback, FallbackFuture>(
start_service: Start,
confirm_idle: Check,
start_sidecar: Fallback,
) -> Result<()>
where
Start: FnOnce() -> StartFuture,
StartFuture: std::future::Future<Output = Result<()>>,
Check: FnOnce(bool) -> CheckFuture,
CheckFuture: std::future::Future<Output = Result<()>>,
Fallback: FnOnce(anyhow::Error) -> FallbackFuture,
FallbackFuture: std::future::Future<Output = Result<()>>,
{
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::<crate::core::service::ServiceStartRefusal>()
.is_some_and(|refusal| {
refusal.code == clash_verge_service_ipc::ServiceErrorCode::InvalidInstallLocation as u16
});
if !is_admin || !location_refused {
let refusal = error.downcast_ref::<crate::core::service::ServiceStartRefusal>();
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<Clear, ClearFuture, Terminate>(
@@ -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;
+26
View File
@@ -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<std::string::String> {
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();
+3
View File
@@ -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<Mutex<Option<ActiveServiceSession>>> = Lazy::new(|| Mutex::new(None));
static PENDING_SERVICE_FALLBACK_NOTICE: AtomicBool = AtomicBool::new(false);
@@ -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::<PROCESSENTRY32W>() 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(())
}
+1 -1
View File
@@ -683,7 +683,7 @@
},
"clashService": {
"installSuccess": "تم تثبيت الخدمة بنجاح",
"sidecarFallback": "تعذّر على الخدمة تشغيل النواة بسبب أذونات المسار. تم التبديل إلى وضع المسؤول لمواصلة التشغيل.",
"sidecarFallback": "تعذّر على الخدمة تشغيل النواة. تم التبديل إلى وضع Sidecar؛ ولا تزال الخدمة غير متاحة.",
"uninstallSuccess": "تم إلغاء تثبيت الخدمة بنجاح"
},
"updater": {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -683,7 +683,7 @@
},
"clashService": {
"installSuccess": "سرویس با موفقیت نصب شد",
"sidecarFallback": "سرویس به‌دلیل مجوزهای مسیر نتوانست هسته را اجرا کند. برای ادامهٔ اجرا به حالت مدیر تغییر یافت.",
"sidecarFallback": "سرویس نتوانست هسته را اجرا کند. اجرا در حالت Sidecar ادامه می‌یابد؛ سرویس همچنان در دسترس نیست.",
"uninstallSuccess": "سرویس با موفقیت حذف نصب شد"
},
"updater": {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -683,7 +683,7 @@
},
"clashService": {
"installSuccess": "サービスのインストールに成功しました。",
"sidecarFallback": "パスの権限によりサービスがコアを起動できなかったため、管理者モードに切り替えて動作を継続しています。",
"sidecarFallback": "サービスでコアを起動できなかったため、Sidecar モードで動作を継続しています。サービスは引き続き利用できません。",
"uninstallSuccess": "サービスのアンインストールに成功しました。"
},
"updater": {
+1 -1
View File
@@ -683,7 +683,7 @@
},
"clashService": {
"installSuccess": "서비스가 설치되었습니다",
"sidecarFallback": "경로 권한 문제로 서비스가 코어를 시작하지 못해 관리자 모드로 전환하여 계속 실행합니다.",
"sidecarFallback": "서비스가 코어를 시작하지 못해 Sidecar 모드로 계속 실행합니다. 서비스는 여전히 사용할 수 없습니다.",
"uninstallSuccess": "서비스가 제거되었습니다"
},
"updater": {
+1 -1
View File
@@ -683,7 +683,7 @@
},
"clashService": {
"installSuccess": "Служба успешно установлена",
"sidecarFallback": "Служба не смогла запустить ядро из-за прав доступа к пути. Работа продолжена в режиме администратора.",
"sidecarFallback": "Служба не смогла запустить ядро. Работа продолжена в режиме Sidecar; служба по-прежнему недоступна.",
"uninstallSuccess": "Служба успешно удалена"
},
"updater": {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -683,7 +683,7 @@
},
"clashService": {
"installSuccess": "Сервис уңышлы урнаштырылды",
"sidecarFallback": "Хезмәт юлга керү хокуклары аркасында төшне эшләтеп җибәрә алмады. Эшне дәвам итү өчен администратор режимына күчелде.",
"sidecarFallback": "Хезмәт төшне эшләтеп җибәрә алмады. Эш Sidecar режимында дәвам итә; хезмәт һаман да эшләми.",
"uninstallSuccess": "Сервис уңышлы салдырылды"
},
"updater": {
+1 -1
View File
@@ -683,7 +683,7 @@
},
"clashService": {
"installSuccess": "已成功安装服务",
"sidecarFallback": "服务因路径权限问题无法启动内核,已切回管理员模式继续运行。",
"sidecarFallback": "服务无法启动内核,已降级为 Sidecar 模式运行,服务仍不可用。",
"uninstallSuccess": "已成功卸载服务"
},
"updater": {
+1 -1
View File
@@ -683,7 +683,7 @@
},
"clashService": {
"installSuccess": "已成功安裝服務",
"sidecarFallback": "服務因路徑權限問題無法啟動核心,已切回管理員模式繼續執行。",
"sidecarFallback": "服務無法啟動核心,已降級為 Sidecar 模式執行,服務仍無法使用。",
"uninstallSuccess": "已成功解除安裝服務"
},
"updater": {