Files
clash-verge-rev/src-tauri/src/core/manager/lifecycle.rs
T
wonfen 57c5484080 fix(service): stop waiting on a service that no longer starts with Windows
When the service's start type was changed from automatic, startup waited
about two minutes for it before falling back to Sidecar. Detect this
immediately, skip the TUN readiness wait, and offer a reinstall that
restores automatic start.

Refs #8129
2026-10-03 20:40:51 +08:00

1614 lines
60 KiB
Rust

use super::{CoreManager, RunningMode};
use crate::config::{Config, IVerge};
use crate::core::handle::Handle;
use crate::core::manager::CLASH_LOGGER;
use crate::core::proxy_control::{self, SysproxyFailure};
use crate::core::service::{SERVICE_MANAGER, ServiceStatus};
use anyhow::Result;
use clash_verge_logging::{Type, logging};
use scopeguard::defer;
use smartstring::alias::String;
use std::path::Path;
#[cfg(target_os = "windows")]
use tauri_plugin_clash_verge_sysinfo::is_current_app_handle_admin;
#[cfg(any(target_os = "windows", test))]
const fn should_wait_for_service(tun_enabled: bool, service_ready: bool, is_admin: bool) -> bool {
tun_enabled && !service_ready && !is_admin
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StartupDecision {
Service,
Sidecar,
Wait,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ProxyStopIntent {
Clear,
HandOverToService,
}
const fn proxy_stop_intent(is_macos: bool, running_mode: RunningMode, decision: StartupDecision) -> ProxyStopIntent {
match (is_macos, running_mode, decision) {
(true, RunningMode::Sidecar, StartupDecision::Service) => ProxyStopIntent::HandOverToService,
_ => ProxyStopIntent::Clear,
}
}
const fn startup_decision(status: &ServiceStatus, service_required: bool) -> StartupDecision {
match status {
ServiceStatus::Ready => StartupDecision::Service,
ServiceStatus::NotInstalled if !service_required => StartupDecision::Sidecar,
ServiceStatus::SidecarAllowed => StartupDecision::Sidecar,
ServiceStatus::Checking
| ServiceStatus::NotInstalled
| ServiceStatus::NeedsReinstall
| ServiceStatus::InstallRequired
| ServiceStatus::Unavailable(_) => StartupDecision::Wait,
ServiceStatus::UninstallRequired | ServiceStatus::ReinstallRequired | ServiceStatus::ForceReinstallRequired => {
StartupDecision::Wait
}
}
}
const fn can_allow_sidecar_for_session(running_mode: &RunningMode, service_status: &ServiceStatus) -> bool {
matches!(
(running_mode, service_status),
(
RunningMode::NotRunning,
ServiceStatus::NotInstalled
| ServiceStatus::NeedsReinstall
| ServiceStatus::InstallRequired
| ServiceStatus::Unavailable(_)
| ServiceStatus::SidecarAllowed
) | (
RunningMode::Sidecar,
ServiceStatus::InstallRequired | ServiceStatus::Unavailable(_) | ServiceStatus::SidecarAllowed
)
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ProxyRestoreExpectation {
mode: RunningMode,
core_readiness_generation: u64,
service_owner_generation: Option<u64>,
}
impl ProxyRestoreExpectation {
const fn capture(
mode: RunningMode,
core_readiness_generation: Option<u64>,
service_owner_generation: u64,
) -> Option<Self> {
let Some(core_readiness_generation) = core_readiness_generation else {
return None;
};
match mode {
RunningMode::Service => Some(Self {
mode,
core_readiness_generation,
service_owner_generation: Some(service_owner_generation),
}),
RunningMode::Sidecar => Some(Self {
mode,
core_readiness_generation,
service_owner_generation: None,
}),
RunningMode::NotRunning => None,
}
}
fn is_valid(
self,
current_mode: &RunningMode,
current_core_readiness_generation: Option<u64>,
current_service_owner_generation: u64,
) -> bool {
self.mode == *current_mode
&& Some(self.core_readiness_generation) == current_core_readiness_generation
&& self
.service_owner_generation
.is_none_or(|expected| expected == current_service_owner_generation)
}
}
async fn run_controlled_stop_transition<StopGuard, StopGuardFuture, Clear, ClearFuture, Stop, StopFuture>(
is_macos: bool,
running_mode: RunningMode,
proxy_intent: ProxyStopIntent,
stop_guard: StopGuard,
clear_proxy: Clear,
stop_core: Stop,
) -> Result<()>
where
StopGuard: FnOnce() -> StopGuardFuture,
StopGuardFuture: std::future::Future<Output = bool>,
Clear: FnOnce() -> ClearFuture,
ClearFuture: std::future::Future<Output = Result<()>>,
Stop: FnOnce() -> StopFuture,
StopFuture: std::future::Future<Output = Result<()>>,
{
match (running_mode, proxy_intent) {
(RunningMode::NotRunning, _) => {}
// A guard that will not stop must not block the core from stopping.
(RunningMode::Sidecar, ProxyStopIntent::HandOverToService) => warn_if_guard_lingers(stop_guard().await),
(RunningMode::Service, _) if is_macos => warn_if_guard_lingers(stop_guard().await),
(RunningMode::Service | RunningMode::Sidecar, _) => clear_proxy().await?,
}
stop_core().await
}
fn warn_if_guard_lingers(stopped: bool) {
if !stopped {
logging!(
warn,
Type::Core,
"the system proxy guard did not stop in time; stopping the core anyway"
);
}
}
async fn run_core_start_transition<Start, StartFuture, Ready, Apply, ApplyFuture>(
start_core: Start,
core_is_ready: Ready,
apply_proxy: Apply,
) -> Result<()>
where
Start: FnOnce() -> StartFuture,
StartFuture: std::future::Future<Output = Result<()>>,
Ready: FnOnce() -> bool,
Apply: FnOnce() -> ApplyFuture,
ApplyFuture: std::future::Future<Output = Result<()>>,
{
start_core().await?;
if core_is_ready() {
apply_proxy().await?;
}
Ok(())
}
async fn run_ready_core_start_transition<Start, StartFuture, Ready, Apply, ApplyFuture>(
start_core: Start,
core_is_ready: Ready,
apply_proxy: Apply,
) -> Result<()>
where
Start: FnOnce() -> StartFuture,
StartFuture: std::future::Future<Output = Result<()>>,
Ready: FnOnce() -> bool,
Apply: FnOnce() -> ApplyFuture,
ApplyFuture: std::future::Future<Output = Result<()>>,
{
start_core().await?;
if !core_is_ready() {
anyhow::bail!("core did not become ready after start");
}
apply_proxy().await
}
#[cfg(any(target_os = "windows", test))]
async fn run_service_start_with_sidecar_fallback<Start, StartFuture, Check, CheckFuture, Fallback, FallbackFuture>(
start_service: Start,
confirm_idle: Check,
start_sidecar: Fallback,
record_unavailable: impl FnOnce(std::string::String),
) -> Result<()>
where
Start: FnOnce() -> StartFuture,
StartFuture: std::future::Future<Output = Result<()>>,
Check: FnOnce() -> 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(());
};
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 start response can hide a running core; the shared check must resolve occupancy.
if let Err(probe_error) = confirm_idle().await {
let error = error.context(format!("Sidecar fallback was not safe: {probe_error:#}"));
record_unavailable(format!("{error:#}"));
return Err(error);
}
logging!(
warn,
Type::Core,
"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 failure: {reason}")))
}
async fn run_sidecar_termination_transition<Clear, ClearFuture, Terminate, TerminateFuture>(
clear_proxy: Clear,
terminate_sidecar: Terminate,
) -> Result<()>
where
Clear: FnOnce() -> ClearFuture,
ClearFuture: std::future::Future<Output = Result<()>>,
Terminate: FnOnce() -> TerminateFuture,
TerminateFuture: std::future::Future<Output = Result<()>>,
{
clear_proxy().await?;
terminate_sidecar().await
}
async fn run_service_config_replacement_transition<
StopGuard,
StopGuardFuture,
Clear,
ClearFuture,
Stop,
StopFuture,
Start,
StartFuture,
Ready,
Restore,
RestoreFuture,
>(
is_macos: bool,
stop_guard: StopGuard,
clear_proxy: Clear,
stop_core: Stop,
start_core: Start,
core_is_ready: Ready,
restore_proxy: Restore,
) -> Result<()>
where
StopGuard: FnOnce() -> StopGuardFuture,
StopGuardFuture: std::future::Future<Output = bool>,
Clear: FnOnce() -> ClearFuture,
ClearFuture: std::future::Future<Output = Result<()>>,
Stop: FnOnce() -> StopFuture,
StopFuture: std::future::Future<Output = Result<()>>,
Start: FnOnce() -> StartFuture,
StartFuture: std::future::Future<Output = Result<()>>,
Ready: FnOnce() -> bool,
Restore: FnOnce() -> RestoreFuture,
RestoreFuture: std::future::Future<Output = Result<()>>,
{
run_controlled_stop_transition(
is_macos,
RunningMode::Service,
ProxyStopIntent::Clear,
stop_guard,
clear_proxy,
stop_core,
)
.await?;
run_ready_core_start_transition(start_core, core_is_ready, restore_proxy).await
}
pub(super) async fn run_core_replacement_transition<Stop, StopFuture, Start, StartFuture, Restore, RestoreFuture>(
stop_core: Stop,
start_core: Start,
restore_proxy: Restore,
) -> Result<()>
where
Stop: FnOnce() -> StopFuture,
StopFuture: std::future::Future<Output = Result<()>>,
Start: FnOnce() -> StartFuture,
StartFuture: std::future::Future<Output = Result<()>>,
Restore: FnOnce() -> RestoreFuture,
RestoreFuture: std::future::Future<Output = Result<()>>,
{
stop_core().await?;
start_core().await?;
restore_proxy().await
}
async fn run_uninstall_transition<Uninstall, UninstallFuture, Prepare, PrepareFuture, Start, StartFuture>(
uninstall: Uninstall,
prepare_sidecar: Prepare,
start_sidecar: Start,
) -> Result<()>
where
Uninstall: FnOnce() -> UninstallFuture,
UninstallFuture: std::future::Future<Output = Result<()>>,
Prepare: FnOnce() -> PrepareFuture,
PrepareFuture: std::future::Future<Output = Result<()>>,
Start: FnOnce() -> StartFuture,
StartFuture: std::future::Future<Output = Result<()>>,
{
uninstall().await?;
prepare_sidecar().await?;
start_sidecar().await
}
#[cfg(target_os = "windows")]
enum HandoffOutcome {
NotReady,
Done,
Failed,
}
fn report_sidecar_failure(error: anyhow::Error, service_rejection: Option<&str>) -> anyhow::Error {
let error = match service_rejection {
Some(reason) => error.context(format!("Service core rejected: {reason}; Sidecar fallback failed")),
None => error,
};
// Proxy failures have their own pending state and recovery dialog.
if SysproxyFailure::from_chain(&error).is_none() {
crate::core::notification::record_sidecar_failure(format!("{error:#}").into());
}
error
}
impl CoreManager {
async fn rollback_failed_start(&self) {
proxy_control::stop_guard().await;
self.core_stopped();
}
pub async fn start_core(&self) -> Result<()> {
if !self.try_start_config_update() {
anyhow::bail!("configuration update is already running");
}
defer! {
self.finish_config_update();
}
self.start_core_during_config_update().await
}
pub(crate) async fn start_core_during_config_update(&self) -> Result<()> {
let _life = self.lifecycle_lock.lock().await;
run_core_start_transition(
|| self.start_core_inner(),
|| {
!matches!(*self.get_running_mode(), RunningMode::NotRunning)
&& self.current_core_readiness_generation().is_some()
},
|| self.apply_proxy_after_start(),
)
.await
}
#[tracing::instrument(skip_all, level = "info", fields(status = tracing::field::Empty, tun_disabled = false, readiness_generation = tracing::field::Empty))]
pub async fn continue_with_sidecar(&self, service_rejection: Option<&str>) -> Result<()> {
if !self.try_start_config_update() {
return Err(report_sidecar_failure(
anyhow::anyhow!("configuration update is already running"),
service_rejection,
));
}
defer! {
self.finish_config_update();
}
// Lock order is config then lifecycle. disable_tun_and_persist commits the shared Verge
// draft without claiming it, so without this it can commit another transaction's staged patch.
let config_write = Config::lock_config_write().await;
let _life = self.lifecycle_lock.lock().await;
// Record failures before releasing the lifecycle lock, including preparation and rollback errors.
let result = async {
let status = SERVICE_MANAGER.current().await;
tracing::Span::current().record("status", tracing::field::debug(&status));
let mode = self.get_running_mode();
if !can_allow_sidecar_for_session(&mode, &status) {
anyhow::bail!("Sidecar continuation is not allowed from {mode:?} / {status:?}");
}
#[cfg(target_os = "windows")]
if matches!(*mode, RunningMode::NotRunning) {
clash_verge_service_ipc::execution::check_sidecar_available().await?;
}
if !matches!(status, ServiceStatus::SidecarAllowed) {
SERVICE_MANAGER.allow_sidecar_for_session()?;
}
// Settling on Sidecar is what makes the verdict final, so ask only once it is recorded.
// Elevation alone carries TUN on Sidecar; a Sidecar that cannot must write it off.
let prepared = async {
let tun_enabled = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false);
if crate::core::runstate::RUN_STATE
.state()
.tun_should_be_disabled(tun_enabled)
{
tracing::Span::current().record("tun_disabled", true);
Config::disable_tun_and_persist().await?;
}
Config::generate().await
}
.await;
drop(config_write);
if let Err(error) = prepared {
SERVICE_MANAGER.withdraw_sidecar_allowance();
return Err(error);
}
// Drop the guard last so an earlier failure cannot leave a running Sidecar unguarded.
proxy_control::stop_guard().await;
let result = async {
self.start_core_inner().await?;
if !matches!(*self.get_running_mode(), RunningMode::Sidecar)
|| self.current_core_readiness_generation().is_none()
{
anyhow::bail!("Sidecar did not become ready");
}
tracing::Span::current().record("readiness_generation", self.current_core_readiness_generation());
self.apply_proxy_after_start().await
}
.await;
if let Err(error) = result {
// Revoke only the failed Sidecar allowance; its startup says nothing about Service health.
SERVICE_MANAGER.withdraw_sidecar_allowance();
if let Err(cleanup_error) = self.rollback_failed_sidecar_transition().await {
return Err(proxy_control::rollback_failure(error, cleanup_error)
.context("failed to clear the proxy before rolling back"));
}
return Err(error);
}
Ok(())
}
.await;
result.map_err(|error| report_sidecar_failure(error, service_rejection))
}
#[tracing::instrument(skip_all, level = "info", fields(readiness_generation = tracing::field::Empty))]
pub async fn uninstall_service_and_start_sidecar(&self) -> Result<()> {
if !self.try_start_config_update() {
anyhow::bail!("configuration update is already running");
}
defer! {
self.finish_config_update();
}
// Lock order is config then lifecycle, for the unclaimed draft write below.
let _config_write = Config::lock_config_write().await;
let _life = self.lifecycle_lock.lock().await;
self.controlled_stop_core_inner().await?;
let result = run_uninstall_transition(
|| async {
SERVICE_MANAGER
.handle_service_status(ServiceStatus::UninstallRequired)
.await
},
|| async {
// Must be asked after the uninstall: until then the Service still makes TUN capable.
let tun_enabled = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false);
if crate::core::runstate::RUN_STATE
.state()
.tun_should_be_disabled(tun_enabled)
{
Config::disable_tun_and_persist().await?;
}
Config::generate().await
},
|| async {
self.start_core_inner().await?;
if !matches!(*self.get_running_mode(), RunningMode::Sidecar)
|| self.current_core_readiness_generation().is_none()
{
anyhow::bail!("Sidecar did not become ready after service uninstall");
}
tracing::Span::current().record("readiness_generation", self.current_core_readiness_generation());
self.apply_proxy_after_start().await
},
)
.await;
if let Err(error) = result {
if let Err(cleanup_error) = self.rollback_failed_sidecar_transition().await {
return Err(proxy_control::rollback_failure(error, cleanup_error)
.context("failed to clear the proxy before rolling back"));
}
return Err(error);
}
Ok(())
}
async fn rollback_failed_sidecar_transition(&self) -> Result<()> {
if matches!(*self.get_running_mode(), RunningMode::Sidecar) {
self.stop_sidecar_after_proxy_clear().await?;
}
self.rollback_failed_start().await;
Ok(())
}
async fn stop_sidecar_after_proxy_clear(&self) -> Result<()> {
run_sidecar_termination_transition(proxy_control::clear, || self.stop_core_by_sidecar_unprepared()).await
}
pub(super) async fn replace_service_core_with_config(&self, config_file: &Path) -> Result<()> {
run_service_config_replacement_transition(
cfg!(target_os = "macos"),
proxy_control::stop_guard,
proxy_control::clear,
|| self.stop_core_unprepared_inner(),
|| self.start_core_by_service_with_config(config_file),
|| {
matches!(*self.get_running_mode(), RunningMode::Service)
&& self.current_core_readiness_generation().is_some()
},
|| self.apply_proxy_after_start(),
)
.await
}
#[tracing::instrument(skip_all, level = "info", fields(mode = ?*self.get_running_mode(), readiness_generation = self.current_core_readiness_generation(), owner_generation = crate::core::service::owner_monitor_generation()))]
pub(crate) async fn apply_proxy_after_start(&self) -> Result<()> {
let expectation = ProxyRestoreExpectation::capture(
*self.get_running_mode(),
self.current_core_readiness_generation(),
crate::core::service::owner_monitor_generation(),
)
.ok_or_else(|| {
anyhow::anyhow!("cannot apply system proxy before core readiness").context(SysproxyFailure::CoreNotReady)
})?;
// At login the app usually beats the network. Leave the write to the network watcher
// rather than fail — only if the watcher is actually live; a user toggling while
// offline still fails fast.
#[cfg(target_os = "macos")]
if crate::core::network_watch::is_armed()
&& !crate::utils::resolve::is_resolve_done()
&& Config::verge()
.await
.latest_arc()
.enable_system_proxy
.unwrap_or_default()
&& !proxy_control::has_network_service().await
{
logging!(
info,
Type::Core,
"no network service yet; the system proxy is applied once one appears"
);
return Ok(());
}
proxy_control::apply().await?;
if !expectation.is_valid(
self.get_running_mode().as_ref(),
self.current_core_readiness_generation(),
crate::core::service::owner_monitor_generation(),
) {
let clear_result = proxy_control::clear().await;
proxy_control::stop_guard().await;
clear_result?;
return Err(anyhow::anyhow!("core readiness changed while applying system proxy")
.context(SysproxyFailure::CoreNotReady));
}
proxy_control::refresh_guard().await?;
if !expectation.is_valid(
self.get_running_mode().as_ref(),
self.current_core_readiness_generation(),
crate::core::service::owner_monitor_generation(),
) {
let clear_result = proxy_control::clear().await;
proxy_control::stop_guard().await;
clear_result?;
return Err(
anyhow::anyhow!("core readiness changed while refreshing the system proxy guard")
.context(SysproxyFailure::CoreNotReady),
);
}
// tell the window when a background apply lands
Handle::refresh_verge();
crate::core::notification::retire_sidecar_failure();
Ok(())
}
#[tracing::instrument(skip_all, level = "info", fields(decision = tracing::field::Empty))]
async fn start_core_inner(&self) -> Result<()> {
if Handle::global().is_exiting() {
return Ok(());
}
if !matches!(*self.get_running_mode(), RunningMode::NotRunning) {
logging!(
debug,
Type::Core,
"start_core called while a core is running; treated as no-op"
);
return Ok(());
}
let startup = self.prepare_startup().await;
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() {
self.core_stopped();
return Ok(());
}
let result = match startup {
StartupDecision::Service => {
#[cfg(target_os = "windows")]
{
run_service_start_with_sidecar_fallback(
|| self.start_core_by_service(),
clash_verge_service_ipc::execution::check_sidecar_available,
|error| self.start_sidecar_after_service_failure(error),
|reason| {
crate::core::runstate::RUN_STATE
.observe(crate::core::runstate::ServiceHealth::Unavailable(reason));
},
)
.await
}
#[cfg(not(target_os = "windows"))]
self.start_core_by_service().await
}
StartupDecision::Sidecar => self.start_core_by_sidecar().await,
StartupDecision::Wait => Ok(()),
};
// A failed start must not leave a locally reported running mode.
if result.is_err() {
self.rollback_failed_start().await;
return result;
}
#[cfg(target_os = "windows")]
if matches!(*self.get_running_mode(), RunningMode::Sidecar) {
self.spawn_service_handoff_watcher().await;
}
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) = clash_verge_service_ipc::execution::check_sidecar_available().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(&reason);
}
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;
self.controlled_stop_core_inner().await
}
pub(crate) async fn controlled_stop_core_inner(&self) -> Result<()> {
self.controlled_stop_core_with_intent(ProxyStopIntent::Clear).await
}
async fn controlled_stop_core_with_intent(&self, proxy_intent: ProxyStopIntent) -> Result<()> {
let running_mode = *self.get_running_mode();
run_controlled_stop_transition(
cfg!(target_os = "macos"),
running_mode,
proxy_intent,
proxy_control::stop_guard,
proxy_control::clear,
|| self.stop_core_unprepared_inner(),
)
.await
}
async fn proxy_stop_intent(&self) -> ProxyStopIntent {
let running_mode = *self.get_running_mode();
if !cfg!(target_os = "macos") || !matches!(running_mode, RunningMode::Sidecar) {
return ProxyStopIntent::Clear;
}
proxy_stop_intent(cfg!(target_os = "macos"), running_mode, self.prepare_startup().await)
}
async fn stop_core_unprepared_inner(&self) -> Result<()> {
CLASH_LOGGER.clear_logs();
match *self.get_running_mode() {
RunningMode::Service => self.stop_core_by_service().await,
RunningMode::Sidecar => self.stop_core_by_sidecar_unprepared().await,
RunningMode::NotRunning => Ok(()),
}
}
#[tracing::instrument(skip_all, level = "info")]
pub async fn restart_core(&self) -> Result<()> {
if !self.try_start_config_update() {
anyhow::bail!("configuration update is already running");
}
defer! {
self.finish_config_update();
}
self.restart_core_during_config_update().await
}
pub(crate) async fn restart_core_during_config_update(&self) -> Result<()> {
let _life = self.lifecycle_lock.lock().await;
let proxy_intent = self.proxy_stop_intent().await;
run_core_replacement_transition(
|| self.controlled_stop_core_with_intent(proxy_intent),
|| async {
self.start_core_inner().await?;
if matches!(*self.get_running_mode(), RunningMode::NotRunning)
|| self.current_core_readiness_generation().is_none()
{
anyhow::bail!("core did not become ready after restart");
}
Ok(())
},
|| self.apply_proxy_after_start(),
)
.await
}
#[tracing::instrument(skip_all, level = "info", fields(core = %clash_core))]
pub async fn change_core(&self, clash_core: &String) -> Result<()> {
if !IVerge::VALID_CLASH_CORES.contains(&clash_core.as_str()) {
anyhow::bail!("invalid clash core: {clash_core}");
}
Config::verge().await.edit_draft(|d| {
d.clash_core = Some(clash_core.to_owned());
});
Config::verge().await.apply();
let verge_data = Config::verge().await.latest_arc();
verge_data.save_file().await?;
self.update_config_checked().await
}
async fn prepare_startup(&self) -> StartupDecision {
#[cfg(target_os = "windows")]
self.wait_for_service_if_needed().await;
let service_required = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false);
if service_required
&& matches!(SERVICE_MANAGER.current().await, ServiceStatus::NotInstalled)
&& SERVICE_MANAGER.require_install_for_session().is_err()
{
return StartupDecision::Wait;
}
startup_decision(&SERVICE_MANAGER.current().await, service_required)
}
#[cfg(target_os = "windows")]
async fn wait_for_service_if_needed(&self) {
use crate::{config::Config, constants::timing};
let tun_enabled = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false);
let service_ready = matches!(SERVICE_MANAGER.current().await, ServiceStatus::Ready);
let is_admin = is_current_app_handle_admin(Handle::app_handle());
if !should_wait_for_service(tun_enabled, service_ready, is_admin) {
if tun_enabled && !service_ready && is_admin {
logging!(
info,
Type::Core,
"service unavailable while app is elevated; starting sidecar immediately"
);
}
return;
}
let state = crate::core::runstate::RUN_STATE.state();
// Waiting cannot bring up a service nothing will start.
if state.service_usable()
|| matches!(&state.health, crate::core::runstate::ServiceHealth::Unavailable(reason)
if crate::core::service::is_not_auto_started(reason))
{
return;
}
// Failing to become ready is not fatal here: the caller falls back to Sidecar, and a
// transient transport failure must not overwrite the last confirmed install state.
let attempts = (timing::SERVICE_WAIT_MAX.as_millis() / timing::SERVICE_WAIT_INTERVAL.as_millis()) as usize;
let wait = crate::core::runstate::RUN_STATE.await_ready(attempts, timing::SERVICE_WAIT_INTERVAL);
let _ = tokio::time::timeout(timing::SERVICE_WAIT_MAX, wait).await;
}
#[cfg(target_os = "windows")]
async fn spawn_service_handoff_watcher(&self) {
use crate::constants::timing;
use crate::process::AsyncHandler;
use std::sync::atomic::Ordering;
use std::time::Instant;
// An accepted Sidecar is the user's decision for this session, not a wait for the Service.
let needs_service = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false)
&& !crate::core::runstate::RUN_STATE.state().sidecar_allowed;
if !needs_service {
return;
}
if self.handoff_watcher_running.swap(true, Ordering::AcqRel) {
return;
}
logging!(
info,
Type::Core,
"service not ready at startup; sidecar active, watching for handoff"
);
AsyncHandler::spawn(|| async move {
let manager = Self::global();
let started = Instant::now();
loop {
if started.elapsed() >= timing::SERVICE_HANDOFF_WINDOW {
logging!(
info,
Type::Core,
"service handoff window elapsed; staying in sidecar mode"
);
break;
}
tokio::time::sleep(timing::SERVICE_HANDOFF_INTERVAL).await;
if !matches!(*manager.get_running_mode(), RunningMode::Sidecar) {
break;
}
match manager.try_handoff_sidecar_to_service().await {
HandoffOutcome::Done => break,
HandoffOutcome::Failed => {
logging!(warn, Type::Core, "handoff attempt failed; staying in sidecar mode");
break;
}
HandoffOutcome::NotReady => {}
}
}
manager.handoff_watcher_running.store(false, Ordering::Release);
});
}
#[cfg(target_os = "windows")]
async fn refresh_service_readiness_for_handoff() -> bool {
if SERVICE_MANAGER.confirm_ready().await.is_err() {
return false;
}
crate::core::runstate::RUN_STATE.state().service_usable()
}
#[cfg(target_os = "windows")]
#[tracing::instrument(skip_all, level = "debug", fields(outcome = tracing::field::Empty))]
async fn try_handoff_sidecar_to_service(&self) -> HandoffOutcome {
// Before probing: a probe reply records an observation, which clears sidecar_allowed.
if crate::core::runstate::RUN_STATE.state().sidecar_allowed {
return HandoffOutcome::Done;
}
if !Self::refresh_service_readiness_for_handoff().await {
return HandoffOutcome::NotReady;
}
if !self.try_start_config_update() {
return HandoffOutcome::NotReady;
}
defer! {
self.finish_config_update();
}
// Lock order is config then lifecycle.
let _life = self.lifecycle_lock.lock().await;
if !matches!(*self.get_running_mode(), RunningMode::Sidecar)
|| !Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false)
|| crate::core::runstate::RUN_STATE.state().sidecar_allowed
{
return HandoffOutcome::Done;
}
logging!(
info,
Type::Core,
"service became ready; handing off from sidecar to service"
);
if let Err(error) = self.stop_sidecar_after_proxy_clear().await {
logging!(
error,
Type::Core,
"failed to clear proxy before sidecar handoff: {error:#}"
);
return HandoffOutcome::Failed;
}
let service_result = run_ready_core_start_transition(
|| self.start_core_by_service(),
|| {
matches!(*self.get_running_mode(), RunningMode::Service)
&& self.current_core_readiness_generation().is_some()
},
|| self.apply_proxy_after_start(),
)
.await;
let Err(service_error) = service_result else {
logging!(info, Type::Core, "handoff to service mode succeeded");
return HandoffOutcome::Done;
};
logging!(error, Type::Core, "handoff to service failed: {service_error:#}");
if let Err(cleanup_error) = self.controlled_stop_core_inner().await {
logging!(
error,
Type::Core,
"failed to stop service before sidecar fallback: {cleanup_error:#}"
);
return HandoffOutcome::Failed;
}
let sidecar_result = run_ready_core_start_transition(
|| self.start_core_by_sidecar(),
|| {
matches!(*self.get_running_mode(), RunningMode::Sidecar)
&& self.current_core_readiness_generation().is_some()
},
|| self.apply_proxy_after_start(),
)
.await;
if let Err(sidecar_error) = sidecar_result {
logging!(error, Type::Core, "sidecar fallback failed: {sidecar_error:#}");
if let Err(cleanup_error) = self.rollback_failed_sidecar_transition().await {
logging!(
error,
Type::Core,
"failed to clear proxy before sidecar rollback: {cleanup_error:#}"
);
}
} else {
logging!(
info,
Type::Core,
"sidecar fallback became ready and restored proxy state"
);
}
HandoffOutcome::Failed
}
}
#[cfg(test)]
mod tests {
use super::{
CoreManager, ProxyRestoreExpectation, ProxyStopIntent, StartupDecision, can_allow_sidecar_for_session,
run_controlled_stop_transition, run_core_replacement_transition, run_core_start_transition,
run_ready_core_start_transition, run_service_config_replacement_transition, run_sidecar_termination_transition,
run_uninstall_transition, should_wait_for_service, startup_decision,
};
use crate::core::{manager::RunningMode, service::ServiceStatus};
use parking_lot::Mutex;
use std::{
future,
sync::{
Arc,
atomic::{AtomicBool, AtomicU64, Ordering},
},
task::Poll,
};
use tokio::sync::{Barrier, Mutex as AsyncMutex};
struct FakeGuardCoordinator {
generation: AtomicU64,
operation_lock: AsyncMutex<()>,
}
impl FakeGuardCoordinator {
const fn new() -> Self {
Self {
generation: AtomicU64::new(1),
operation_lock: AsyncMutex::const_new(()),
}
}
async fn run_guard_request(&self, captured_generation: u64, calls: &Mutex<Vec<&'static str>>) -> bool {
let _operation = self.operation_lock.lock().await;
if self.generation.load(Ordering::Acquire) != captured_generation {
return false;
}
calls.lock().push("guard-reapply");
true
}
async fn stop_guard(&self, calls: &Mutex<Vec<&'static str>>) -> bool {
self.generation.fetch_add(1, Ordering::AcqRel);
let _operation = self.operation_lock.lock().await;
calls.lock().push("guard-stopped");
true
}
}
fn transition_step(
calls: &Arc<Mutex<Vec<&'static str>>>,
step: &'static str,
fail_at: Option<&'static str>,
) -> future::Ready<anyhow::Result<()>> {
calls.lock().push(step);
future::ready(if fail_at == Some(step) {
Err(anyhow::anyhow!("{step} failed"))
} else {
Ok(())
})
}
#[tokio::test]
async fn uninstall_transition_is_ordered_and_short_circuits_at_every_failure() {
let steps = ["uninstall", "prepare-sidecar", "start-sidecar"];
for fail_at in std::iter::once(None).chain(steps.map(Some)) {
let calls = Arc::new(Mutex::new(Vec::new()));
let result = run_uninstall_transition(
|| transition_step(&calls, steps[0], fail_at),
|| transition_step(&calls, steps[1], fail_at),
|| transition_step(&calls, steps[2], fail_at),
)
.await;
let expected_len = fail_at
.and_then(|failed| steps.iter().position(|step| *step == failed).map(|index| index + 1))
.unwrap_or(steps.len());
assert_eq!(&*calls.lock(), &steps[..expected_len]);
assert_eq!(result.is_err(), fail_at.is_some());
}
}
#[test]
fn only_a_macos_sidecar_on_its_way_to_the_service_leaves_the_proxy_alone() {
use super::proxy_stop_intent;
assert_eq!(
proxy_stop_intent(true, RunningMode::Sidecar, StartupDecision::Service),
ProxyStopIntent::HandOverToService
);
for (is_macos, mode, decision) in [
(true, RunningMode::Sidecar, StartupDecision::Sidecar),
(true, RunningMode::Sidecar, StartupDecision::Wait),
(true, RunningMode::Service, StartupDecision::Service),
(true, RunningMode::NotRunning, StartupDecision::Service),
(false, RunningMode::Sidecar, StartupDecision::Service),
] {
assert_eq!(
proxy_stop_intent(is_macos, mode, decision),
ProxyStopIntent::Clear,
"{is_macos} {mode:?} {decision:?}"
);
}
}
#[tokio::test]
async fn a_sidecar_handed_to_the_service_stops_its_guard_and_leaves_the_proxy_alone() {
let calls = Arc::new(Mutex::new(Vec::new()));
let result = run_controlled_stop_transition(
true,
RunningMode::Sidecar,
ProxyStopIntent::HandOverToService,
|| {
calls.lock().push("guard-stopped");
future::ready(true)
},
|| transition_step(&calls, "unexpected-proxy-clear", None),
|| transition_step(&calls, "core_stop", None),
)
.await;
assert!(result.is_ok());
assert_eq!(&*calls.lock(), &["guard-stopped", "core_stop"]);
}
#[tokio::test]
async fn a_guard_that_would_not_stop_does_not_block_the_core_from_stopping() {
for (mode, intent) in [
(RunningMode::Sidecar, ProxyStopIntent::HandOverToService),
(RunningMode::Service, ProxyStopIntent::Clear),
] {
let calls = Arc::new(Mutex::new(Vec::new()));
let result = run_controlled_stop_transition(
true,
mode,
intent,
|| future::ready(false),
|| transition_step(&calls, "proxy_clear", None),
|| transition_step(&calls, "core_stop", None),
)
.await;
assert!(result.is_ok(), "{mode:?} {intent:?}");
assert_eq!(&*calls.lock(), &["core_stop"], "{mode:?} {intent:?}");
}
}
#[tokio::test]
async fn an_ordinary_sidecar_stop_still_clears_even_off_macos() {
for is_macos in [true, false] {
let calls = Arc::new(Mutex::new(Vec::new()));
let result = run_controlled_stop_transition(
is_macos,
RunningMode::Sidecar,
ProxyStopIntent::Clear,
|| future::ready(true),
|| transition_step(&calls, "proxy_clear", None),
|| transition_step(&calls, "core_stop", None),
)
.await;
assert!(result.is_ok());
assert_eq!(&*calls.lock(), &["proxy_clear", "core_stop"]);
}
}
#[tokio::test]
async fn controlled_macos_service_stop_drains_guard_before_one_helper_transaction() {
let calls = Arc::new(Mutex::new(Vec::new()));
let result = run_controlled_stop_transition(
true,
RunningMode::Service,
ProxyStopIntent::Clear,
|| {
calls.lock().push("guard-stopped");
future::ready(true)
},
|| transition_step(&calls, "unexpected-proxy-clear", None),
|| transition_step(&calls, "service_stop_with_proxy_clear", None),
)
.await;
assert!(result.is_ok());
assert_eq!(&*calls.lock(), &["guard-stopped", "service_stop_with_proxy_clear"]);
}
#[tokio::test]
async fn failed_macos_service_stop_cannot_be_followed_by_a_stale_guard_write() {
let coordinator = Arc::new(FakeGuardCoordinator::new());
let calls = Arc::new(Mutex::new(Vec::new()));
let guard_started = Arc::new(Barrier::new(2));
let release_guard = Arc::new(Barrier::new(2));
let captured_generation = coordinator.generation.load(Ordering::Acquire);
let in_flight_guard = {
let coordinator = Arc::clone(&coordinator);
let calls = Arc::clone(&calls);
let guard_started = Arc::clone(&guard_started);
let release_guard = Arc::clone(&release_guard);
tokio::spawn(async move {
let _operation = coordinator.operation_lock.lock().await;
calls.lock().push("guard-started");
guard_started.wait().await;
release_guard.wait().await;
calls.lock().push("guard-finished");
})
};
guard_started.wait().await;
let mut stop = Box::pin(run_controlled_stop_transition(
true,
RunningMode::Service,
ProxyStopIntent::Clear,
|| coordinator.stop_guard(&calls),
|| transition_step(&calls, "unexpected-proxy-clear", None),
|| transition_step(&calls, "service-stop-failed", Some("service-stop-failed")),
));
assert!(matches!(futures::poll!(stop.as_mut()), Poll::Pending));
release_guard.wait().await;
assert!(in_flight_guard.await.is_ok());
assert!(stop.await.is_err());
assert!(!coordinator.run_guard_request(captured_generation, &calls).await);
assert_eq!(
&*calls.lock(),
&[
"guard-started",
"guard-finished",
"guard-stopped",
"service-stop-failed"
]
);
}
#[tokio::test]
async fn handoff_sidecar_cleanup_is_clear_gated_and_ordered() {
for fail_at in [None, Some("handoff-proxy-clear")] {
let calls = Arc::new(Mutex::new(Vec::new()));
let sidecar_alive = AtomicBool::new(true);
let result = run_sidecar_termination_transition(
|| transition_step(&calls, "handoff-proxy-clear", fail_at),
|| async {
sidecar_alive.store(false, Ordering::Release);
calls.lock().push("handoff-sidecar-stop");
Ok(())
},
)
.await;
if fail_at.is_some() {
assert!(result.is_err());
assert!(sidecar_alive.load(Ordering::Acquire));
assert_eq!(&*calls.lock(), &["handoff-proxy-clear"]);
} else {
assert!(result.is_ok());
assert!(!sidecar_alive.load(Ordering::Acquire));
assert_eq!(&*calls.lock(), &["handoff-proxy-clear", "handoff-sidecar-stop"]);
}
}
}
#[tokio::test]
async fn handoff_chosen_core_applies_proxy_only_after_required_readiness() {
let calls = Arc::new(Mutex::new(Vec::new()));
let result = run_ready_core_start_transition(
|| transition_step(&calls, "core-start", None),
|| {
calls.lock().push("core-ready");
true
},
|| transition_step(&calls, "proxy-apply", None),
)
.await;
assert!(result.is_ok());
assert_eq!(&*calls.lock(), &["core-start", "core-ready", "proxy-apply"]);
}
#[tokio::test]
async fn handoff_chosen_core_without_readiness_never_applies_proxy() {
let calls = Arc::new(Mutex::new(Vec::new()));
let result = run_ready_core_start_transition(
|| transition_step(&calls, "core-start", None),
|| {
calls.lock().push("core-not-ready");
false
},
|| transition_step(&calls, "unexpected-proxy-apply", None),
)
.await;
assert!(result.is_err());
assert_eq!(&*calls.lock(), &["core-start", "core-not-ready"]);
}
#[tokio::test]
async fn handoff_cleanup_failure_aborts_fallback_and_leaves_service_alive() {
let calls = Arc::new(Mutex::new(Vec::new()));
let service_alive = AtomicBool::new(true);
let result = run_controlled_stop_transition(
false,
RunningMode::Service,
ProxyStopIntent::Clear,
|| future::ready(true),
|| transition_step(&calls, "service-proxy-clear", Some("service-proxy-clear")),
|| {
service_alive.store(false, Ordering::Release);
transition_step(&calls, "service-stop", None)
},
)
.await;
assert!(result.is_err());
assert!(service_alive.load(Ordering::Acquire));
assert_eq!(&*calls.lock(), &["service-proxy-clear"]);
}
#[tokio::test]
async fn controlled_start_applies_proxy_only_after_core_is_ready() {
let calls = Arc::new(Mutex::new(Vec::new()));
let result = run_core_start_transition(
|| transition_step(&calls, "core_start", None),
|| true,
|| transition_step(&calls, "proxy_apply", None),
)
.await;
assert!(result.is_ok());
assert_eq!(&*calls.lock(), &["core_start", "proxy_apply"]);
}
#[tokio::test]
async fn controlled_start_does_not_apply_proxy_without_a_ready_core() {
let calls = Arc::new(Mutex::new(Vec::new()));
let result = run_core_start_transition(
|| transition_step(&calls, "core_start", None),
|| false,
|| transition_step(&calls, "proxy_apply", None),
)
.await;
assert!(result.is_ok());
assert_eq!(&*calls.lock(), &["core_start"]);
}
#[tokio::test]
async fn restart_start_failure_keeps_proxy_direct_guard_stopped_and_pac_unavailable() {
let proxy_is_direct = AtomicBool::new(false);
let guard_stopped = AtomicBool::new(false);
let pac_available = AtomicBool::new(true);
let restored_new_core = AtomicBool::new(false);
let result = run_core_replacement_transition(
|| {
proxy_is_direct.store(true, Ordering::Release);
guard_stopped.store(true, Ordering::Release);
pac_available.store(false, Ordering::Release);
future::ready(Ok(()))
},
|| future::ready(Err(anyhow::anyhow!("new core failed to start"))),
|| {
restored_new_core.store(true, Ordering::Release);
future::ready(Ok(()))
},
)
.await;
assert!(result.is_err());
assert!(proxy_is_direct.load(Ordering::Acquire));
assert!(guard_stopped.load(Ordering::Acquire));
assert!(!pac_available.load(Ordering::Acquire));
assert!(!restored_new_core.load(Ordering::Acquire));
}
#[tokio::test]
async fn service_config_replacement_is_controlled_and_generation_safe_on_every_platform() {
for is_macos in [false, true] {
let calls = Arc::new(Mutex::new(Vec::new()));
let manager = CoreManager::isolated();
let old_readiness_generation = manager.mark_core_ready();
manager.core_started(RunningMode::Service);
let owner_monitor_generation = AtomicU64::new(1);
let old_owner_monitor_generation = owner_monitor_generation.load(Ordering::Acquire);
let result = run_service_config_replacement_transition(
is_macos,
|| {
calls.lock().push("guard-stopped");
future::ready(true)
},
|| transition_step(&calls, "proxy-clear", None),
|| {
calls.lock().push("helper-stop");
owner_monitor_generation.fetch_add(1, Ordering::AcqRel);
manager.core_stopped();
future::ready(Ok(()))
},
|| {
calls.lock().push("service-start");
owner_monitor_generation.fetch_add(1, Ordering::AcqRel);
manager.mark_core_ready();
manager.core_started(RunningMode::Service);
if owner_monitor_generation
.compare_exchange(
old_owner_monitor_generation,
old_owner_monitor_generation.wrapping_add(1),
Ordering::AcqRel,
Ordering::Acquire,
)
.is_ok()
{
calls.lock().push("old-monitor-invalidated-readiness");
manager.invalidate_core_readiness();
} else {
calls.lock().push("old-monitor-rejected");
}
future::ready(Ok(()))
},
|| {
calls.lock().push("readiness-checked");
matches!(*manager.get_running_mode(), RunningMode::Service)
&& manager.current_core_readiness_generation().is_some()
&& manager.current_core_readiness_generation() != Some(old_readiness_generation)
},
|| transition_step(&calls, "proxy-apply", None),
)
.await;
assert!(result.is_ok());
let expected_preparation = if is_macos { "guard-stopped" } else { "proxy-clear" };
assert_eq!(
&*calls.lock(),
&[
expected_preparation,
"helper-stop",
"service-start",
"old-monitor-rejected",
"readiness-checked",
"proxy-apply",
]
);
}
}
#[test]
fn startup_waits_when_a_service_requirement_cannot_be_satisfied() {
assert_eq!(startup_decision(&ServiceStatus::Ready, true), StartupDecision::Service);
assert_eq!(
startup_decision(&ServiceStatus::NotInstalled, true),
StartupDecision::Wait
);
assert_eq!(
startup_decision(&ServiceStatus::NotInstalled, false),
StartupDecision::Sidecar
);
assert_eq!(
startup_decision(&ServiceStatus::SidecarAllowed, true),
StartupDecision::Sidecar
);
assert_eq!(
startup_decision(&ServiceStatus::NeedsReinstall, true),
StartupDecision::Wait
);
assert_eq!(
startup_decision(&ServiceStatus::Unavailable("broken".into()), true),
StartupDecision::Wait
);
assert_eq!(startup_decision(&ServiceStatus::Checking, true), StartupDecision::Wait);
}
#[test]
fn service_evidence_failures_never_silently_fall_back() {
assert_eq!(
startup_decision(&ServiceStatus::NeedsReinstall, false),
StartupDecision::Wait
);
assert_eq!(
startup_decision(&ServiceStatus::Unavailable("missing".into()), false),
StartupDecision::Wait
);
}
#[test]
fn service_wait_is_only_required_for_non_admin_tun() {
assert!(should_wait_for_service(true, false, false));
assert!(!should_wait_for_service(true, false, true));
assert!(!should_wait_for_service(true, true, false));
assert!(!should_wait_for_service(false, false, false));
}
#[test]
fn sidecar_session_allowance_is_cross_platform_and_explicit() {
let allowed_statuses = [
ServiceStatus::NotInstalled,
ServiceStatus::NeedsReinstall,
ServiceStatus::InstallRequired,
ServiceStatus::Unavailable("offline".into()),
ServiceStatus::SidecarAllowed,
];
for status in &allowed_statuses {
assert!(can_allow_sidecar_for_session(&RunningMode::NotRunning, status));
assert!(!can_allow_sidecar_for_session(&RunningMode::Service, status));
if !matches!(
status,
ServiceStatus::InstallRequired | ServiceStatus::Unavailable(_) | ServiceStatus::SidecarAllowed
) {
assert!(!can_allow_sidecar_for_session(&RunningMode::Sidecar, status));
} else {
assert!(can_allow_sidecar_for_session(&RunningMode::Sidecar, status));
}
}
let rejected_statuses = [
ServiceStatus::Checking,
ServiceStatus::Ready,
ServiceStatus::UninstallRequired,
ServiceStatus::ReinstallRequired,
ServiceStatus::ForceReinstallRequired,
];
for status in &rejected_statuses {
assert!(!can_allow_sidecar_for_session(&RunningMode::NotRunning, status));
}
}
#[tokio::test]
async fn blocked_windows_fallback_records_failure_without_starting_sidecar() {
use crate::core::runstate::{FakeEnv, RunStateStore, ServiceHealth};
for idle in [false, true] {
let store = RunStateStore::new(FakeEnv::new());
store.observe(ServiceHealth::Ready);
let started = AtomicBool::new(false);
let result = super::run_service_start_with_sidecar_fallback(
|| async {
Err(crate::core::service::ServiceStartRefusal {
code: clash_verge_service_ipc::ServiceErrorCode::InvalidInstallLocation as u16,
core_path: "verge-mihomo.exe".into(),
message: "no administrator-approved copy is installed".into(),
}
.into())
},
|| async move {
anyhow::ensure!(idle, "another core is running");
Ok(())
},
|_| async {
started.store(true, Ordering::SeqCst);
Ok(())
},
|reason| store.observe(ServiceHealth::Unavailable(reason)),
)
.await;
assert_eq!(started.load(Ordering::SeqCst), idle);
assert_eq!(result.is_ok(), idle);
assert!(!store.state().sidecar_allowed);
if !idle {
assert!(store.state().service_needs_attention());
assert!(!store.state().service_usable());
assert!(result.is_err_and(|error| format!("{error:#}").contains("another core is running")));
}
}
}
#[test]
fn failed_service_install_can_continue_with_an_existing_sidecar() {
assert!(can_allow_sidecar_for_session(
&RunningMode::Sidecar,
&ServiceStatus::InstallRequired,
));
}
#[test]
fn proxy_restore_requires_the_same_ready_core_generation() {
let sidecar =
ProxyRestoreExpectation::capture(RunningMode::Sidecar, Some(20), 8).unwrap_or_else(|| unreachable!());
assert!(sidecar.is_valid(&RunningMode::Sidecar, Some(20), 8));
assert!(!sidecar.is_valid(&RunningMode::Sidecar, Some(21), 8));
assert!(!sidecar.is_valid(&RunningMode::Sidecar, None, 8));
assert!(ProxyRestoreExpectation::capture(RunningMode::Sidecar, None, 8).is_none());
let service =
ProxyRestoreExpectation::capture(RunningMode::Service, Some(20), 8).unwrap_or_else(|| unreachable!());
assert!(service.is_valid(&RunningMode::Service, Some(20), 8));
assert!(!service.is_valid(&RunningMode::Service, Some(20), 9));
assert!(!service.is_valid(&RunningMode::NotRunning, Some(20), 8));
assert!(ProxyRestoreExpectation::capture(RunningMode::Service, None, 8).is_none());
assert!(ProxyRestoreExpectation::capture(RunningMode::NotRunning, Some(20), 8).is_none());
}
#[tokio::test]
async fn failed_start_rolls_back_even_from_service_mode() {
let manager = CoreManager::isolated();
manager.core_started(RunningMode::Service);
manager.rollback_failed_start().await;
assert_eq!(*manager.get_running_mode(), RunningMode::NotRunning);
manager.core_started(RunningMode::Sidecar);
manager.rollback_failed_start().await;
assert_eq!(*manager.get_running_mode(), RunningMode::NotRunning);
}
}