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, ensure}; 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(_) ) | (RunningMode::Sidecar, ServiceStatus::InstallRequired) ) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct ProxyRestoreExpectation { mode: RunningMode, core_readiness_generation: u64, service_owner_generation: Option, } impl ProxyRestoreExpectation { const fn capture( mode: RunningMode, core_readiness_generation: Option, service_owner_generation: u64, ) -> Option { 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, 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( 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, Clear: FnOnce() -> ClearFuture, ClearFuture: std::future::Future>, Stop: FnOnce() -> StopFuture, StopFuture: std::future::Future>, { match (running_mode, proxy_intent) { (RunningMode::NotRunning, _) => {} // Do not hand the OS to another owner while the old guard may still write. (RunningMode::Sidecar, ProxyStopIntent::HandOverToService) => ensure!( stop_guard().await, "the system proxy guard did not stop in time; not handing the proxy to the service" ), (RunningMode::Service, _) if is_macos => ensure!( stop_guard().await, "the system proxy guard did not stop in time; not stopping the service-owned core" ), (RunningMode::Service | RunningMode::Sidecar, _) => clear_proxy().await?, } stop_core().await } async fn run_core_start_transition( start_core: Start, core_is_ready: Ready, apply_proxy: Apply, ) -> Result<()> where Start: FnOnce() -> StartFuture, StartFuture: std::future::Future>, Ready: FnOnce() -> bool, Apply: FnOnce() -> ApplyFuture, ApplyFuture: std::future::Future>, { start_core().await?; if core_is_ready() { apply_proxy().await?; } Ok(()) } async fn run_ready_core_start_transition( start_core: Start, core_is_ready: Ready, apply_proxy: Apply, ) -> Result<()> where Start: FnOnce() -> StartFuture, StartFuture: std::future::Future>, Ready: FnOnce() -> bool, Apply: FnOnce() -> ApplyFuture, ApplyFuture: std::future::Future>, { start_core().await?; if !core_is_ready() { anyhow::bail!("core did not become ready after start"); } apply_proxy().await } async fn run_sidecar_termination_transition( clear_proxy: Clear, terminate_sidecar: Terminate, ) -> Result<()> where Clear: FnOnce() -> ClearFuture, ClearFuture: std::future::Future>, Terminate: FnOnce(), { clear_proxy().await?; terminate_sidecar(); Ok(()) } 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, Clear: FnOnce() -> ClearFuture, ClearFuture: std::future::Future>, Stop: FnOnce() -> StopFuture, StopFuture: std::future::Future>, Start: FnOnce() -> StartFuture, StartFuture: std::future::Future>, Ready: FnOnce() -> bool, Restore: FnOnce() -> RestoreFuture, RestoreFuture: std::future::Future>, { 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_core: Stop, start_core: Start, restore_proxy: Restore, ) -> Result<()> where Stop: FnOnce() -> StopFuture, StopFuture: std::future::Future>, Start: FnOnce() -> StartFuture, StartFuture: std::future::Future>, Restore: FnOnce() -> RestoreFuture, RestoreFuture: std::future::Future>, { stop_core().await?; start_core().await?; restore_proxy().await } async fn run_uninstall_transition( uninstall: Uninstall, prepare_sidecar: Prepare, start_sidecar: Start, ) -> Result<()> where Uninstall: FnOnce() -> UninstallFuture, UninstallFuture: std::future::Future>, Prepare: FnOnce() -> PrepareFuture, PrepareFuture: std::future::Future>, Start: FnOnce() -> StartFuture, StartFuture: std::future::Future>, { 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 } 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(); } let _life = self.lifecycle_lock.lock().await; let status = SERVICE_MANAGER.current().await; let mode = self.get_running_mode(); if !can_allow_sidecar_for_session(&mode, &status) { anyhow::bail!("Sidecar continuation is not allowed from {mode:?} / {status:?}"); } Config::suppress_tun_for_session().await; Config::generate().await?; SERVICE_MANAGER.allow_sidecar_for_session()?; // 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"); } 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(()) } 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(); } 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 { 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"); } 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 } 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) })?; 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), ); } Ok(()) } async fn start_core_inner(&self) -> Result<()> { if Handle::global().is_exiting() { return Ok(()); } if !matches!(*self.get_running_mode(), RunningMode::NotRunning) { logging!( info, Type::Core, "start_core called while a core is running; treated as no-op" ); return Ok(()); } let startup = self.prepare_startup().await; if matches!(startup, StartupDecision::Wait) { self.rollback_failed_start().await; return Ok(()); } if Handle::global().is_exiting() { self.core_stopped(); return Ok(()); } let result = match startup { StartupDecision::Service => 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 } 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().await; match *self.get_running_mode() { RunningMode::Service => self.stop_core_by_service().await, RunningMode::Sidecar => { self.stop_core_by_sidecar_unprepared(); Ok(()) } RunningMode::NotRunning => Ok(()), } } 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; logging!(info, Type::Core, "Restarting core"); 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 } 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) && !Config::tun_suppressed_for_session(); 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; let needs_service = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false) && !Config::tun_suppressed_for_session(); 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")] async fn try_handoff_sidecar_to_service(&self) -> HandoffOutcome { 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) || Config::tun_suppressed_for_session() { 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>) -> 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>) -> 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>>, step: &'static str, fail_at: Option<&'static str>, ) -> future::Ready> { 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_keeps_the_core_where_it_is() { 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_err(), "{mode:?} {intent:?}"); assert!(calls.lock().is_empty(), "{mode:?} {intent:?}: {:?}", *calls.lock()); } } #[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), || { sidecar_alive.store(false, Ordering::Release); calls.lock().push("handoff-sidecar-stop"); }, ) .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()), ]; 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) { assert!(!can_allow_sidecar_for_session(&RunningMode::Sidecar, status)); } } let rejected_statuses = [ ServiceStatus::Checking, ServiceStatus::Ready, ServiceStatus::UninstallRequired, ServiceStatus::ReinstallRequired, ServiceStatus::ForceReinstallRequired, ServiceStatus::SidecarAllowed, ]; for status in &rejected_statuses { assert!(!can_allow_sidecar_for_session(&RunningMode::NotRunning, status)); } } #[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); } }