use super::{CoreManager, PROFILE_SELECTIONS_PENDING_COMMIT, RunningMode}; use crate::core::service::StageRequest; use crate::{ config::{Config, IProfiles, runtime::IRuntime}, constants::timing, core::{ handle, validate::{CoreConfigValidator, ValidationOutcome, ValidationSkipReason}, }, utils::{dirs, help}, }; use anyhow::{Result, anyhow}; use clash_verge_draft::DraftTransaction; use clash_verge_logging::{Type, logging}; use clash_verge_service_ipc::StageRuntimeOutcome; use scopeguard::defer; use smartstring::alias::String; use std::{ collections::HashSet, path::PathBuf, time::{Duration, Instant}, }; use tauri_plugin_mihomo::Error as MihomoError; /// A staging result separated from the policy that acts on it. #[derive(Debug, Clone, PartialEq, Eq)] enum StageAttempt { Unsupported, /// Restarting would present the same rejected bundle. RefusedTheBundle(String), /// A new service session may resolve this refusal. RefusedForAnotherReason(String), /// The service may have staged the bundle without delivering its reply. Unanswered(String), Answered(StageRuntimeOutcome), } #[derive(Debug, PartialEq, Eq)] enum ConfigApplication { ReloadFrom(String), ReplaceCore, Fail(String), } pub(crate) struct ConfigUpdateGuard<'a>(&'a CoreManager); impl Drop for ConfigUpdateGuard<'_> { fn drop(&mut self) { self.0.finish_config_update(); } } /// Avoids replacing a working core only when the service staged successfully or rejected the /// bundle itself; session-level failures may be repaired by replacement. fn plan_config_application(attempt: &StageAttempt) -> ConfigApplication { match attempt { StageAttempt::Answered(StageRuntimeOutcome::Staged { config_path }) => { ConfigApplication::ReloadFrom(config_path.into()) } StageAttempt::Answered(StageRuntimeOutcome::RestartRequired { reason }) => { logging!( info, Type::Core, "Service declined to stage the runtime ({reason:?}); replacing the core instead" ); ConfigApplication::ReplaceCore } StageAttempt::RefusedTheBundle(message) => ConfigApplication::Fail(message.clone()), StageAttempt::RefusedForAnotherReason(message) => { logging!( warn, Type::Core, "Service refused to stage the runtime ({message}); replacing the core instead" ); ConfigApplication::ReplaceCore } StageAttempt::Unanswered(error) => { logging!( warn, Type::Core, "Failed to stage the service runtime, replacing the core instead: {error}" ); ConfigApplication::ReplaceCore } StageAttempt::Unsupported => ConfigApplication::ReplaceCore, } } async fn ask_to_stage(path: &std::path::Path) -> StageAttempt { match crate::core::service::stage_runtime_by_service(path).await { Ok(StageRequest::Answered(outcome)) => StageAttempt::Answered(outcome), Ok(StageRequest::Refused { code, message }) => { let message = message.into(); if StageRequest::is_about_the_bundle(code) { StageAttempt::RefusedTheBundle(message) } else { StageAttempt::RefusedForAnotherReason(message) } } Err(err) => StageAttempt::Unanswered(format!("{err:#}").into()), } } /// Confirms one silent request because the service may commit before its reply is lost. /// Re-staging an already committed bundle is idempotent and bounded by `confirm_within`. #[tracing::instrument(skip_all, level = "debug", fields(confirm_within = ?confirm_within, asks = 1))] async fn stage_with_confirmation(confirm_within: Duration, ask: Ask) -> StageAttempt where Ask: Fn() -> Fut, Fut: std::future::Future, { let first = match ask().await { StageAttempt::Unanswered(first) => first, answered => return answered, }; logging!( warn, Type::Core, "Staging did not answer ({first}); asking once more before replacing the core" ); tracing::Span::current().record("asks", 2); match tokio::time::timeout(confirm_within, ask()).await { Ok(StageAttempt::Unanswered(again)) => { StageAttempt::Unanswered(format!("{first}; asked again: {again}").into()) } Ok(answered) => answered, Err(_) => StageAttempt::Unanswered(format!("{first}; the second ask did not answer either").into()), } } impl CoreManager { pub async fn use_default_config(&self, error_key: &str, error_msg: &str) -> Result<()> { use crate::constants::files::RUNTIME_CONFIG; let runtime_path = dirs::app_home_dir()?.join(RUNTIME_CONFIG); let clash_config = &Config::clash().await.latest_arc().0; Config::runtime().await.edit_draft(|d| { *d = IRuntime { config: Some(clash_config.to_owned()), dns_override: None, exists_keys: HashSet::new(), chain_logs: Default::default(), } }); help::save_yaml(&runtime_path, &clash_config, Some("# Clash Verge Runtime")).await?; handle::Handle::notice_message(error_key, error_msg); Ok(()) } pub async fn update_config_forced(&self) -> Result { self.update_config_with_force(true).await } #[tracing::instrument(skip_all, level = "info", fields(force))] pub async fn update_config_with_force(&self, force: bool) -> Result { if handle::Handle::global().is_exiting() { return Ok(ValidationOutcome::Skipped { reason: ValidationSkipReason::Exiting, }); } if !self.try_start_config_update() { logging!(debug, Type::Core, "Configuration update is already running"); return Ok(ValidationOutcome::Busy); } defer! { self.finish_config_update(); } if !force && !self.should_update_config() { logging!(debug, Type::Core, "Skipping config update due to debounce"); return Ok(ValidationOutcome::Skipped { reason: ValidationSkipReason::Debounced, }); } if force { self.set_last_update(Instant::now()); } self.perform_config_update(None).await } #[tracing::instrument(skip_all, level = "info", fields(profile = ?candidate.current, outcome = tracing::field::Empty))] pub(crate) async fn update_config_forced_with_profiles( &self, candidate: &IProfiles, rollback: &IProfiles, ) -> Result, ValidationOutcome>> { if handle::Handle::global().is_exiting() { return Ok(Err(ValidationOutcome::Skipped { reason: ValidationSkipReason::Exiting, })); } if !self.try_start_config_update() { return Ok(Err(ValidationOutcome::Busy)); } let guard = ConfigUpdateGuard(self); self.set_last_update(Instant::now()); crate::config::profiles::supersede_selected_activation(); let outcome = match PROFILE_SELECTIONS_PENDING_COMMIT .scope(true, self.perform_config_update(Some(candidate))) .await { Ok(outcome) => outcome, Err(error) => { tracing::Span::current().record("outcome", "rolled_back"); self.restore_profile_config(rollback).await?; return Err(error); } }; if !outcome.is_valid() { tracing::Span::current().record("outcome", "invalid"); crate::config::profiles::restore_selected_nodes().await; return Ok(Err(outcome)); } match candidate.save_file().await { Ok(()) => { tracing::Span::current().record("outcome", "committed"); Ok(Ok(guard)) } Err(error) => { tracing::Span::current().record("outcome", "save_rolled_back"); self.restore_profile_config(rollback).await?; Err(error) } } } async fn restore_profile_config(&self, profiles: &IProfiles) -> Result<()> { let outcome = self.perform_config_update(Some(profiles)).await?; if outcome.is_valid() { crate::config::profiles::restore_selected_nodes().await; Ok(()) } else { Err(anyhow!("failed to restore previous Core configuration: {outcome}")) } } pub async fn update_config_checked(&self) -> Result<()> { let outcome = self.update_config_forced().await?; if outcome.is_valid() { Ok(()) } else { Err(anyhow!("{outcome}")) } } fn should_update_config(&self) -> bool { let now = Instant::now(); let last = self.get_last_update(); if let Some(last_time) = last && now.duration_since(*last_time) < timing::CONFIG_UPDATE_DEBOUNCE { return false; } self.set_last_update(now); true } async fn perform_config_update(&self, profiles: Option<&IProfiles>) -> Result { let runtime = Config::runtime().await; let transaction = DraftTransaction::begin(vec![&runtime])?; let generate = match profiles { Some(profiles) => Config::generate_with_profiles(profiles).await, None => Config::generate().await, }; if let Err(err) = generate { let message: String = err.to_string().into(); return Ok(ValidationOutcome::invalid_from_message(message)); } self.validate_and_apply(transaction).await } pub(crate) async fn update_runtime_config(&self, f: F) -> Result where F: FnOnce(&mut IRuntime), { if !self.try_start_config_update() { logging!(debug, Type::Core, "Configuration update is already running"); return Ok(ValidationOutcome::Busy); } defer! { self.finish_config_update(); } let runtime = Config::runtime().await; let transaction = DraftTransaction::begin(vec![&runtime])?; runtime.edit_draft(f); self.validate_and_apply(transaction).await } /// Validates and applies the caller's transaction, committing only on success. async fn validate_and_apply(&self, transaction: DraftTransaction<'_>) -> Result { // One serialization feeds check and run files; the core never applies unvalidated bytes. let yaml = Config::runtime_config_yaml().await?; let outcome = CoreConfigValidator::global() .validate_config_outcome_with(&yaml) .await?; if !outcome.is_valid() { return Ok(outcome); } let run_path = Config::write_runtime_file(&yaml).await?; self.apply_config(run_path).await?; transaction.commit(); Ok(ValidationOutcome::Valid) } /// Applies a generated configuration through the active core owner. async fn apply_config(&self, path: PathBuf) -> Result<()> { if matches!(*self.get_running_mode(), RunningMode::Service) { let _lifecycle = self.lifecycle_lock.lock().await; if !matches!(*self.get_running_mode(), RunningMode::Service) { return Err(anyhow!("core mode changed while applying service configuration")); } return self.apply_config_by_service(&path).await; } let path = dirs::path_to_str(&path)?; self.reload_or_restart(path).await } /// Applies through the service, replacing the core when in-place staging is unavailable. /// Caller must hold `lifecycle_lock`. #[tracing::instrument(skip_all, level = "info", fields(path = %path.display(), outcome = tracing::field::Empty))] async fn apply_config_by_service(&self, path: &std::path::Path) -> Result<()> { match plan_config_application(&self.attempt_staging(path).await) { ConfigApplication::Fail(message) => { // Replacement would hand the service the same rejected bundle. logging!( warn, Type::Core, "Service refused the runtime, leaving the core running: {message}" ); return Err(anyhow!("{message}")); } ConfigApplication::ReloadFrom(staged) => match self.reload_config(&staged).await { Ok(()) => { tracing::Span::current().record("outcome", "staged"); crate::core::service::request_runtime_provider_sync(timing::RUNTIME_PROVIDER_SYNC_DELAY); return Ok(()); } Err(err) => logging!( warn, Type::Core, "Failed to reload the staged service runtime, replacing the core instead: {err}" ), }, ConfigApplication::ReplaceCore => {} } self.replace_service_core_with_config(path).await?; tracing::Span::current().record("outcome", "replaced"); Ok(()) } async fn attempt_staging(&self, path: &std::path::Path) -> StageAttempt { if !crate::core::service::active_service_supports_runtime_staging() { return StageAttempt::Unsupported; } stage_with_confirmation(timing::STAGE_CONFIRM_TIMEOUT, || ask_to_stage(path)).await } /// Reload the Core from `path`, and replace the Core if it will not take it. #[tracing::instrument(skip_all, level = "info", fields(outcome = tracing::field::Empty))] async fn reload_or_restart(&self, path: &str) -> Result<()> { let Err(err) = self.reload_config(path).await else { tracing::Span::current().record("outcome", "reloaded"); return Ok(()); }; logging!( warn, Type::Core, "Failed to apply configuration by mihomo api, restart core to apply it, error msg: {err}" ); match self.restart_core_during_config_update().await { Ok(_) => { tracing::Span::current().record("outcome", "restarted"); Ok(()) } Err(err) => { logging!(error, Type::Core, "Failed to restart core: {err:#}"); Err(err.context("failed to apply the configuration")) } } } async fn reload_config(&self, path: &str) -> Result<(), MihomoError> { handle::Handle::mihomo().reload_config(true, path).await } } #[cfg(test)] mod tests { use super::{ConfigApplication, StageAttempt, StageRequest, plan_config_application, stage_with_confirmation}; use clash_verge_service_ipc::{StageRejection, StageRuntimeOutcome}; use std::{cell::Cell, time::Duration}; const CONFIRM_WITHIN: Duration = Duration::from_secs(5); fn staged() -> StageAttempt { StageAttempt::Answered(StageRuntimeOutcome::Staged { config_path: "/service/runtime.generation-1/config.yaml".to_owned(), }) } /// Exhaustively maps every service rejection into the policy under test. fn declined(reason: StageRejection) -> StageAttempt { match &reason { StageRejection::CoreNotRunning | StageRejection::CorePathChanged | StageRejection::RuntimeUnwritable { .. } | StageRejection::CoreRestarted => {} } StageAttempt::Answered(StageRuntimeOutcome::RestartRequired { reason }) } #[test] fn a_service_that_declined_makes_the_core_be_replaced() { for reason in [ StageRejection::CoreNotRunning, StageRejection::CorePathChanged, StageRejection::RuntimeUnwritable { detail: "held open".to_owned(), }, // A watchdog restart mid-staging can leave a generation that no manifest describes. StageRejection::CoreRestarted, ] { assert_eq!( plan_config_application(&declined(reason.clone())), ConfigApplication::ReplaceCore, "declining is an outcome, not an error: {reason:?} must fall back, not fail" ); } } #[test] fn a_service_too_old_to_stage_makes_the_core_be_replaced() { assert_eq!( plan_config_application(&StageAttempt::Unsupported), ConfigApplication::ReplaceCore, "an installed service without staging must keep working, not demand a reinstall" ); } #[test] fn a_refusal_about_the_bundle_leaves_the_core_alone() { // Keep the working core when replacement would present the same rejected bundle. assert_eq!( plan_config_application(&StageAttempt::RefusedTheBundle("runtime asset is unavailable".into())), ConfigApplication::Fail("runtime asset is unavailable".into()) ); } #[test] fn a_refusal_about_anything_else_still_replaces_the_core() { // A new session can repair stale ownership and clear the inherited system proxy. assert_eq!( plan_config_application(&StageAttempt::RefusedForAnotherReason("owner session is stale".into())), ConfigApplication::ReplaceCore ); } #[test] fn only_bundle_codes_count_as_a_refusal_of_the_bundle() { use clash_verge_service_ipc::ServiceErrorCode; assert!(StageRequest::is_about_the_bundle( ServiceErrorCode::InvalidRuntimeAsset as u16 )); assert!(StageRequest::is_about_the_bundle( ServiceErrorCode::InvalidInstallLocation as u16 )); for other in [ ServiceErrorCode::StaleOwnerSession, ServiceErrorCode::UnauthorizedOwner, ServiceErrorCode::ProtocolMismatch, ServiceErrorCode::NotActive, ] { assert!( !StageRequest::is_about_the_bundle(other as u16), "{other:?} says nothing about the bundle, so starting must still be tried" ); } } #[tokio::test] async fn an_answer_is_taken_at_its_word_and_not_asked_for_twice() { // Do not pay for a confirmation after a conclusive answer. for answer in [ staged(), StageAttempt::Answered(StageRuntimeOutcome::RestartRequired { reason: StageRejection::CoreNotRunning, }), StageAttempt::RefusedTheBundle("runtime asset is unavailable".into()), StageAttempt::RefusedForAnotherReason("owner session is stale".into()), ] { let asks = Cell::new(0); let outcome = stage_with_confirmation(CONFIRM_WITHIN, || { asks.set(asks.get() + 1); std::future::ready(answer.clone()) }) .await; assert_eq!(outcome, answer); assert_eq!(asks.get(), 1, "{answer:?} was answered, so asking again buys nothing"); } } #[tokio::test] async fn silence_is_resolved_by_asking_again_rather_than_assumed() { // Confirm a committed generation whose first reply was lost. let asks = Cell::new(0); let outcome = stage_with_confirmation(CONFIRM_WITHIN, || { asks.set(asks.get() + 1); std::future::ready(match asks.get() { 1 => StageAttempt::Unanswered("connection reset".into()), _ => staged(), }) }) .await; assert_eq!( outcome, staged(), "the second answer must decide, not the first silence" ); assert_eq!(asks.get(), 2); assert_eq!( plan_config_application(&outcome), ConfigApplication::ReloadFrom("/service/runtime.generation-1/config.yaml".into()), "resolving the silence is only worth doing if it changes what happens next" ); } #[tokio::test] async fn a_second_silence_still_replaces_the_core() { let asks = Cell::new(0); let outcome = stage_with_confirmation(CONFIRM_WITHIN, || { asks.set(asks.get() + 1); std::future::ready(StageAttempt::Unanswered("connection reset".into())) }) .await; assert_eq!(asks.get(), 2, "asking a third time would just be a retry loop"); assert_eq!(plan_config_application(&outcome), ConfigApplication::ReplaceCore); assert!( matches!(&outcome, StageAttempt::Unanswered(detail) if detail.contains("connection reset") && detail.contains("asked again")), "two silences must stay unanswered and record that both were tried, got {outcome:?}" ); } #[tokio::test(start_paused = true)] async fn a_second_ask_that_keeps_working_is_not_waited_out() { // Do not wait indefinitely for a service still copying assets. let asks = Cell::new(0); let started = tokio::time::Instant::now(); let outcome = stage_with_confirmation(CONFIRM_WITHIN, || async { asks.set(asks.get() + 1); match asks.get() { 1 => StageAttempt::Unanswered("deadline has elapsed".into()), _ => std::future::pending().await, } }) .await; assert_eq!(plan_config_application(&outcome), ConfigApplication::ReplaceCore); assert_eq!( started.elapsed(), CONFIRM_WITHIN, "the caller must be released at the deadline, not at the service's convenience" ); } }