chore: sync backend review fixes into contract flow

This commit is contained in:
Tunglies
2026-10-05 14:15:06 +08:00
10 changed files with 141 additions and 186 deletions
+7 -4
View File
@@ -6,10 +6,13 @@ These rules supplement the repository's root `AGENTS.md`.
its draft and persistence APIs. Patch orchestration must use
`feat/effects.rs` and `feat/executor.rs`; refresh notifications must use
`core::notify::announce` after successful persistence and commit. This keeps
failed transactions from publishing state. Independent transactions retain
their boundaries and announce via `after_commit`; runtime-only profile
activation announces after successful application. Cross-layer operations
must preserve these ownership and transaction boundaries.
rolled-back transactions from publishing state. If runtime and memory state
are already committed and cannot be rolled back, announce that state even
when persistence fails, while preserving the persistence error. Independent
transactions retain their boundaries and announce their own committed
results; runtime-only profile activation announces after successful
application. Cross-layer operations must preserve these ownership and
transaction boundaries.
2. **Register every new `IVerge` field.** Register the field in the
exhaustive effect registry, including an explicit `NoEffect` where
appropriate, and preserve its platform attributes. Manual field-to-flag
+2 -2
View File
@@ -143,9 +143,9 @@ impl Config {
PENDING_DNS_OVERRIDE_NOTICE.store(true, Ordering::Relaxed);
Handle::notice(NoticeStatus::DnsOverrideAutoDisabled, "");
}
verge.data_arc().save_file().await?;
let result = verge.data_arc().save_file().await;
Handle::refresh_verge();
Ok(())
result
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ use anyhow::{Context as _, Result, bail};
use std::{io::ErrorKind, path::PathBuf};
pub(crate) struct FileSnapshot {
path: PathBuf,
pub(crate) path: PathBuf,
content: Option<Vec<u8>>,
}
-57
View File
@@ -1,57 +0,0 @@
use crate::{
config::{Config, IVerge},
core::{autostart, hotkey, lightweight, tray},
};
use anyhow::Result;
use futures::future::BoxFuture;
pub(crate) fn ensure_autostart(_patch: &IVerge) -> BoxFuture<'_, Result<()>> {
Box::pin(async move { autostart::update_launch().await })
}
pub(crate) fn ensure_hotkey(patch: &IVerge) -> BoxFuture<'_, Result<()>> {
Box::pin(async move {
if let Some(hotkeys) = &patch.hotkeys {
hotkey::Hotkey::global().update(hotkeys.to_owned()).await?;
}
Ok(())
})
}
pub(crate) fn ensure_tray_menu(_patch: &IVerge) -> BoxFuture<'_, Result<()>> {
Box::pin(async move { tray::Tray::global().update_menu().await })
}
pub(crate) fn ensure_tray_icon(patch: &IVerge) -> BoxFuture<'_, Result<()>> {
Box::pin(async move {
tray::Tray::global()
.update_icon(&Config::verge().await.latest_arc())
.await?;
#[cfg(target_os = "macos")]
if let Some(enabled) = patch.enable_tray_speed {
tray::Tray::global().update_speed_task(enabled);
}
#[cfg(not(target_os = "macos"))]
let _ = patch;
Ok(())
})
}
pub(crate) fn ensure_tray_tooltip(_patch: &IVerge) -> BoxFuture<'_, Result<()>> {
Box::pin(async move { tray::Tray::global().update_tooltip().await })
}
pub(crate) fn ensure_tray_click(_patch: &IVerge) -> BoxFuture<'_, Result<()>> {
Box::pin(async move { tray::Tray::global().update_click_behavior().await })
}
pub(crate) fn ensure_lightweight(patch: &IVerge) -> BoxFuture<'_, Result<()>> {
Box::pin(async move {
if patch.enable_auto_light_weight_mode.unwrap_or(false) {
lightweight::enable_auto_light_weight_mode().await;
} else {
lightweight::disable_auto_light_weight_mode();
}
Ok(())
})
}
+1 -1
View File
@@ -238,7 +238,7 @@ impl CoreManager {
}
}
fn current_core_readiness_generation(&self) -> Option<u64> {
pub(crate) fn current_core_readiness_generation(&self) -> Option<u64> {
active_core_readiness_generation(self.core_readiness_state.load(Ordering::Acquire))
}
-1
View File
@@ -1,4 +1,3 @@
pub(crate) mod app_effects;
pub mod auto_backup;
pub mod autostart;
pub mod backup;
+3 -9
View File
@@ -26,14 +26,6 @@ pub async fn set_dns_override(
profile_uid: String,
enabled: bool,
confirmation: Option<String>,
) -> Result<DnsOverrideOutcome> {
crate::core::notify::after_commit(Box::pin(set_dns_override_inner(profile_uid, enabled, confirmation))).await
}
async fn set_dns_override_inner(
profile_uid: String,
enabled: bool,
confirmation: Option<String>,
) -> Result<DnsOverrideOutcome> {
let _profile_write = PROFILE_WRITE_LOCK.lock().await;
let _config_write = Config::lock_config_write().await;
@@ -83,8 +75,10 @@ async fn set_dns_override_inner(
}
transaction.commit();
verge.data_arc().save_file().await?;
// Core and memory state remain applied even when persistence fails.
let result = verge.data_arc().save_file().await;
announce(Refresh::Verge);
announce(Refresh::Clash);
result?;
Ok(outcome)
}
+2 -3
View File
@@ -5,7 +5,6 @@ use std::collections::BTreeSet;
pub(super) enum Effect {
RestartCore,
ClashConfig,
VergeConfig,
Autostart,
SystemProxy,
TrayIcon,
@@ -90,8 +89,8 @@ verge_registry! {
web_ui_list => NoEffect;
clash_core => NoEffect;
hotkeys => [Hotkey, TrayMenu];
enable_global_hotkey => [VergeConfig];
home_cards => [VergeConfig];
enable_global_hotkey => NoEffect;
home_cards => NoEffect;
auto_close_connection => NoEffect;
auto_check_update => NoEffect;
default_latency_test => NoEffect;
+124 -103
View File
@@ -6,13 +6,13 @@ use crate::{
snapshot::{capture_config_files, restore_files},
},
core::auto_backup::AutoBackupManager,
core::{CoreManager, logger, manager::ConfigUpdateGuard, proxy_control},
core::{CoreManager, autostart, hotkey, lightweight, logger, manager::ConfigUpdateGuard, proxy_control, tray},
};
use anyhow::Result;
use clash_verge_draft::DraftTransaction;
use clash_verge_logging::{Type, logging_error};
use futures::future::BoxFuture;
use serde_yaml_ng::Mapping;
use std::sync::Arc;
use tokio::sync::MutexGuard;
pub(super) enum Patch<'a> {
@@ -20,101 +20,108 @@ pub(super) enum Patch<'a> {
Clash(&'a Mapping),
}
struct EffectContext<'a> {
patch: &'a IVerge,
manager: &'a CoreManager,
update: &'a ConfigUpdateGuard<'a>,
}
type Ensure = for<'a> fn(&'a EffectContext<'_>) -> BoxFuture<'a, Result<()>>;
const EFFECT_ORDER: &[(Effect, Ensure)] = &[
(Effect::RestartCore, ensure_restart),
(Effect::ClashConfig, ensure_clash),
(Effect::VergeConfig, ensure_verge),
(Effect::Autostart, ensure_autostart),
(Effect::Language, ensure_language),
(Effect::SystemProxy, ensure_system_proxy),
(Effect::Hotkey, ensure_hotkey),
(Effect::TrayMenu, ensure_tray_menu),
(Effect::TrayIcon, ensure_tray_icon),
(Effect::TrayTooltip, ensure_tray_tooltip),
(Effect::TrayClick, ensure_tray_click),
(Effect::Lightweight, ensure_lightweight),
(Effect::LogLevel, ensure_log_level),
(Effect::LogFile, ensure_log_file),
const EFFECT_ORDER: &[Effect] = &[
Effect::RestartCore,
Effect::ClashConfig,
Effect::Autostart,
Effect::Language,
Effect::SystemProxy,
Effect::Hotkey,
Effect::TrayMenu,
Effect::TrayIcon,
Effect::TrayTooltip,
Effect::TrayClick,
Effect::Lightweight,
Effect::LogLevel,
Effect::LogFile,
];
macro_rules! ensure {
($name:ident, $ctx:ident, $body:block) => {
fn $name<'a>($ctx: &'a EffectContext<'_>) -> BoxFuture<'a, Result<()>> {
Box::pin(async move $body)
}
};
async fn ensure_restart(manager: &CoreManager) -> Result<()> {
Config::generate().await?;
let previous = manager.current_core_readiness_generation();
let result = manager.restart_core_during_config_update().await;
if result.is_ok()
|| manager
.current_core_readiness_generation()
.is_some_and(|current| Some(current) != previous)
{
Config::runtime().await.apply();
}
result?;
Ok(())
}
ensure!(ensure_restart, ctx, {
Config::generate().await?;
ctx.manager.restart_core_during_config_update().await
});
ensure!(ensure_clash, ctx, {
ctx.manager.update_config_in_patch(ctx.update).await?;
announce(Refresh::Clash);
Ok(())
});
ensure!(ensure_verge, _ctx, { Ok(()) });
ensure!(ensure_autostart, ctx, {
crate::core::app_effects::ensure_autostart(ctx.patch).await
});
ensure!(ensure_language, ctx, {
if let Some(language) = &ctx.patch.language {
clash_verge_i18n::set_locale(language.as_str());
async fn ensure_effect(
effect: Effect,
patch: &IVerge,
manager: &CoreManager,
update: &ConfigUpdateGuard<'_>,
) -> Result<()> {
match effect {
Effect::RestartCore => {
ensure_restart(manager).await?;
}
Effect::ClashConfig => {
manager.update_config_in_patch(update).await?;
Config::runtime().await.apply();
announce(Refresh::Clash);
}
Effect::Autostart => autostart::update_launch().await?,
Effect::Language => {
if let Some(language) = &patch.language {
clash_verge_i18n::set_locale(language.as_str());
}
}
Effect::SystemProxy => {
let _lifecycle = manager.lifecycle_lock.lock().await;
// Disabling only writes OS state and must remain available when the Core is stopped.
if Config::verge()
.await
.latest_arc()
.enable_system_proxy
.unwrap_or_default()
{
manager.apply_proxy_after_start().await?;
} else {
proxy_control::apply().await?;
proxy_control::refresh_guard().await?;
}
}
Effect::Hotkey => {
if let Some(hotkeys) = &patch.hotkeys {
hotkey::Hotkey::global().update(hotkeys.to_owned()).await?;
}
}
Effect::TrayMenu => tray::Tray::global().update_menu().await?,
Effect::TrayIcon => {
tray::Tray::global()
.update_icon(&Config::verge().await.latest_arc())
.await?;
#[cfg(target_os = "macos")]
if let Some(enabled) = patch.enable_tray_speed {
tray::Tray::global().update_speed_task(enabled);
}
}
Effect::TrayTooltip => tray::Tray::global().update_tooltip().await?,
Effect::TrayClick => tray::Tray::global().update_click_behavior().await?,
Effect::Lightweight => {
if patch.enable_auto_light_weight_mode.unwrap_or(false) {
lightweight::enable_auto_light_weight_mode().await;
} else {
lightweight::disable_auto_light_weight_mode();
}
}
Effect::LogLevel => logger::Logger::global().update_log_level(patch.get_log_level())?,
Effect::LogFile => {
logger::update_log_config(
patch.app_log_max_size.unwrap_or(128),
patch.app_log_max_count.unwrap_or(8),
)
.await?;
}
}
Ok(())
});
ensure!(ensure_system_proxy, ctx, {
let _lifecycle = ctx.manager.lifecycle_lock.lock().await;
// Disabling only writes OS state and must remain available when the Core is stopped.
if Config::verge()
.await
.latest_arc()
.enable_system_proxy
.unwrap_or_default()
{
ctx.manager.apply_proxy_after_start().await
} else {
proxy_control::apply().await?;
proxy_control::refresh_guard().await
}
});
ensure!(ensure_hotkey, ctx, {
crate::core::app_effects::ensure_hotkey(ctx.patch).await
});
ensure!(ensure_tray_menu, ctx, {
crate::core::app_effects::ensure_tray_menu(ctx.patch).await
});
ensure!(ensure_tray_icon, ctx, {
crate::core::app_effects::ensure_tray_icon(ctx.patch).await
});
ensure!(ensure_tray_tooltip, ctx, {
crate::core::app_effects::ensure_tray_tooltip(ctx.patch).await
});
ensure!(ensure_tray_click, ctx, {
crate::core::app_effects::ensure_tray_click(ctx.patch).await
});
ensure!(ensure_lightweight, ctx, {
crate::core::app_effects::ensure_lightweight(ctx.patch).await
});
ensure!(ensure_log_level, ctx, {
logger::Logger::global().update_log_level(ctx.patch.get_log_level())
});
ensure!(ensure_log_file, ctx, {
logger::update_log_config(
ctx.patch.app_log_max_size.unwrap_or(128),
ctx.patch.app_log_max_count.unwrap_or(8),
)
.await
});
}
async fn apply_inner(config_write: &MutexGuard<'_, ()>, patch: Patch<'_>, effects: Effects) -> Result<()> {
let manager = CoreManager::global();
@@ -126,24 +133,27 @@ async fn apply_inner(config_write: &MutexGuard<'_, ()>, patch: Patch<'_>, effect
Patch::Verge { .. } => DraftTransaction::begin(vec![&verge, &runtime])?,
Patch::Clash(_) => DraftTransaction::begin(vec![&clash, &runtime])?,
};
let snapshots = capture_config_files().await?;
let original_runtime = runtime.data_arc();
let mut snapshots = capture_config_files().await?;
let empty = IVerge::default();
let context = EffectContext {
patch: match &patch {
Patch::Verge { patch, .. } => patch,
Patch::Clash(_) => &empty,
},
manager,
update: &update,
let verge_patch = match &patch {
Patch::Verge { patch, .. } => patch,
Patch::Clash(_) => &empty,
};
match &patch {
Patch::Verge { patch, .. } => verge.edit_draft(|draft| draft.patch_config(patch)),
Patch::Clash(patch) => clash.edit_draft(|draft| draft.patch_config(patch)),
}
let result: Result<()> = async {
for (effect, ensure) in EFFECT_ORDER {
if effects.contains(effect) {
ensure(&context).await?;
for &effect in EFFECT_ORDER {
if effects.contains(&effect) {
let result = Box::pin(ensure_effect(effect, verge_patch, manager, &update)).await;
if matches!(patch, Patch::Clash(_)) && matches!(effect, Effect::TrayMenu | Effect::TrayIcon) {
// Tray failures must not reject an applied Clash mode change.
logging_error!(Type::Tray, result);
} else {
result?;
}
}
}
match patch {
@@ -156,6 +166,11 @@ async fn apply_inner(config_write: &MutexGuard<'_, ()>, patch: Patch<'_>, effect
.await;
if let Err(error) = result {
transaction.rollback();
if !Arc::ptr_eq(&original_runtime, &runtime.data_arc()) {
// The Core already loaded this runtime; restoring its file would only hide that state.
let runtime_path = crate::utils::dirs::app_home_dir()?.join(crate::constants::files::RUNTIME_CONFIG);
snapshots.retain(|snapshot| snapshot.path != runtime_path);
}
return match restore_files(&snapshots).await {
Ok(()) => Err(error),
Err(rollback_error) => Err(proxy_control::rollback_failure(error, rollback_error)),
@@ -173,7 +188,13 @@ async fn apply_inner(config_write: &MutexGuard<'_, ()>, patch: Patch<'_>, effect
}
pub(super) async fn apply(config_write: &MutexGuard<'_, ()>, patch: Patch<'_>, effects: Effects) -> Result<()> {
crate::core::notify::after_commit(apply_inner(config_write, patch, effects)).await
let runtime = Config::runtime().await;
let original = runtime.data_arc();
let result = crate::core::notify::after_commit(apply_inner(config_write, patch, effects)).await;
if result.is_err() && !Arc::ptr_eq(&original, &runtime.data_arc()) {
announce(Refresh::Clash);
}
result
}
#[cfg(test)]
+1 -5
View File
@@ -27,12 +27,8 @@ pub async fn probe_listener(request: ListenerProbe) -> Result<ListenerProbeOutco
.context("listener probe task failed")
}
pub async fn save_proxy_ports(settings: ProxyPortSettings) -> Result<SaveProxyPortsOutcome> {
crate::core::notify::after_commit(Box::pin(save_proxy_ports_inner(settings))).await
}
#[allow(clippy::cognitive_complexity)]
async fn save_proxy_ports_inner(settings: ProxyPortSettings) -> Result<SaveProxyPortsOutcome> {
pub async fn save_proxy_ports(settings: ProxyPortSettings) -> Result<SaveProxyPortsOutcome> {
settings.validate()?;
let _config_write = Config::lock_config_write().await;
let manager = CoreManager::global();