refactor(backend): declarative effects (#8128)

* refactor(core): type notice statuses behind the frozen wire contract

* refactor(utils): unified retry and strategy-list helpers

* refactor(feat): declarative effect registry for verge and clash patches

* refactor(core): derive refresh announcements from transaction commit

* refactor(core): fold tray, hotkey, autostart updates into effect ensures

* refactor(utils): migrate remaining bounded retry loops

* docs: add src-tauri architecture guardrails

* refactor(core): coalesce burst refresh announcements

* refactor(core): complete bounded service retry migration

* ci(backend): add architecture ban gates

* test(feat): freeze representative effect registry mappings

* docs(backend): record dead wire contract entries

* chore(core): document watcher exit conditions

* fix(backend): preserve applied state across later failures

* fix(config): retain runtime after an applied core replacement

* refactor(backend): flatten refresh publication and retry policies

* docs(backend): focus guardrails on architecture ownership

* refactor(effects): use one execution order

* fix(config): publish retained runtime and reconcile proxy settings
This commit is contained in:
Tunglies
2026-10-07 07:38:55 +00:00
committed by GitHub
parent 78691ea5c8
commit 8328e34208
53 changed files with 1925 additions and 876 deletions
+2 -1
View File
@@ -1,3 +1,4 @@
use crate::core::notify::NoticeStatus;
use crate::{config::with_encryption, process::AsyncHandler};
use anyhow::{Context as _, Result, anyhow, bail};
use clash_verge_logging::{Type, logging};
@@ -58,7 +59,7 @@ fn parse_mapping(yaml_str: String, path: PathBuf) -> Result<Mapping> {
let error_msg = format!("YAML syntax error in {}: {}", path.display(), err);
logging!(error, Type::Config, "{}", error_msg);
crate::core::handle::Handle::notice_message("config_validate::yaml_syntax_error", &error_msg);
crate::core::handle::Handle::notice(NoticeStatus::ConfigValidateYamlSyntaxError, error_msg.as_str());
bail!("YAML syntax error: {}", err)
}
+53 -36
View File
@@ -109,44 +109,61 @@ fn current_user_home() -> anyhow::Result<PathBuf> {
}
.max(1024);
loop {
if buffer_size > MAX_BUFFER_SIZE {
anyhow::bail!("effective-user home lookup exceeded the maximum buffer size");
}
use crate::utils::retry::{RetryError, RetryPolicy, retry_sync};
retry_sync(
RetryPolicy::fixed(
std::num::NonZeroUsize::MIN.saturating_add(11),
std::time::Duration::ZERO,
),
|_| {
if buffer_size > MAX_BUFFER_SIZE {
return Err(RetryError::Stop(anyhow::anyhow!(
"effective-user home lookup exceeded the maximum buffer size"
)));
}
let mut passwd = std::mem::MaybeUninit::<libc::passwd>::zeroed();
let mut result = std::ptr::null_mut();
let mut buffer = vec![0_u8; buffer_size];
let code = unsafe {
libc::getpwuid_r(
uid,
passwd.as_mut_ptr(),
buffer.as_mut_ptr().cast(),
buffer.len(),
&mut result,
)
};
if code == libc::ERANGE {
buffer_size = buffer_size.saturating_mul(2);
continue;
}
if code != 0 {
return Err(std::io::Error::from_raw_os_error(code).into());
}
if result.is_null() {
anyhow::bail!("effective user {uid} has no password database entry");
}
let mut passwd = std::mem::MaybeUninit::<libc::passwd>::zeroed();
let mut result = std::ptr::null_mut();
let mut buffer = vec![0_u8; buffer_size];
let code = unsafe {
libc::getpwuid_r(
uid,
passwd.as_mut_ptr(),
buffer.as_mut_ptr().cast(),
buffer.len(),
&mut result,
)
};
if code == libc::ERANGE {
buffer_size = buffer_size.saturating_mul(2);
return Err(RetryError::Retry(anyhow::anyhow!(
"effective-user home lookup exceeded the maximum buffer size"
)));
}
if code != 0 {
return Err(RetryError::Stop(std::io::Error::from_raw_os_error(code).into()));
}
if result.is_null() {
return Err(RetryError::Stop(anyhow::anyhow!(
"effective user {uid} has no password database entry"
)));
}
let passwd = unsafe { passwd.assume_init() };
if passwd.pw_dir.is_null() {
anyhow::bail!("effective user {uid} has no home directory");
}
let home = unsafe { CStr::from_ptr(passwd.pw_dir) };
if home.to_bytes().is_empty() {
anyhow::bail!("effective user {uid} has an empty home directory");
}
return Ok(PathBuf::from(std::ffi::OsStr::from_bytes(home.to_bytes())));
}
let passwd = unsafe { passwd.assume_init() };
if passwd.pw_dir.is_null() {
return Err(RetryError::Stop(anyhow::anyhow!(
"effective user {uid} has no home directory"
)));
}
let home = unsafe { CStr::from_ptr(passwd.pw_dir) };
if home.to_bytes().is_empty() {
return Err(RetryError::Stop(anyhow::anyhow!(
"effective user {uid} has an empty home directory"
)));
}
Ok(PathBuf::from(std::ffi::OsStr::from_bytes(home.to_bytes())))
},
)
}
pub const fn move_decision(
+2 -1
View File
@@ -8,9 +8,10 @@ pub mod linux;
#[cfg(target_os = "macos")]
pub mod macos_launch_guard;
pub(crate) mod network;
pub mod notification;
pub use crate::core::notify::desktop as notification;
pub mod port;
pub mod resolve;
pub mod retry;
#[cfg(target_os = "windows")]
pub mod schtasks;
pub mod server;
-97
View File
@@ -1,97 +0,0 @@
use std::borrow::Cow;
use crate::{core::handle, utils::window_manager::WindowState};
use clash_verge_i18n;
use tauri_plugin_notification::NotificationExt as _;
pub enum NotificationEvent<'a> {
DashboardToggled,
ClashModeChanged {
mode: &'a str,
},
SystemProxyToggled(bool),
/// A proxy change failed while no window could report it.
SystemProxyFailed,
/// The system proxy guard stopped.
SystemProxyGuardStopped,
TunModeToggled(bool),
LightweightModeEntered,
ProfilesReactivated,
AppQuit,
}
/// Whether no app window can currently show the failure.
pub const fn needs_system_notification(state: WindowState) -> bool {
match state {
WindowState::Hidden | WindowState::Minimized | WindowState::NotExist => true,
WindowState::VisibleFocused | WindowState::VisibleUnfocused => false,
}
}
fn notify(title: Cow<'_, str>, body: Cow<'_, str>) {
let app_handle = handle::Handle::app_handle();
app_handle.notification().builder().title(title).body(body).show().ok();
}
pub async fn notify_event<'a>(event: NotificationEvent<'a>) {
match event {
NotificationEvent::DashboardToggled => {
let title = clash_verge_i18n::t!("notifications.dashboardToggled.title");
let body = clash_verge_i18n::t!("notifications.dashboardToggled.body");
notify(title, body);
}
NotificationEvent::ClashModeChanged { mode } => {
let title = clash_verge_i18n::t!("notifications.clashModeChanged.title");
let body = clash_verge_i18n::t!("notifications.clashModeChanged.body")
.replace("{mode}", mode)
.into();
notify(title, body);
}
NotificationEvent::SystemProxyToggled(enabled) => {
let title = clash_verge_i18n::t!("notifications.systemProxyToggled.title");
let key = if enabled {
"notifications.systemProxyToggled.on"
} else {
"notifications.systemProxyToggled.off"
};
let body = clash_verge_i18n::t!(key);
notify(title, body);
}
NotificationEvent::SystemProxyFailed => {
let title = clash_verge_i18n::t!("notifications.systemProxyFailed.title");
let body = clash_verge_i18n::t!("notifications.systemProxyFailed.body");
notify(title, body);
}
NotificationEvent::SystemProxyGuardStopped => {
let title = clash_verge_i18n::t!("notifications.systemProxyGuardStopped.title");
let body = clash_verge_i18n::t!("notifications.systemProxyGuardStopped.body");
notify(title, body);
}
NotificationEvent::TunModeToggled(enabled) => {
let title = clash_verge_i18n::t!("notifications.tunModeToggled.title");
let key = if enabled {
"notifications.tunModeToggled.on"
} else {
"notifications.tunModeToggled.off"
};
let body = clash_verge_i18n::t!(key);
notify(title, body);
}
NotificationEvent::LightweightModeEntered => {
let title = clash_verge_i18n::t!("notifications.lightweightModeEntered.title");
let body = clash_verge_i18n::t!("notifications.lightweightModeEntered.body");
notify(title, body);
}
NotificationEvent::ProfilesReactivated => {
let title = clash_verge_i18n::t!("notifications.profilesReactivated.title");
let body = clash_verge_i18n::t!("notifications.profilesReactivated.body");
notify(title, body);
}
NotificationEvent::AppQuit => {
let title = clash_verge_i18n::t!("notifications.appQuit.title");
let body = clash_verge_i18n::t!("notifications.appQuit.body");
notify(title, body);
}
}
}
+3 -2
View File
@@ -1,3 +1,4 @@
use crate::core::notify::NoticeStatus;
use std::sync::atomic::{AtomicBool, Ordering};
use anyhow::Result;
@@ -13,8 +14,8 @@ use crate::{
service::{SERVICE_MANAGER, ServiceManager},
tray::Tray,
},
core::{auto_backup::AutoBackupManager, lightweight::auto_lightweight_boot},
feat,
module::{auto_backup::AutoBackupManager, lightweight::auto_lightweight_boot},
process::AsyncHandler,
utils::{init, server, window_manager::WindowManager},
};
@@ -224,7 +225,7 @@ async fn init_core_manager() -> bool {
Err(error) => {
logging!(error, Type::Setup, "core manager initialization failed: {error:#}");
CoreManager::global().record_startup_error(CoreFailure::StartFailed(format!("{error:#}")));
Handle::notice_message("core_start::error", "");
Handle::notice(NoticeStatus::CoreStartError, "");
false
}
}
+8 -7
View File
@@ -1,3 +1,4 @@
use crate::core::notify::NoticeStatus;
use anyhow::Result;
use percent_encoding::percent_decode_str;
use smartstring::alias::String;
@@ -93,18 +94,18 @@ async fn import_subscription(url: &str, name: Option<&String>) {
if let Err(e) = profiles::profiles_append_item_safe(&mut item).await {
logging!(error, Type::Config, "failed to import subscription url: {:?}", e);
Config::profiles().await.discard();
handle::Handle::notice_message("import_sub_url::error", e.to_string());
handle::Handle::notice(NoticeStatus::ImportSubUrlError, e.to_string());
return;
}
if let Err(e) = profiles::profiles_save_file_safe().await {
logging!(error, Type::Config, "failed to save imported subscription: {e:#}");
handle::Handle::notice_message("import_sub_url::error", e.to_string());
handle::Handle::notice(NoticeStatus::ImportSubUrlError, e.to_string());
return;
}
logging_error!(Type::Timer, Timer::global().refresh().await);
handle::Handle::notice_message(
"import_sub_url::ok",
handle::Handle::notice(
NoticeStatus::ImportSubUrlOk,
"", // 空 msg 传入,我们不希望导致 后端-前端-后端 死循环,这里只做提醒。
);
@@ -116,7 +117,7 @@ async fn fetch_profile_item(url: &str, name: Option<&String>) -> Option<PrfItem>
Ok(item) => Some(item),
Err(e) => {
logging!(error, Type::Config, "failed to parse profile from url: {:?}", e);
handle::Handle::notice_message("import_sub_url::error", e.to_string());
handle::Handle::notice(NoticeStatus::ImportSubUrlError, e.to_string());
None
}
}
@@ -149,11 +150,11 @@ async fn refresh_core_config() {
Ok(outcome) => {
let message = outcome.to_string();
logging!(warn, Type::Config, "Apply config failed: {}", message);
handle::Handle::notice_message("config_validate::error", message);
handle::Handle::notice(NoticeStatus::ConfigValidateError, message);
}
Err(err) => {
logging!(error, Type::Config, "Apply config error: {err:#}");
handle::Handle::notice_message("update_failed", format!("{err}"));
handle::Handle::notice(NoticeStatus::UpdateFailed, format!("{err}"));
}
}
}
+185
View File
@@ -0,0 +1,185 @@
use std::{future::Future, num::NonZeroUsize, time::Duration};
#[derive(Clone, Copy, Debug)]
pub struct RetryPolicy {
pub attempts: NonZeroUsize,
delay: Duration,
}
impl RetryPolicy {
pub const fn fixed(attempts: NonZeroUsize, delay: Duration) -> Self {
Self { attempts, delay }
}
fn delays(self) -> impl Iterator<Item = Duration> {
std::iter::repeat_n(self.delay, self.attempts.get() - 1)
}
}
pub enum RetryError<E> {
Retry(E),
Stop(E),
}
pub async fn retry<T: Send, E: Send, Fut: Future<Output = Result<T, RetryError<E>>> + Send>(
policy: RetryPolicy,
mut operation: impl FnMut(usize) -> Fut + Send,
) -> Result<T, E> {
let mut result = operation(0).await;
for (index, delay) in policy.delays().enumerate() {
match result {
Ok(_) | Err(RetryError::Stop(_)) => break,
Err(RetryError::Retry(_)) => {
if !delay.is_zero() {
tokio::time::sleep(delay).await;
}
}
}
result = operation(index + 1).await;
}
result.map_err(|error| match error {
RetryError::Retry(error) | RetryError::Stop(error) => error,
})
}
pub async fn try_strategies<
S: Copy + Send,
T: Send,
E: Send,
Fut: Future<Output = Result<T, E>> + Send,
const N: usize,
>(
first: S,
rest: [S; N],
mut operation: impl FnMut(S) -> Fut + Send,
) -> Result<(S, T), E> {
retry(
RetryPolicy::fixed(NonZeroUsize::MIN.saturating_add(N), Duration::ZERO),
move |attempt| {
let strategy = if attempt == 0 { first } else { rest[attempt - 1] };
let future = operation(strategy);
async move { future.await.map(|value| (strategy, value)).map_err(RetryError::Retry) }
},
)
.await
}
pub async fn retry_with_state<S: Send, T: Send, E: Send>(
policy: RetryPolicy,
mut state: S,
mut operation: impl for<'a> FnMut(&'a mut S, usize) -> futures::future::BoxFuture<'a, Result<T, RetryError<E>>> + Send,
) -> Result<T, E> {
let mut result = operation(&mut state, 0).await;
for (index, delay) in policy.delays().enumerate() {
match result {
Ok(_) | Err(RetryError::Stop(_)) => break,
Err(RetryError::Retry(_)) => {
if !delay.is_zero() {
tokio::time::sleep(delay).await;
}
}
}
result = operation(&mut state, index + 1).await;
}
result.map_err(|error| match error {
RetryError::Retry(error) | RetryError::Stop(error) => error,
})
}
pub fn retry_sync<T, E>(
policy: RetryPolicy,
mut operation: impl FnMut(usize) -> Result<T, RetryError<E>>,
) -> Result<T, E> {
let mut result = operation(0);
for (index, delay) in policy.delays().enumerate() {
match result {
Ok(_) | Err(RetryError::Stop(_)) => break,
Err(RetryError::Retry(_)) => {
if !delay.is_zero() {
std::thread::sleep(delay);
}
}
}
result = operation(index + 1);
}
result.map_err(|error| match error {
RetryError::Retry(error) | RetryError::Stop(error) => error,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn retries_stop_at_success_terminal_error_or_attempt_limit() {
let policy = RetryPolicy::fixed(NonZeroUsize::MIN.saturating_add(2), Duration::ZERO);
let mut calls = 0;
let success = retry(policy, |attempt| {
calls += 1;
async move {
if attempt == 1 {
Ok(7)
} else {
Err(RetryError::Retry(attempt))
}
}
})
.await;
assert_eq!((success, calls), (Ok(7), 2));
calls = 0;
let stopped = retry(policy, |_| {
calls += 1;
async move { Err::<(), _>(RetryError::Stop("terminal")) }
})
.await;
assert_eq!((stopped, calls), (Err("terminal"), 1));
calls = 0;
let exhausted = retry(policy, |attempt| {
calls += 1;
async move { Err::<(), _>(RetryError::Retry(attempt)) }
})
.await;
assert_eq!((exhausted, calls), (Err(2), 3));
}
#[tokio::test]
async fn strategies_preserve_order_and_the_last_failure() {
let mut visited = Vec::new();
let result = try_strategies("direct", ["clash", "system"], |strategy| {
visited.push(strategy);
async move { Err::<(), _>(strategy) }
})
.await;
assert_eq!(result, Err("system"));
assert_eq!(visited, ["direct", "clash", "system"]);
}
#[tokio::test]
async fn stateful_and_sync_retries_preserve_progress_and_stop() {
let policy = RetryPolicy::fixed(NonZeroUsize::MIN.saturating_add(2), Duration::ZERO);
let result = retry_with_state(policy, Vec::new(), |visited, attempt| {
Box::pin(async move {
visited.push(attempt);
if visited.len() == 3 {
Ok(visited.clone())
} else {
Err(RetryError::Retry(()))
}
})
})
.await;
assert_eq!(result, Ok(vec![0, 1, 2]));
let mut visited = Vec::new();
let result = retry_sync(policy, |attempt| {
visited.push(attempt);
if attempt == 1 {
Err::<(), _>(RetryError::Stop("terminal"))
} else {
Err(RetryError::Retry("retry"))
}
});
assert_eq!(result, Err("terminal"));
assert_eq!(visited, [0, 1]);
}
}
+29 -15
View File
@@ -1,7 +1,7 @@
use super::resolve;
use crate::{
config::{Config, DEFAULT_PAC, MixedPort},
module::lightweight,
core::lightweight,
process::AsyncHandler,
utils::{dirs, window_manager::WindowManager},
};
@@ -54,20 +54,34 @@ pub async fn check_singleton() -> Result<SingletonDisposition> {
.context("failed to initialize singleton lock")?;
if !try_lock_instance(&lock).context("failed to acquire singleton lock")? {
let deadline = std::time::Instant::now() + Duration::from_secs(20);
loop {
if std::time::Instant::now() >= deadline {
bail!("another app instance is starting");
}
// Retry the lock: during restart, the old server stops before releasing it.
if try_lock_instance(&lock).context("failed to acquire singleton lock")? {
break;
}
if let Ok(record) = read_instance_record(&record_path)
&& notify_existing_instance(&record).await
{
return Ok(SingletonDisposition::Secondary);
}
tokio::time::sleep(Duration::from_millis(50)).await;
use crate::utils::retry::{RetryError, RetryPolicy, retry};
let secondary = retry(
RetryPolicy::fixed(
std::num::NonZeroUsize::MIN.saturating_add(400),
Duration::from_millis(50),
),
|_| async {
if std::time::Instant::now() >= deadline {
return Err(RetryError::Stop(anyhow::anyhow!("another app instance is starting")));
}
// A restarting primary releases the lock after spawning us, with its server down.
if try_lock_instance(&lock)
.context("failed to acquire singleton lock")
.map_err(RetryError::Stop)?
{
return Ok(false);
}
if let Ok(record) = read_instance_record(&record_path)
&& notify_existing_instance(&record).await
{
return Ok(true);
}
Err(RetryError::Retry(anyhow::anyhow!("another app instance is starting")))
},
)
.await?;
if secondary {
return Ok(SingletonDisposition::Secondary);
}
}