mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-14 21:20:48 +08:00
1588 lines
59 KiB
Rust
1588 lines
59 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,
|
|
}
|
|
|
|
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) -> 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. 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;
|
|
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(())
|
|
}
|
|
|
|
#[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();
|
|
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();
|
|
}
|
|
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;
|
|
}
|
|
|
|
if crate::core::runstate::RUN_STATE.state().service_usable() {
|
|
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);
|
|
}
|
|
}
|