mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-30 18:52:12 +08:00
* fix(cmd): preserve anyhow context in coded errors Use alternate error formatting so coded failures retain the full anyhow context chain without changing their wire format. * fix(macos): skip redundant proxy disable writes When both proxy modes are off, read macOS state first and skip networksetup if the OS is already clean. Update the guard target only after a successful write. * feat(proxy): classify system proxy failures Attach stable, actionable proxy failure codes to anyhow chains while preserving the original diagnostic causes. * feat(cmd): return structured command failures Replace encoded error strings with serializable code and detail fields, and update frontend notice consumers to handle the structured shape. * fix(scripts): use the js-yaml namespace import Import js-yaml through its ESM namespace so the i18n cleanup script can access load and dump. * feat(proxy): localize classified proxy failures Map privilege and direct-fallback codes to localized explanations, and let mapped causes outrank generic operation keys in notices. * fix(ui): bound notice text and render failure details Limit resolved notice text without truncating clipboard content, and normalize structured failures embedded in translation parameters. * feat(proxy): persist unseen proxy failures Store unresolved classified failures by code, notify the frontend when the table changes, and show each failure sequence once when a visible window can receive it. * feat(proxy): report tray and hotkey toggle failures Record classified failures from tray and hotkey proxy toggles so they appear when a visible window is available. * fix(ui): restore the proxy switch after failed toggles Refresh frontend proxy state after a failed toggle so the switch reflects the rolled-back configuration. * feat(proxy): stop the guard after repeated failures Report guard outcomes, stop after the failure threshold, and persist the classified failure for the frontend. * fix(macos): preserve proxy during service handoff Stop a sidecar being handed to the service without clearing proxy state that the new owner must inherit. * feat(macos): classify misplaced sidecar state Distinguish a refused sidecar proxy write from a machine that still needs the privileged service installed. * feat(ui): recover misplaced sidecar notices Offer a restart action for macOS proxy failures caused by a ready service while the core still runs as a sidecar. * feat(ui): install the service from proxy notices Let privilege notices install the macOS service, restart the core, and report the runtime mode that was actually reached. * feat(cmd): attach requested proxy operations Carry the requested proxy direction with structured command failures when the originating command knows it. * feat(sysproxy): drain guards before OS access Wait for the local proxy guard to stop before OS reads, service handoff, or final writes, and refuse transitions when it does not drain. * feat(sysproxy): recover partial proxy writes Use write progress to choose whether to restore the old guard or compensate with a disabled proxy state, while preserving actionable errors. * feat(sysproxy): skip already-applied proxy writes Compare macOS proxy snapshots with global, PAC, and disabled targets so matching state avoids redundant privileged writes. * feat(proxy): report background proxy failures Persist and surface classified proxy failures from lifecycle paths that have no direct caller awaiting the result. * feat(ui): move proxy recovery into a dialog Guide users through installing or switching to the service after a refused proxy write, and suppress duplicate toast reports. * fix(ui): close proxy dialog after service recovery Dismiss the recovery dialog once the core reaches service mode, even when the original proxy request remains pending. * feat(ui): retry proxy request after service recovery Replay the requested proxy direction after the core reaches service mode, while leaving incidental restores unchanged. * fix(proxy): refresh state after successful apply Publish the proxy refresh from the shared apply path after releasing the lifecycle lock, and remove the tray-specific duplicate. * feat(ui): show proxy recovery progress Display installation, restart, and proxy-apply stages while keeping the primary action spinner visible during recovery. * feat(ui): route unavailable TUN through service recovery Let the TUN switch request service recovery and restore its requested state after the core restarts in service mode. * fix(ui): avoid duplicate TUN recovery errors Roll back deferred TUN toggles without reporting an error, and publish configuration refreshes from the transaction commit path. * chore: update changelog & pinlock * fix(core): resolve platform-specific clippy warnings * fix(core): detect Windows wildcard TCP conflicts Windows permits a specific exclusive bind beside an existing non-exclusive wildcard listener. Consult the system TCP listener table so the probe reports that overlap as a conflict.
663 lines
27 KiB
Rust
663 lines
27 KiB
Rust
use super::{CoreManager, PROFILE_SELECTIONS_PENDING_COMMIT, RunningMode};
|
|
use crate::core::service::StageRequest;
|
|
use crate::{
|
|
config::{Config, ConfigType, 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;
|
|
|
|
/// What came back from asking the Service to stage a runtime.
|
|
///
|
|
/// Separated from the decision below so the decision stays a pure function of it. The variants are
|
|
/// the four ways an attempt can end, and what distinguishes them is how much they let us conclude:
|
|
/// a refusal is the Service's verdict on this bundle, while silence says nothing at all.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
enum StageAttempt {
|
|
/// The Service that owns the running Core predates staging.
|
|
Unsupported,
|
|
/// The Service answered, and its answer was a refusal about the bundle itself — the kind a
|
|
/// fresh start would run into again, because it would materialise the same bundle.
|
|
RefusedTheBundle(String),
|
|
/// The Service answered with a refusal about something other than the bundle: the session, the
|
|
/// protocol, the request. Starting is how several of those get resolved, so it is not a reason
|
|
/// to leave the Core where it is.
|
|
RefusedForAnotherReason(String),
|
|
/// The request did not come back. The Service is authoritative about its own runtime, so
|
|
/// without an answer nothing may be assumed about what it did or did not write.
|
|
Unanswered(String),
|
|
Answered(StageRuntimeOutcome),
|
|
}
|
|
|
|
/// How the Core should be made to pick up a configuration.
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
enum ConfigApplication {
|
|
/// Point the running Core at this path.
|
|
ReloadFrom(String),
|
|
/// Stop the Core and start it again from a freshly materialised runtime.
|
|
ReplaceCore,
|
|
/// Leave the Core alone and report why the configuration cannot be applied.
|
|
Fail(String),
|
|
}
|
|
|
|
pub(crate) struct ConfigUpdateGuard<'a>(&'a CoreManager);
|
|
|
|
impl Drop for ConfigUpdateGuard<'_> {
|
|
fn drop(&mut self) {
|
|
self.0.finish_config_update();
|
|
}
|
|
}
|
|
|
|
/// Decide how to apply a configuration, given how staging went.
|
|
///
|
|
/// Two branches avoid replacing the Core, for opposite reasons. One is success. The other is a
|
|
/// refusal *about the bundle*: the Service inspected it and would not take it, and a fresh start
|
|
/// materialises the same bundle — so it would fail the same way, having stopped a Core that was
|
|
/// working.
|
|
///
|
|
/// The distinction matters more than it looks. A refusal about the session is not a refusal about
|
|
/// the bundle: starting proposes a new session rather than presenting the old one, and the restart
|
|
/// path is also what notices that ownership was lost and clears the system proxy. Treating every
|
|
/// non-zero code alike would skip that.
|
|
///
|
|
/// Everything else — an older Service, a request that never came back, or a Service that asked to
|
|
/// be restarted — replaces the Core, which is what this did before staging existed and what the
|
|
/// Service guarantees is still safe after every way of declining.
|
|
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,
|
|
}
|
|
}
|
|
|
|
/// One round trip to the Service, classified but not judged.
|
|
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.to_string().into();
|
|
if StageRequest::is_about_the_bundle(code) {
|
|
StageAttempt::RefusedTheBundle(message)
|
|
} else {
|
|
StageAttempt::RefusedForAnotherReason(message)
|
|
}
|
|
}
|
|
Err(err) => StageAttempt::Unanswered(format!("{err:#}").into()),
|
|
}
|
|
}
|
|
|
|
/// Ask once, and ask again only when the first attempt came back silent.
|
|
///
|
|
/// Silence is the one answer that has to be resolved rather than acted on. The Service commits the
|
|
/// generation before it replies, so a reply that never arrives can leave `config.yaml` already
|
|
/// replaced while this side concludes nothing happened — and the Service restarts a Core that dies
|
|
/// later from exactly that file. Treating silence as "nothing happened" is therefore a guess, and
|
|
/// the expensive half of it is silent: the Core keeps running the previous configuration until
|
|
/// something restarts it, and then it does not.
|
|
///
|
|
/// Asking again is cheap precisely where it matters. Staging plans against the manifest it wrote,
|
|
/// so a bundle that already landed copies nothing and answers in milliseconds; and because the
|
|
/// Service holds its lifecycle lock for the whole operation, a second ask that overtakes a first
|
|
/// one still in flight waits for it rather than interleaving with it. `confirm_within` is what
|
|
/// keeps that wait from being the caller's problem — past it, the answer has stopped being worth
|
|
/// more than the fallback, which is where a second silence goes anyway.
|
|
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"
|
|
);
|
|
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()),
|
|
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
|
|
}
|
|
|
|
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!(info, 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
|
|
}
|
|
|
|
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) => {
|
|
self.restore_profile_config(rollback).await?;
|
|
return Err(error);
|
|
}
|
|
};
|
|
if !outcome.is_valid() {
|
|
crate::config::profiles::restore_selected_nodes().await;
|
|
return Ok(Err(outcome));
|
|
}
|
|
match candidate.save_file().await {
|
|
Ok(()) => Ok(Ok(guard)),
|
|
Err(error) => {
|
|
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!(info, 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
|
|
}
|
|
|
|
/// Validate the staged Runtime Config and hand it to the Core, committing only if both work.
|
|
///
|
|
/// Takes the transaction rather than opening one, so it covers the staging its callers did.
|
|
/// Every way out of here other than the last line rolls that staging back.
|
|
async fn validate_and_apply(&self, transaction: DraftTransaction<'_>) -> Result<ValidationOutcome> {
|
|
let outcome = CoreConfigValidator::global().validate_config_outcome().await?;
|
|
if !outcome.is_valid() {
|
|
return Ok(outcome);
|
|
}
|
|
|
|
let run_path = Config::generate_file(ConfigType::Run).await?;
|
|
self.apply_config(run_path).await?;
|
|
transaction.commit();
|
|
Ok(ValidationOutcome::Valid)
|
|
}
|
|
|
|
/// Hand the generated configuration to the Core.
|
|
///
|
|
/// Says nothing about drafts: whether the staged Runtime Config is kept follows from
|
|
/// whether this succeeded, and the caller's transaction decides that.
|
|
///
|
|
/// Both modes have the same shape — put the configuration where the Core can read it, ask the
|
|
/// Core to reload, restart it if that did not work. Only the first step differs: in Sidecar
|
|
/// mode the Core already reads the app's own directory, while in Service mode the Service has
|
|
/// to materialise the configuration into the directory it started the Core in.
|
|
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
|
|
}
|
|
|
|
/// Apply a configuration in Service mode, staging it in place when that is possible.
|
|
///
|
|
/// Every way of not staging leads to the same place it always led: stop the Core and start it
|
|
/// again from a freshly materialised runtime. That is what makes declining safe — the Service
|
|
/// leaves the runtime it could not stage exactly as the running Core left it.
|
|
///
|
|
/// Caller must hold `lifecycle_lock`.
|
|
async fn apply_config_by_service(&self, path: &std::path::Path) -> Result<()> {
|
|
match plan_config_application(&self.attempt_staging(path).await) {
|
|
ConfigApplication::Fail(message) => {
|
|
// The Service looked at this bundle and would not take it. Replacing the Core would
|
|
// hand it the same bundle, so the outage would buy nothing.
|
|
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(()) => {
|
|
logging!(info, Type::Core, "Configuration staged and applied by service");
|
|
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?;
|
|
logging!(info, Type::Core, "Configuration materialized and applied by service");
|
|
Ok(())
|
|
}
|
|
|
|
/// Ask the Service to stage the runtime, reporting the attempt rather than judging it.
|
|
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.
|
|
async fn reload_or_restart(&self, path: &str) -> Result<()> {
|
|
let Err(err) = self.reload_config(path).await else {
|
|
logging!(info, Type::Core, "Configuration applied");
|
|
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(_) => {
|
|
logging!(info, Type::Core, "Configuration applied after restart");
|
|
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(),
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn a_staged_runtime_is_reloaded_from_where_the_service_put_it() {
|
|
let attempt = StageAttempt::Answered(StageRuntimeOutcome::Staged {
|
|
config_path: "/service/runtime.generation-1/config.yaml".to_owned(),
|
|
});
|
|
|
|
assert_eq!(
|
|
plan_config_application(&attempt),
|
|
ConfigApplication::ReloadFrom("/service/runtime.generation-1/config.yaml".into()),
|
|
"the core must be pointed at the service's copy, never at the app's own"
|
|
);
|
|
}
|
|
|
|
/// Wrap a rejection as the answer the Service would have sent.
|
|
///
|
|
/// The `match` is the point, not the wrapping: it is exhaustive, so a new way for the Service
|
|
/// to decline cannot be added upstream without this failing to compile — which is what sends
|
|
/// whoever adds it to the test below to say what it should lead to. `CoreRestarted` reached
|
|
/// production without one, and inherited its verdict silently.
|
|
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(),
|
|
},
|
|
// The service's own watchdog can restart the core mid-staging, which leaves the
|
|
// generation holding a mixture no manifest describes. Rebuilding it from nothing is
|
|
// the only answer that does not guess at what is in there.
|
|
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() {
|
|
// Replacing the core would materialise the same bundle the Service just rejected, so the
|
|
// outage buys nothing: the configuration update fails either way, and this way the user's
|
|
// proxy is still working when it does.
|
|
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 stale session is the case that matters: starting proposes a new one, so it is the cure
|
|
// rather than a repeat of the failure — and the restart path is also what notices ownership
|
|
// was lost and clears the 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"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_request_that_never_came_back_makes_the_core_be_replaced() {
|
|
// Not knowing what the service did is the one case where reloading would be a guess: the
|
|
// staged path might not exist, or might hold the previous configuration.
|
|
assert_eq!(
|
|
plan_config_application(&StageAttempt::Unanswered("connection reset".into())),
|
|
ConfigApplication::ReplaceCore
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_answer_is_taken_at_its_word_and_not_asked_for_twice() {
|
|
// Staging is only idempotent in what it writes, not in what it costs: a second ask after a
|
|
// perfectly good answer would re-send the whole bundle for nothing.
|
|
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() {
|
|
// The case this exists for: the service committed the generation and the reply was lost.
|
|
// Asking again reaches a service that has already done the work, so the second answer is
|
|
// the truth the first one failed to deliver — and it keeps the core off the replace path.
|
|
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() {
|
|
// A service still copying assets is why the deadline exists: the answer would be worth
|
|
// having, but not at the cost of leaving the user's configuration unapplied indefinitely.
|
|
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"
|
|
);
|
|
}
|
|
}
|