mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-27 16:22:11 +08:00
585 lines
22 KiB
Rust
585 lines
22 KiB
Rust
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<Ask, Fut>(confirm_within: Duration, ask: Ask) -> StageAttempt
|
|
where
|
|
Ask: Fn() -> Fut,
|
|
Fut: std::future::Future<Output = StageAttempt>,
|
|
{
|
|
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<ValidationOutcome> {
|
|
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<ValidationOutcome> {
|
|
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<std::result::Result<ConfigUpdateGuard<'_>, 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<ValidationOutcome> {
|
|
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<F>(&self, f: F) -> Result<ValidationOutcome>
|
|
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<ValidationOutcome> {
|
|
// 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"
|
|
);
|
|
}
|
|
}
|