refactor: Rewrite and improve sysproxy logic (#7744)

* 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.
This commit is contained in:
wonfen
2026-08-14 23:52:37 +08:00
committed by GitHub
parent 0844ed30a8
commit 22947632c0
93 changed files with 5208 additions and 337 deletions
+3 -1
View File
@@ -24,7 +24,9 @@ pub async fn delete_local_backup(filename: String) -> CmdResult<()> {
/// Restore local backup
#[tauri::command]
pub async fn restore_local_backup(filename: String) -> CmdResult<()> {
feat::restore_local_backup(filename).await.stringify_err()
feat::restore_local_backup(filename)
.await
.map_err(|error| super::proxy_aware_error(&error))
}
/// Import local backup into the app's backup directory
+20 -25
View File
@@ -1,4 +1,4 @@
use super::{CmdResult, WithErrorCode as _, coded_error};
use super::{CmdResult, CommandFailure, WithErrorCode as _, proxy_aware_coded_error, proxy_aware_error};
use crate::feat;
use crate::utils::{dirs, yaml_emitter};
use crate::{
@@ -34,7 +34,7 @@ pub async fn get_clash_info() -> CmdResult<ClashInfo> {
pub async fn patch_clash_config(payload: Mapping) -> CmdResult {
feat::patch_clash(&payload)
.await
.with_error_code("CLASH_CONFIG_UPDATE_FAILED")
.map_err(|error| proxy_aware_coded_error(&error, "CLASH_CONFIG_UPDATE_FAILED"))
}
/// 修改Clash模式
@@ -59,7 +59,7 @@ pub async fn get_clash_mode() -> CmdResult<Option<String>> {
/// 切换Clash核心
#[tauri::command]
pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<String>> {
pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<CommandFailure>> {
logging!(info, Type::Config, "changing core to {clash_core}");
match CoreManager::global().change_core(&clash_core).await {
@@ -75,18 +75,19 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<String>>
Ok(None)
}
Err(err) => {
let error_msg: String = format!("Core changed but failed to restart: {err}").into();
let failed = err.context("core changed but failed to restart");
let error_msg: String = format!("{failed:#}").into();
handle::Handle::notice_message("config_core::change_error", error_msg.clone());
logging!(error, Type::Core, "{error_msg}");
Ok(Some(coded_error("CORE_CHANGE_FAILED", error_msg)))
Ok(Some(proxy_aware_coded_error(&failed, "CORE_CHANGE_FAILED")))
}
}
}
Err(err) => {
let error_msg: String = err;
let error_msg: String = format!("{err:#}").into();
logging!(error, Type::Core, "failed to change core: {error_msg}");
handle::Handle::notice_message("config_core::change_error", error_msg.clone());
Ok(Some(coded_error("CORE_CHANGE_FAILED", error_msg)))
handle::Handle::notice_message("config_core::change_error", error_msg);
Ok(Some(proxy_aware_coded_error(&err, "CORE_CHANGE_FAILED")))
}
}
}
@@ -97,7 +98,7 @@ pub async fn start_core() -> CmdResult {
let result = CoreManager::global()
.start_core()
.await
.with_error_code("CORE_START_FAILED");
.map_err(|error| proxy_aware_coded_error(&error, "CORE_START_FAILED"));
if result.is_ok() {
handle::Handle::refresh_clash();
}
@@ -111,7 +112,7 @@ pub async fn stop_core() -> CmdResult {
let result = CoreManager::global()
.stop_core()
.await
.with_error_code("CORE_STOP_FAILED");
.map_err(|error| proxy_aware_coded_error(&error, "CORE_STOP_FAILED"));
if result.is_ok() {
handle::Handle::refresh_clash();
}
@@ -125,7 +126,7 @@ pub async fn restart_core() -> CmdResult {
let result = CoreManager::global()
.restart_core()
.await
.with_error_code("CORE_RESTART_FAILED");
.map_err(|error| proxy_aware_coded_error(&error, "CORE_RESTART_FAILED"));
if result.is_ok() {
handle::Handle::refresh_clash();
}
@@ -195,26 +196,20 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult {
});
// 应用新配置
CoreManager::global()
.update_config_checked()
.await
.stringify_err_log(|err| {
let err = format!("Failed to apply config with DNS: {err}");
logging!(error, Type::Config, "{err}");
})?;
CoreManager::global().update_config_checked().await.map_err(|error| {
logging!(error, Type::Config, "failed to apply the config with DNS: {error:#}");
proxy_aware_error(&error)
})?;
logging!(info, Type::Config, "DNS config successfully applied");
} else {
// 当关闭DNS设置时,重新生成配置(不加载DNS配置文件)
logging!(info, Type::Config, "DNS settings disabled, regenerating config");
CoreManager::global()
.update_config_checked()
.await
.stringify_err_log(|err| {
let err = format!("Failed to apply regenerated config: {err}");
logging!(error, Type::Config, "{err}");
})?;
CoreManager::global().update_config_checked().await.map_err(|error| {
logging!(error, Type::Config, "failed to apply the regenerated config: {error:#}");
proxy_aware_error(&error)
})?;
logging!(info, Type::Config, "Config regenerated successfully");
}
+3 -1
View File
@@ -11,5 +11,7 @@ pub async fn probe_listener(request: ListenerProbe) -> CmdResult<ListenerProbeOu
#[tauri::command]
pub async fn save_proxy_ports(settings: ProxyPortSettings) -> CmdResult<SaveProxyPortsOutcome> {
feat::save_proxy_ports(settings).await.stringify_err()
feat::save_proxy_ports(settings)
.await
.map_err(|error| super::proxy_aware_error(&error))
}
+240 -11
View File
@@ -1,12 +1,94 @@
use anyhow::Result;
use smartstring::alias::String;
pub type CmdResult<T = ()> = Result<T, String>;
pub type CmdResult<T = ()> = Result<T, CommandFailure>;
const USER_ERROR_PREFIX: &str = "CVR_ERROR:";
/// Structured command failure returned to the frontend.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct CommandFailure {
/// Stable frontend identifier, when classified.
#[serde(skip_serializing_if = "Option::is_none")]
pub code: Option<std::string::String>,
/// Human-readable diagnostic detail.
pub detail: std::string::String,
/// Requested operation when known by the command.
#[serde(skip_serializing_if = "Option::is_none")]
pub operation: Option<crate::core::notification::FailedOperation>,
}
pub fn coded_error(code: &str, detail: impl std::fmt::Display) -> String {
format!("{USER_ERROR_PREFIX}{code}\n{detail}").into()
impl CommandFailure {
/// A failure with a stable code.
pub fn coded(code: &str, detail: impl std::fmt::Display) -> Self {
Self {
code: Some(code.to_owned()),
detail: detail.to_string(),
operation: None,
}
}
/// A failure with nothing to classify it by.
pub fn plain(detail: impl std::fmt::Display) -> Self {
Self {
code: None,
detail: detail.to_string(),
operation: None,
}
}
/// Attach the requested operation when known.
#[must_use]
pub const fn asking_for(mut self, operation: Option<crate::core::notification::FailedOperation>) -> Self {
self.operation = operation;
self
}
}
/// Preserve plain-string failures without inventing a code.
impl From<&str> for CommandFailure {
fn from(detail: &str) -> Self {
Self::plain(detail)
}
}
impl From<std::string::String> for CommandFailure {
fn from(detail: std::string::String) -> Self {
Self {
code: None,
detail,
operation: None,
}
}
}
impl From<String> for CommandFailure {
fn from(detail: String) -> Self {
Self::plain(detail)
}
}
impl std::fmt::Display for CommandFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.detail)
}
}
pub fn coded_error(code: &str, detail: impl std::fmt::Display) -> CommandFailure {
CommandFailure::coded(code, detail)
}
/// Prefer an actionable proxy classification over the operation's fallback code.
pub fn proxy_aware_coded_error(error: &anyhow::Error, fallback: &str) -> CommandFailure {
let code =
crate::core::proxy_control::SysproxyFailure::from_chain(error).map_or(fallback, |failure| failure.code());
CommandFailure::coded(code, format_args!("{error:#}"))
}
/// Preserve uncoded failures unless the chain contains a proxy classification.
pub fn proxy_aware_error(error: &anyhow::Error) -> CommandFailure {
match crate::core::proxy_control::SysproxyFailure::from_chain(error) {
Some(failure) => CommandFailure::coded(failure.code(), format_args!("{error:#}")),
None => CommandFailure::plain(format_args!("{error:#}")),
}
}
// Command modules
@@ -60,7 +142,7 @@ pub trait WithErrorCode<T> {
impl<T, E: std::fmt::Display> StringifyErr<T> for Result<T, E> {
fn stringify_err(self) -> CmdResult<T> {
self.map_err(|e| e.to_string().into())
self.map_err(CommandFailure::plain)
}
fn stringify_err_log<F>(self, log_fn: F) -> CmdResult<T>
@@ -68,28 +150,175 @@ impl<T, E: std::fmt::Display> StringifyErr<T> for Result<T, E> {
F: Fn(&str),
{
self.map_err(|e| {
let msg = String::from(e.to_string());
log_fn(&msg);
msg
let failure = CommandFailure::plain(e);
log_fn(&failure.detail);
failure
})
}
}
impl<T, E: std::fmt::Display> WithErrorCode<T> for Result<T, E> {
fn with_error_code(self, code: &str) -> CmdResult<T> {
self.map_err(|error| coded_error(code, error))
// Alternate formatting expands the full `anyhow` context chain.
self.map_err(|error| CommandFailure::coded(code, format_args!("{error:#}")))
}
}
#[cfg(test)]
mod tests {
use super::coded_error;
use super::{CommandFailure, WithErrorCode as _, coded_error, proxy_aware_coded_error, proxy_aware_error};
use crate::core::proxy_control::SysproxyFailure;
use anyhow::Context as _;
#[test]
fn coded_error_preserves_stable_code_and_diagnostic_detail() {
assert_eq!(
coded_error("CORE_RESTART_FAILED", "connection refused"),
"CVR_ERROR:CORE_RESTART_FAILED\nconnection refused"
CommandFailure {
code: Some("CORE_RESTART_FAILED".to_owned()),
detail: "connection refused".to_owned(),
operation: None,
}
);
}
#[test]
fn with_error_code_preserves_whole_anyhow_context_chain() {
let source: anyhow::Result<()> = Err(anyhow::anyhow!("connection refused"))
.context("failed to reach the mihomo core")
.context("failed to restart the core");
let failure = source
.with_error_code("CORE_RESTART_FAILED")
.err()
.unwrap_or_else(|| CommandFailure::plain("the call should have failed"));
assert_eq!(
failure,
CommandFailure {
code: Some("CORE_RESTART_FAILED".to_owned()),
detail: "failed to restart the core: failed to reach the mihomo core: connection refused".to_owned(),
operation: None,
}
);
}
#[test]
fn with_error_code_leaves_plain_display_errors_untouched() {
let source: Result<(), &str> = Err("plain failure");
let failure = source
.with_error_code("PROFILE_READ_FAILED")
.err()
.unwrap_or_else(|| CommandFailure::plain("the call should have failed"));
assert_eq!(
failure,
CommandFailure {
code: Some("PROFILE_READ_FAILED".to_owned()),
detail: "plain failure".to_owned(),
operation: None,
}
);
}
#[test]
fn a_classification_outranks_the_code_naming_the_operation() {
let failure = anyhow::Error::new(sysproxy::Error::RequiresAdminPrivileges)
.context(SysproxyFailure::PrivilegeRequired)
.context("failed to restart the core");
let reported = proxy_aware_coded_error(&failure, "CORE_RESTART_FAILED");
assert_eq!(reported.code.as_deref(), Some("SYSPROXY_PRIVILEGE_REQUIRED"));
assert!(reported.detail.contains("failed to restart the core"), "{reported:?}");
assert!(reported.detail.contains("admin privileges"), "{reported:?}");
}
#[test]
fn without_a_classification_the_callers_code_is_used() {
let failure = anyhow::anyhow!("connection refused").context("failed to start the core");
let reported = proxy_aware_coded_error(&failure, "CORE_START_FAILED");
assert_eq!(
reported,
CommandFailure {
code: Some("CORE_START_FAILED".to_owned()),
detail: "failed to start the core: connection refused".to_owned(),
operation: None,
}
);
}
#[test]
fn commands_that_report_plain_text_stay_uncoded_without_a_classification() {
let failure = anyhow::anyhow!("disk full").context("failed to save the configuration");
let reported = proxy_aware_error(&failure);
assert_eq!(
reported,
CommandFailure {
code: None,
detail: "failed to save the configuration: disk full".to_owned(),
operation: None,
}
);
}
#[test]
fn commands_that_report_plain_text_still_carry_a_classification() {
let failure = anyhow::Error::new(SysproxyFailure::DirectFallback {
detail: "service could not set the proxy".to_owned(),
})
.context("failed to apply the verge patch");
let reported = proxy_aware_error(&failure);
assert_eq!(reported.code.as_deref(), Some("SYSPROXY_DIRECT_FALLBACK"));
assert!(
reported.detail.contains("failed to apply the verge patch"),
"{reported:?}"
);
assert!(
reported.detail.contains("service could not set the proxy"),
"{reported:?}"
);
}
#[test]
fn a_command_that_knows_the_request_can_say_which_way_it_went() {
let asking_on = CommandFailure::coded("SYSPROXY_PRIVILEGE_REQUIRED", "refused")
.asking_for(Some(crate::core::notification::FailedOperation::SystemProxyEnable));
assert_eq!(
serde_json::to_value(asking_on).unwrap_or(serde_json::Value::Null),
serde_json::json!({
"code": "SYSPROXY_PRIVILEGE_REQUIRED",
"detail": "refused",
"operation": "systemProxyEnable",
})
);
assert_eq!(
serde_json::to_value(CommandFailure::coded("CORE_START_FAILED", "no").asking_for(None))
.unwrap_or(serde_json::Value::Null),
serde_json::json!({ "code": "CORE_START_FAILED", "detail": "no" })
);
}
#[test]
fn the_serialised_shape_is_what_the_frontend_reads() {
assert_eq!(
serde_json::to_value(CommandFailure::coded("CORE_START_FAILED", "connection refused"))
.unwrap_or(serde_json::Value::Null),
serde_json::json!({ "code": "CORE_START_FAILED", "detail": "connection refused" })
);
assert_eq!(
serde_json::to_value(CommandFailure::plain("disk full")).unwrap_or(serde_json::Value::Null),
serde_json::json!({ "detail": "disk full" })
);
}
}
+1 -1
View File
@@ -98,7 +98,7 @@ async fn restore_original(
file_path: &std::path::Path,
original_content: &str,
original_existed: bool,
) -> Result<(), String> {
) -> CmdResult<()> {
if original_existed {
fs::write(file_path, original_content).await.stringify_err()
} else {
+8 -5
View File
@@ -1,4 +1,4 @@
use super::{CmdResult, WithErrorCode as _};
use super::{CmdResult, proxy_aware_coded_error};
use crate::core::{
CoreManager,
service::{SERVICE_MANAGER, ServiceStatus},
@@ -11,12 +11,15 @@ async fn execute_service_operation_sync(status: ServiceStatus, error_code: &str)
&status,
ServiceStatus::ReinstallRequired | ServiceStatus::ForceReinstallRequired
) {
manager.controlled_stop_core_inner().await.with_error_code(error_code)?;
manager
.controlled_stop_core_inner()
.await
.map_err(|error| proxy_aware_coded_error(&error, error_code))?;
}
SERVICE_MANAGER
.handle_service_status(status)
.await
.with_error_code(error_code)
.map_err(|error| proxy_aware_coded_error(&error, error_code))
}
#[tauri::command]
@@ -29,7 +32,7 @@ pub async fn uninstall_service() -> CmdResult {
CoreManager::global()
.uninstall_service_and_start_sidecar()
.await
.with_error_code("SERVICE_UNINSTALL_FAILED")
.map_err(|error| proxy_aware_coded_error(&error, "SERVICE_UNINSTALL_FAILED"))
}
#[tauri::command]
@@ -47,5 +50,5 @@ pub async fn continue_with_sidecar() -> CmdResult {
crate::core::CoreManager::global()
.continue_with_sidecar()
.await
.with_error_code("SERVICE_SIDECAR_FAILED")
.map_err(|error| proxy_aware_coded_error(&error, "SERVICE_SIDECAR_FAILED"))
}
+10 -1
View File
@@ -1,4 +1,7 @@
use crate::core::runstate::{RUN_STATE, RunStateView};
use crate::core::{
notification::{self, PendingFailure},
runstate::{RUN_STATE, RunStateView},
};
/// The whole Run State in one call.
///
@@ -9,3 +12,9 @@ use crate::core::runstate::{RUN_STATE, RunStateView};
pub async fn get_runtime_state() -> Result<RunStateView, String> {
Ok(RUN_STATE.settled().await.to_view())
}
/// Return unresolved failures without consuming them.
#[tauri::command]
pub async fn get_pending_failures() -> Vec<PendingFailure> {
notification::pending_failures()
}
+50 -3
View File
@@ -1,5 +1,10 @@
use super::CmdResult;
use crate::{cmd::StringifyErr as _, config::IVerge, feat};
use super::{CmdResult, proxy_aware_error};
use crate::{
cmd::StringifyErr as _,
config::IVerge,
core::notification::{self, FailedOperation},
feat,
};
use clash_verge_draft::SharedDraft;
/// 获取Verge配置
@@ -11,5 +16,47 @@ pub async fn get_verge_config() -> CmdResult<SharedDraft<IVerge>> {
/// 修改Verge配置
#[tauri::command]
pub async fn patch_verge_config(payload: IVerge) -> CmdResult {
feat::patch_verge(&payload, false).await.stringify_err()
let operation = system_proxy_operation(&payload);
let result = match operation {
Some(operation) => notification::asking_for(operation, Box::pin(feat::patch_verge(&payload, false))).await,
None => feat::patch_verge(&payload, false).await,
};
result.map_err(|error| proxy_aware_error(&error).asking_for(operation))
}
/// Extract a system proxy operation from a Verge patch.
const fn system_proxy_operation(payload: &IVerge) -> Option<FailedOperation> {
match payload.enable_system_proxy {
Some(true) => Some(FailedOperation::SystemProxyEnable),
Some(false) => Some(FailedOperation::SystemProxyDisable),
None => None,
}
}
#[cfg(test)]
mod tests {
use super::system_proxy_operation;
use crate::{config::IVerge, core::notification::FailedOperation};
#[test]
fn only_a_system_proxy_toggle_says_which_way_it_was_going() {
let asking_on = IVerge {
enable_system_proxy: Some(true),
..IVerge::default()
};
let asking_off = IVerge {
enable_system_proxy: Some(false),
..IVerge::default()
};
assert_eq!(
system_proxy_operation(&asking_on),
Some(FailedOperation::SystemProxyEnable)
);
assert_eq!(
system_proxy_operation(&asking_off),
Some(FailedOperation::SystemProxyDisable)
);
assert_eq!(system_proxy_operation(&IVerge::default()), None);
}
}
+3 -1
View File
@@ -46,5 +46,7 @@ pub async fn delete_webdav_backup(filename: String) -> CmdResult<()> {
/// 从 WebDAV 恢复备份文件
#[tauri::command]
pub async fn restore_webdav_backup(filename: String) -> CmdResult<()> {
feat::restore_webdav_backup(filename).await.stringify_err()
feat::restore_webdav_backup(filename)
.await
.map_err(|error| super::proxy_aware_error(&error))
}
+3 -2
View File
@@ -146,8 +146,9 @@ impl Hotkey {
}
HotkeyFunction::ToggleSystemProxy => {
AsyncHandler::spawn(async move || {
let is_proxy_enabled = feat::toggle_system_proxy().await;
notify_event(NotificationEvent::SystemProxyToggled(is_proxy_enabled)).await;
if let Some(is_proxy_enabled) = feat::toggle_system_proxy().await {
notify_event(NotificationEvent::SystemProxyToggled(is_proxy_enabled)).await;
}
});
}
HotkeyFunction::ToggleTunMode => {
+108
View File
@@ -435,6 +435,13 @@ fn bind_claim(claim: &BindClaim) -> io::Result<Vec<Socket>> {
if claim.transport == ListenerTransport::Tcp {
socket.listen(1)?;
}
#[cfg(windows)]
if claim.transport == ListenerTransport::Tcp
&& !claim.address.is_unspecified()
&& windows_wildcard_tcp_listener_exists(claim.address, claim.port)?
{
return Err(io::ErrorKind::AddrInUse.into());
}
sockets.push(socket);
Ok(sockets)
}
@@ -504,6 +511,107 @@ fn set_exclusive_address_use(socket: &Socket) -> io::Result<()> {
Ok(())
}
#[cfg(windows)]
fn windows_wildcard_tcp_listener_exists(address: IpAddr, port: u16) -> io::Result<bool> {
use windows_sys::Win32::{
NetworkManagement::IpHelper::{MIB_TCP6ROW_OWNER_PID, MIB_TCPROW_OWNER_PID},
Networking::WinSock::{AF_INET, AF_INET6},
};
let family = match address {
IpAddr::V4(_) => u32::from(AF_INET),
IpAddr::V6(_) => u32::from(AF_INET6),
};
let table = windows_tcp_listener_table(family)?;
match address {
IpAddr::V4(_) => Ok(windows_tcp_listener_rows::<MIB_TCPROW_OWNER_PID>(&table)?
.iter()
.any(|row| row.dwLocalAddr == 0 && windows_tcp_port(row.dwLocalPort) == port)),
IpAddr::V6(_) => Ok(windows_tcp_listener_rows::<MIB_TCP6ROW_OWNER_PID>(&table)?
.iter()
.any(|row| row.ucLocalAddr == [0; 16] && windows_tcp_port(row.dwLocalPort) == port)),
}
}
#[cfg(windows)]
fn windows_tcp_listener_table(family: u32) -> io::Result<Vec<u32>> {
use std::{mem::size_of, ptr::null_mut};
use windows_sys::Win32::{
Foundation::ERROR_INSUFFICIENT_BUFFER,
NetworkManagement::IpHelper::{GetExtendedTcpTable, TCP_TABLE_OWNER_PID_LISTENER},
};
let mut byte_count = 0;
// SAFETY: a null table is the documented size-query form; `byte_count` is writable.
let status = unsafe {
GetExtendedTcpTable(
null_mut(),
&raw mut byte_count,
0,
family,
TCP_TABLE_OWNER_PID_LISTENER,
0,
)
};
if status != ERROR_INSUFFICIENT_BUFFER {
return Err(io::Error::from_raw_os_error(status.cast_signed()));
}
loop {
let word_count = (byte_count as usize).div_ceil(size_of::<u32>());
let mut table = vec![0u32; word_count];
// SAFETY: `table` has at least `byte_count` writable bytes and the API updates
// `byte_count` when the listener table grows between calls.
let status = unsafe {
GetExtendedTcpTable(
table.as_mut_ptr().cast(),
&raw mut byte_count,
0,
family,
TCP_TABLE_OWNER_PID_LISTENER,
0,
)
};
match status {
0 => return Ok(table),
ERROR_INSUFFICIENT_BUFFER => {}
error => return Err(io::Error::from_raw_os_error(error.cast_signed())),
}
}
}
#[cfg(windows)]
fn windows_tcp_listener_rows<Row>(table: &[u32]) -> io::Result<&[Row]> {
use std::mem::{align_of, size_of, size_of_val};
let Some((&row_count, _)) = table.split_first() else {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"Windows returned an empty TCP listener table",
));
};
let row_count = row_count as usize;
let required_bytes = row_count
.checked_mul(size_of::<Row>())
.and_then(|rows| size_of::<u32>().checked_add(rows))
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Windows TCP listener table is too large"))?;
if required_bytes > size_of_val(table) || align_of::<Row>() > align_of::<u32>() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"Windows returned a malformed TCP listener table",
));
}
// SAFETY: the size and alignment checks above cover `row_count` complete rows;
// the API places them immediately after its u32 entry count.
Ok(unsafe { std::slice::from_raw_parts(table.as_ptr().add(1).cast(), row_count) })
}
#[cfg(windows)]
const fn windows_tcp_port(raw_port: u32) -> u16 {
u16::from_be(raw_port as u16)
}
const fn conflict_outcome(claim: &BindClaim) -> ListenerProbeOutcome {
ListenerProbeOutcome::Conflict {
port: claim.port,
+2 -2
View File
@@ -428,8 +428,8 @@ impl CoreManager {
Ok(())
}
Err(err) => {
logging!(error, Type::Core, "Failed to restart core: {}", err);
Err(anyhow!("Failed to apply config: {}", err))
logging!(error, Type::Core, "Failed to restart core: {err:#}");
Err(err.context("failed to apply the configuration"))
}
}
}
+195 -38
View File
@@ -1,11 +1,10 @@
use super::{CoreManager, RunningMode};
use crate::cmd::StringifyErr as _;
use crate::config::{Config, IVerge};
use crate::core::handle::Handle;
use crate::core::manager::CLASH_LOGGER;
use crate::core::proxy_control;
use crate::core::service::{SERVICE_MANAGER, ServiceStatus};
use anyhow::Result;
use anyhow::{Result, ensure};
use clash_verge_logging::{Type, logging};
use scopeguard::defer;
use smartstring::alias::String;
@@ -25,6 +24,22 @@ enum StartupDecision {
Wait,
}
/// Proxy action captured before a controlled stop.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ProxyStopIntent {
Clear,
/// Let the service overwrite the inherited proxy state.
HandOverToService,
}
/// Select whether a controlled stop clears or hands over proxy state.
const fn proxy_stop_intent(is_macos: bool, running_mode: RunningMode, decision: StartupDecision) -> ProxyStopIntent {
match (is_macos, running_mode, decision) {
(true, RunningMode::Sidecar, StartupDecision::Service) => ProxyStopIntent::HandOverToService,
_ => ProxyStopIntent::Clear,
}
}
const fn startup_decision(status: &ServiceStatus, service_required: bool) -> StartupDecision {
match status {
ServiceStatus::Ready => StartupDecision::Service,
@@ -102,22 +117,31 @@ impl ProxyRestoreExpectation {
async fn run_controlled_stop_transition<StopGuard, StopGuardFuture, Clear, ClearFuture, Stop, StopFuture>(
is_macos: bool,
running_mode: RunningMode,
proxy_intent: ProxyStopIntent,
stop_guard: StopGuard,
clear_proxy: Clear,
stop_core: Stop,
) -> Result<()>
where
StopGuard: FnOnce() -> StopGuardFuture,
StopGuardFuture: std::future::Future<Output = ()>,
StopGuardFuture: std::future::Future<Output = bool>,
Clear: FnOnce() -> ClearFuture,
ClearFuture: std::future::Future<Output = Result<()>>,
Stop: FnOnce() -> StopFuture,
StopFuture: std::future::Future<Output = Result<()>>,
{
match running_mode {
RunningMode::NotRunning => {}
RunningMode::Service if is_macos => stop_guard().await,
RunningMode::Service | RunningMode::Sidecar => clear_proxy().await?,
match (running_mode, proxy_intent) {
(RunningMode::NotRunning, _) => {}
// Do not hand the OS to another owner while the old guard may still write.
(RunningMode::Sidecar, ProxyStopIntent::HandOverToService) => ensure!(
stop_guard().await,
"the system proxy guard did not stop in time; not handing the proxy to the service"
),
(RunningMode::Service, _) if is_macos => ensure!(
stop_guard().await,
"the system proxy guard did not stop in time; not stopping the service-owned core"
),
(RunningMode::Service | RunningMode::Sidecar, _) => clear_proxy().await?,
}
stop_core().await
}
@@ -197,7 +221,7 @@ async fn run_service_config_replacement_transition<
) -> Result<()>
where
StopGuard: FnOnce() -> StopGuardFuture,
StopGuardFuture: std::future::Future<Output = ()>,
StopGuardFuture: std::future::Future<Output = bool>,
Clear: FnOnce() -> ClearFuture,
ClearFuture: std::future::Future<Output = Result<()>>,
Stop: FnOnce() -> StopFuture,
@@ -208,7 +232,15 @@ where
Restore: FnOnce() -> RestoreFuture,
RestoreFuture: std::future::Future<Output = Result<()>>,
{
run_controlled_stop_transition(is_macos, RunningMode::Service, stop_guard, clear_proxy, stop_core).await?;
run_controlled_stop_transition(
is_macos,
RunningMode::Service,
ProxyStopIntent::Clear,
stop_guard,
clear_proxy,
stop_core,
)
.await?;
run_ready_core_start_transition(start_core, core_is_ready, restore_proxy).await
}
@@ -311,16 +343,16 @@ impl CoreManager {
self.apply_proxy_after_start().await
}
.await;
if let Err(error) = &result {
if let Err(error) = result {
// Revoke only the failed Sidecar allowance; its startup says nothing about Service health.
SERVICE_MANAGER.withdraw_sidecar_allowance();
if let Err(cleanup_error) = self.rollback_failed_sidecar_transition().await {
return Err(anyhow::anyhow!(
"Sidecar startup failed: {error:#}; failed to clear proxy before rollback: {cleanup_error:#}"
));
return Err(proxy_control::rollback_failure(error, cleanup_error)
.context("failed to clear the proxy before rolling back"));
}
return Err(error);
}
result
Ok(())
}
pub async fn uninstall_service_and_start_sidecar(&self) -> Result<()> {
@@ -354,14 +386,14 @@ impl CoreManager {
)
.await;
if let Err(error) = &result
&& let Err(cleanup_error) = self.rollback_failed_sidecar_transition().await
{
return Err(anyhow::anyhow!(
"Service uninstall transition failed: {error:#}; failed to clear proxy before rollback: {cleanup_error:#}"
));
if let Err(error) = result {
if let Err(cleanup_error) = self.rollback_failed_sidecar_transition().await {
return Err(proxy_control::rollback_failure(error, cleanup_error)
.context("failed to clear the proxy before rolling back"));
}
return Err(error);
}
result
Ok(())
}
async fn rollback_failed_sidecar_transition(&self) -> Result<()> {
@@ -483,10 +515,16 @@ impl CoreManager {
/// 调用者须已持有 `lifecycle_lock`。
pub(crate) async fn controlled_stop_core_inner(&self) -> Result<()> {
self.controlled_stop_core_with_intent(ProxyStopIntent::Clear).await
}
/// 调用者须已持有 `lifecycle_lock`。
async fn controlled_stop_core_with_intent(&self, proxy_intent: ProxyStopIntent) -> Result<()> {
let running_mode = *self.get_running_mode();
run_controlled_stop_transition(
cfg!(target_os = "macos"),
running_mode,
proxy_intent,
proxy_control::stop_guard,
proxy_control::clear,
|| self.stop_core_unprepared_inner(),
@@ -494,6 +532,15 @@ impl CoreManager {
.await
}
/// Capture handoff intent before stopping; startup state may change afterward.
async fn proxy_stop_intent(&self) -> ProxyStopIntent {
let running_mode = *self.get_running_mode();
if !cfg!(target_os = "macos") || !matches!(running_mode, RunningMode::Sidecar) {
return ProxyStopIntent::Clear;
}
proxy_stop_intent(cfg!(target_os = "macos"), running_mode, self.prepare_startup().await)
}
/// 调用者须已持有 `lifecycle_lock`,且已完成受控代理清理。
async fn stop_core_unprepared_inner(&self) -> Result<()> {
CLASH_LOGGER.clear_logs().await;
@@ -521,8 +568,9 @@ impl CoreManager {
// 持锁覆盖 stop+start,避免生命周期操作插入。
let _life = self.lifecycle_lock.lock().await;
logging!(info, Type::Core, "Restarting core");
let proxy_intent = self.proxy_stop_intent().await;
run_core_replacement_transition(
|| self.controlled_stop_core_inner(),
|| self.controlled_stop_core_with_intent(proxy_intent),
|| async {
self.start_core_inner().await?;
if matches!(*self.get_running_mode(), RunningMode::NotRunning) {
@@ -535,9 +583,10 @@ impl CoreManager {
.await
}
pub async fn change_core(&self, clash_core: &String) -> Result<(), String> {
/// Switch the core binary and reload configuration.
pub async fn change_core(&self, clash_core: &String) -> Result<()> {
if !IVerge::VALID_CLASH_CORES.contains(&clash_core.as_str()) {
return Err(format!("Invalid clash core: {}", clash_core).into());
anyhow::bail!("invalid clash core: {clash_core}");
}
Config::verge().await.edit_draft(|d| {
@@ -546,10 +595,8 @@ impl CoreManager {
Config::verge().await.apply();
let verge_data = Config::verge().await.latest_arc();
verge_data.save_file().await.map_err(|e| e.to_string())?;
self.update_config_checked().await.stringify_err()?;
Ok(())
verge_data.save_file().await?;
self.update_config_checked().await
}
async fn prepare_startup(&self) -> StartupDecision {
@@ -761,7 +808,7 @@ impl CoreManager {
#[cfg(test)]
mod tests {
use super::{
CoreManager, ProxyRestoreExpectation, StartupDecision, can_allow_sidecar_for_session,
CoreManager, ProxyRestoreExpectation, ProxyStopIntent, StartupDecision, can_allow_sidecar_for_session,
run_controlled_stop_transition, run_core_replacement_transition, run_core_start_transition,
run_ready_core_start_transition, run_service_config_replacement_transition, run_sidecar_termination_transition,
run_uninstall_transition, should_wait_for_service, startup_decision,
@@ -800,10 +847,11 @@ mod tests {
true
}
async fn stop_guard(&self, calls: &Mutex<Vec<&'static str>>) {
async fn stop_guard(&self, calls: &Mutex<Vec<&'static str>>) -> bool {
self.generation.fetch_add(1, Ordering::AcqRel);
let _operation = self.operation_lock.lock().await;
calls.lock().push("guard-stopped");
true
}
}
@@ -848,7 +896,8 @@ mod tests {
let result = run_controlled_stop_transition(
true,
RunningMode::Sidecar,
|| future::ready(()),
ProxyStopIntent::Clear,
|| future::ready(true),
|| transition_step(&calls, "proxy_clear", None),
|| transition_step(&calls, "core_stop", None),
)
@@ -858,6 +907,112 @@ mod tests {
assert_eq!(&*calls.lock(), &["proxy_clear", "core_stop"]);
}
#[test]
fn only_a_macos_sidecar_on_its_way_to_the_service_leaves_the_proxy_alone() {
use super::proxy_stop_intent;
assert_eq!(
proxy_stop_intent(true, RunningMode::Sidecar, StartupDecision::Service),
ProxyStopIntent::HandOverToService
);
for (is_macos, mode, decision) in [
(true, RunningMode::Sidecar, StartupDecision::Sidecar),
(true, RunningMode::Sidecar, StartupDecision::Wait),
(true, RunningMode::Service, StartupDecision::Service),
(true, RunningMode::NotRunning, StartupDecision::Service),
(false, RunningMode::Sidecar, StartupDecision::Service),
] {
assert_eq!(
proxy_stop_intent(is_macos, mode, decision),
ProxyStopIntent::Clear,
"{is_macos} {mode:?} {decision:?}"
);
}
}
#[tokio::test]
async fn a_sidecar_handed_to_the_service_stops_its_guard_and_leaves_the_proxy_alone() {
let calls = Arc::new(Mutex::new(Vec::new()));
let result = run_controlled_stop_transition(
true,
RunningMode::Sidecar,
ProxyStopIntent::HandOverToService,
|| {
calls.lock().push("guard-stopped");
future::ready(true)
},
|| transition_step(&calls, "unexpected-proxy-clear", None),
|| transition_step(&calls, "core_stop", None),
)
.await;
assert!(result.is_ok());
assert_eq!(&*calls.lock(), &["guard-stopped", "core_stop"]);
}
#[tokio::test]
async fn a_guard_that_would_not_stop_keeps_the_core_where_it_is() {
for (mode, intent) in [
(RunningMode::Sidecar, ProxyStopIntent::HandOverToService),
(RunningMode::Service, ProxyStopIntent::Clear),
] {
let calls = Arc::new(Mutex::new(Vec::new()));
let result = run_controlled_stop_transition(
true,
mode,
intent,
|| future::ready(false),
|| transition_step(&calls, "proxy_clear", None),
|| transition_step(&calls, "core_stop", None),
)
.await;
assert!(result.is_err(), "{mode:?} {intent:?}");
assert!(calls.lock().is_empty(), "{mode:?} {intent:?}: {:?}", &*calls.lock());
}
}
#[tokio::test]
async fn a_sidecar_handed_over_still_stops_even_when_a_user_space_clear_would_fail() {
let calls = Arc::new(Mutex::new(Vec::new()));
let result = run_controlled_stop_transition(
true,
RunningMode::Sidecar,
ProxyStopIntent::HandOverToService,
|| future::ready(true),
|| transition_step(&calls, "proxy_clear", Some("proxy_clear")),
|| transition_step(&calls, "core_stop", None),
)
.await;
assert!(result.is_ok());
assert_eq!(&*calls.lock(), &["core_stop"]);
}
#[tokio::test]
async fn an_ordinary_sidecar_stop_still_clears_even_off_macos() {
for is_macos in [true, false] {
let calls = Arc::new(Mutex::new(Vec::new()));
let result = run_controlled_stop_transition(
is_macos,
RunningMode::Sidecar,
ProxyStopIntent::Clear,
|| future::ready(true),
|| transition_step(&calls, "proxy_clear", None),
|| transition_step(&calls, "core_stop", None),
)
.await;
assert!(result.is_ok());
assert_eq!(&*calls.lock(), &["proxy_clear", "core_stop"]);
}
}
#[tokio::test]
async fn controlled_macos_service_stop_drains_guard_before_one_helper_transaction() {
let calls = Arc::new(Mutex::new(Vec::new()));
@@ -865,9 +1020,10 @@ mod tests {
let result = run_controlled_stop_transition(
true,
RunningMode::Service,
ProxyStopIntent::Clear,
|| {
calls.lock().push("guard-stopped");
future::ready(())
future::ready(true)
},
|| transition_step(&calls, "unexpected-proxy-clear", None),
|| transition_step(&calls, "service_stop_with_proxy_clear", None),
@@ -904,9 +1060,8 @@ mod tests {
let mut stop = Box::pin(run_controlled_stop_transition(
true,
RunningMode::Service,
|| async {
coordinator.stop_guard(&calls).await;
},
ProxyStopIntent::Clear,
|| coordinator.stop_guard(&calls),
|| transition_step(&calls, "unexpected-proxy-clear", None),
|| transition_step(&calls, "service-stop-failed", Some("service-stop-failed")),
));
@@ -934,7 +1089,8 @@ mod tests {
let result = run_controlled_stop_transition(
true,
RunningMode::Sidecar,
|| future::ready(()),
ProxyStopIntent::Clear,
|| future::ready(true),
|| transition_step(&calls, "proxy_clear", Some("proxy_clear")),
|| transition_step(&calls, "core_stop", None),
)
@@ -1037,7 +1193,8 @@ mod tests {
let result = run_controlled_stop_transition(
false,
RunningMode::Service,
|| future::ready(()),
ProxyStopIntent::Clear,
|| future::ready(true),
|| transition_step(&calls, "service-proxy-clear", Some("service-proxy-clear")),
|| {
service_alive.store(false, Ordering::Release);
@@ -1139,7 +1296,7 @@ mod tests {
is_macos,
|| {
calls.lock().push("guard-stopped");
future::ready(())
future::ready(true)
},
|| transition_step(&calls, "proxy-clear", None),
|| {
+3 -4
View File
@@ -280,10 +280,9 @@ impl CoreManager {
}
Err(fallback_error) => {
crate::config::Config::block_startup_core(&fallback_error);
return Err(anyhow::anyhow!(
"core startup failed: {start_error:#}; mixed proxy port fallback failed: \
{fallback_error:#}"
));
return Err(start_error.context(format!(
"the mixed proxy port fallback did not rescue core startup: {fallback_error:#}"
)));
}
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ pub mod hotkey;
pub mod listener;
pub mod logger;
pub mod manager;
mod notification;
pub mod notification;
pub(crate) mod owner_identity;
pub mod proxy_control;
pub mod proxy_view;
+386 -1
View File
@@ -1,7 +1,13 @@
use clash_verge_logging::{Type, logging};
use once_cell::sync::Lazy;
use parking_lot::Mutex;
use serde_json::json;
use smartstring::alias::String;
use std::{
collections::HashMap,
future::Future,
sync::atomic::{AtomicU64, Ordering},
};
use tauri::{AppHandle, Emitter as _, Manager as _, WebviewWindow};
#[derive(Debug)]
@@ -16,6 +22,236 @@ pub enum FrontendEvent<'a> {
ProfileUpdateStarted { uid: &'a String },
ProfileUpdateCompleted { uid: &'a String },
RunStateChanged { state: serde_json::Value },
PendingFailuresChanged,
}
/// Operation associated with a pending failure.
#[allow(clippy::enum_variant_names)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub enum FailedOperation {
SystemProxyEnable,
SystemProxyDisable,
SystemProxyRestore,
SystemProxyGuard,
}
impl FailedOperation {
/// User requests outrank incidental restores under the same code.
const fn outranks(self, other: Self) -> bool {
matches!(self, Self::SystemProxyEnable | Self::SystemProxyDisable) && matches!(other, Self::SystemProxyRestore)
}
/// Whether reaching `enabled` resolves this operation's failure.
const fn retired_by_system_proxy_success(self, enabled: bool) -> bool {
match self {
Self::SystemProxyEnable => enabled,
Self::SystemProxyDisable => !enabled,
Self::SystemProxyRestore => true,
// Only replacing or stopping the guard resolves its failure.
Self::SystemProxyGuard => false,
}
}
}
/// Latest unresolved failure for one stable code.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PendingFailure {
/// Stable code used as the table key.
pub code: String,
/// Full diagnostic context chain.
pub detail: String,
pub operation: FailedOperation,
/// Monotonic identity for repeated failures under the same code.
pub sequence: u64,
}
/// Non-destructive pending state, indexed by stable code.
#[derive(Debug, Default)]
struct FailureTable {
entries: Mutex<HashMap<String, PendingFailure>>,
sequence: AtomicU64,
}
impl FailureTable {
fn record(&self, operation: FailedOperation, code: &str, detail: String) {
// Sequence assignment and replacement must share one ordering lock.
let mut entries = self.entries.lock();
// Preserve an unanswered request over a later restore.
let operation = entries
.get(code)
.filter(|existing| existing.operation.outranks(operation))
.map_or(operation, |existing| existing.operation);
let failure = PendingFailure {
code: code.into(),
detail,
operation,
sequence: self.sequence.fetch_add(1, Ordering::AcqRel).wrapping_add(1),
};
entries.insert(code.into(), failure);
}
fn snapshot(&self) -> Vec<PendingFailure> {
let mut failures: Vec<PendingFailure> = self.entries.lock().values().cloned().collect();
failures.sort_by_key(|failure| failure.sequence);
failures
}
/// Return whether a guard failure was retired.
fn retire_guard(&self) -> bool {
let mut entries = self.entries.lock();
let before = entries.len();
entries.retain(|_, failure| failure.operation != FailedOperation::SystemProxyGuard);
before != entries.len()
}
/// Return whether any proxy failure was retired.
fn retire_system_proxy(&self, enabled: bool) -> bool {
let mut entries = self.entries.lock();
let before = entries.len();
entries.retain(|_, failure| !failure.operation.retired_by_system_proxy_success(enabled));
before != entries.len()
}
}
#[cfg(test)]
mod failure_table_tests {
use super::{FailedOperation, FailureTable};
#[tokio::test]
async fn a_write_nobody_asked_for_is_a_restore() {
assert_eq!(super::what_was_asked(), FailedOperation::SystemProxyRestore);
}
#[tokio::test]
async fn a_declared_intent_reaches_the_place_the_write_fails() {
let asked = super::asking_for(FailedOperation::SystemProxyEnable, async {
tokio::task::yield_now().await;
super::what_was_asked()
})
.await;
assert_eq!(asked, FailedOperation::SystemProxyEnable);
assert_eq!(super::what_was_asked(), FailedOperation::SystemProxyRestore);
}
#[test]
fn an_incidental_failure_does_not_take_over_a_request_the_user_is_still_owed() {
let table = FailureTable::default();
table.record(
FailedOperation::SystemProxyEnable,
"SYSPROXY_PRIVILEGE_REQUIRED",
"asked for it on".into(),
);
table.record(
FailedOperation::SystemProxyRestore,
"SYSPROXY_PRIVILEGE_REQUIRED",
"and a restart hit it too".into(),
);
let entries = table.snapshot();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].operation, FailedOperation::SystemProxyEnable);
assert_eq!(entries[0].detail, "and a restart hit it too");
assert!(!table.retire_system_proxy(false));
assert!(table.retire_system_proxy(true));
}
#[test]
fn a_request_still_replaces_an_incidental_failure() {
let table = FailureTable::default();
table.record(
FailedOperation::SystemProxyRestore,
"SYSPROXY_PRIVILEGE_REQUIRED",
"a".into(),
);
table.record(
FailedOperation::SystemProxyDisable,
"SYSPROXY_PRIVILEGE_REQUIRED",
"b".into(),
);
let entries = table.snapshot();
assert_eq!(entries[0].operation, FailedOperation::SystemProxyDisable);
assert!(!table.retire_system_proxy(true));
assert!(table.retire_system_proxy(false));
}
#[test]
fn two_incidental_failures_still_replace_each_other() {
let table = FailureTable::default();
table.record(
FailedOperation::SystemProxyRestore,
"SYSPROXY_PRIVILEGE_REQUIRED",
"first".into(),
);
table.record(
FailedOperation::SystemProxyRestore,
"SYSPROXY_PRIVILEGE_REQUIRED",
"second".into(),
);
let entries = table.snapshot();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].detail, "second");
}
}
tokio::task_local! {
/// User-requested operation inherited by the current task.
static ASKING_FOR: FailedOperation;
}
/// Run `operation` with the user's intent attached to whatever failure it produces.
pub async fn asking_for<T>(operation: FailedOperation, work: impl Future<Output = T>) -> T {
ASKING_FOR.scope(operation, work).await
}
/// What the caller asked for, or a restore when nobody asked.
pub fn what_was_asked() -> FailedOperation {
ASKING_FOR
.try_with(|operation| *operation)
.unwrap_or(FailedOperation::SystemProxyRestore)
}
static PENDING_FAILURES: Lazy<FailureTable> = Lazy::new(FailureTable::default);
pub fn record_failure(operation: FailedOperation, code: &str, detail: impl Into<String>) {
PENDING_FAILURES.record(operation, code, detail.into());
notify_pending_failures_changed();
}
pub fn has_pending_failure(code: &str) -> bool {
PENDING_FAILURES.entries.lock().contains_key(code)
}
/// Return unresolved failures oldest first without clearing them.
pub fn pending_failures() -> Vec<PendingFailure> {
PENDING_FAILURES.snapshot()
}
/// Retire guard failures after replacement or shutdown.
pub fn retire_guard_failures() {
if PENDING_FAILURES.retire_guard() {
notify_pending_failures_changed();
}
}
/// Retire failures resolved by the resulting proxy state.
pub fn retire_system_proxy_failures(enabled: bool) {
if PENDING_FAILURES.retire_system_proxy(enabled) {
notify_pending_failures_changed();
}
}
/// Nudge the window to reread pending state; the event carries no payload.
fn notify_pending_failures_changed() {
let Some(app_handle) = crate::APP_HANDLE.get() else {
return;
};
NotificationSystem::send_event(app_handle.clone(), FrontendEvent::PendingFailuresChanged);
}
#[derive(Debug)]
@@ -42,6 +278,7 @@ impl NotificationSystem {
FrontendEvent::ProfileUpdateStarted { uid } => ("profile-update-started", Ok(json!({ "uid": uid }))),
FrontendEvent::ProfileUpdateCompleted { uid } => ("profile-update-completed", Ok(json!({ "uid": uid }))),
FrontendEvent::RunStateChanged { state } => ("verge://run-state-changed", Ok(state)),
FrontendEvent::PendingFailuresChanged => ("verge://pending-failures-changed", Ok(serde_json::Value::Null)),
}
}
@@ -61,3 +298,151 @@ impl NotificationSystem {
}
}
}
#[cfg(test)]
mod tests {
use super::{FailedOperation, FailureTable};
fn table() -> FailureTable {
FailureTable::default()
}
#[test]
fn reading_does_not_clear() {
let table = table();
table.record(
FailedOperation::SystemProxyRestore,
"SYSPROXY_PRIVILEGE_REQUIRED",
"refused".into(),
);
assert_eq!(table.snapshot(), table.snapshot());
assert_eq!(table.snapshot().len(), 1);
}
#[test]
fn a_code_keeps_only_its_most_recent_failure() {
let table = table();
table.record(
FailedOperation::SystemProxyRestore,
"SYSPROXY_PRIVILEGE_REQUIRED",
"first".into(),
);
table.record(
FailedOperation::SystemProxyRestore,
"SYSPROXY_PRIVILEGE_REQUIRED",
"second".into(),
);
let failures = table.snapshot();
assert_eq!(failures.len(), 1);
assert_eq!(failures[0].detail, "second");
}
#[test]
fn a_new_failure_under_a_known_code_gets_a_new_sequence() {
let table = table();
table.record(
FailedOperation::SystemProxyRestore,
"SYSPROXY_DIRECT_FALLBACK",
"a".into(),
);
let first = table.snapshot()[0].sequence;
table.record(
FailedOperation::SystemProxyRestore,
"SYSPROXY_DIRECT_FALLBACK",
"b".into(),
);
assert!(table.snapshot()[0].sequence > first);
}
#[test]
fn failures_are_reported_oldest_first() {
let table = table();
table.record(FailedOperation::SystemProxyRestore, "FIRST", "a".into());
table.record(FailedOperation::SystemProxyRestore, "SECOND", "b".into());
let failures = table.snapshot();
let codes: Vec<&str> = failures.iter().map(|failure| failure.code.as_str()).collect();
assert_eq!(codes, ["FIRST", "SECOND"]);
}
#[test]
fn a_working_proxy_retires_what_it_disproves() {
let table = table();
table.record(
FailedOperation::SystemProxyRestore,
"SYSPROXY_PRIVILEGE_REQUIRED",
"refused".into(),
);
assert!(table.retire_system_proxy(false));
assert!(table.snapshot().is_empty());
}
#[test]
fn a_users_request_is_retired_only_by_reaching_the_state_they_asked_for() {
let table = table();
table.record(
FailedOperation::SystemProxyDisable,
"SYSPROXY_PRIVILEGE_REQUIRED",
"refused".into(),
);
assert!(!table.retire_system_proxy(true));
assert_eq!(table.snapshot().len(), 1);
assert!(table.retire_system_proxy(false));
assert!(table.snapshot().is_empty());
}
#[test]
fn a_failed_enable_is_retired_by_the_proxy_coming_on() {
let table = table();
table.record(
FailedOperation::SystemProxyEnable,
"SYSPROXY_DIRECT_FALLBACK",
"fell back".into(),
);
assert!(!table.retire_system_proxy(false));
assert!(table.retire_system_proxy(true));
assert!(table.snapshot().is_empty());
}
#[test]
fn a_stopped_guard_is_retired_by_a_guard_starting_again_and_not_by_an_apply() {
let table = table();
table.record(
FailedOperation::SystemProxyGuard,
"SYSPROXY_GUARD_STOPPED",
"gave up".into(),
);
assert!(!table.retire_system_proxy(true));
assert!(!table.retire_system_proxy(false));
assert_eq!(table.snapshot().len(), 1);
assert!(table.retire_guard());
assert!(table.snapshot().is_empty());
}
#[test]
fn retiring_a_guard_leaves_everything_else_alone() {
let table = table();
table.record(
FailedOperation::SystemProxyEnable,
"SYSPROXY_PRIVILEGE_REQUIRED",
"refused".into(),
);
assert!(!table.retire_guard());
assert_eq!(table.snapshot().len(), 1);
}
#[test]
fn retiring_nothing_reports_nothing() {
assert!(!table().retire_system_proxy(true));
}
}
+777 -31
View File
@@ -1,10 +1,21 @@
use crate::{
config::{Config, IVerge, MixedPort},
core::{CoreManager, manager::RunningMode, service, sysopt::Sysopt},
core::{
CoreManager,
manager::RunningMode,
notification::{self, FailedOperation},
runstate::RUN_STATE,
service,
sysopt::Sysopt,
},
process::AsyncHandler,
utils::server,
utils::{
notification::{NotificationEvent, needs_system_notification, notify_event},
server,
window_manager::WindowManager,
},
};
use anyhow::{Result, bail, ensure};
use anyhow::{Result, ensure};
use clash_verge_logging::{Type, logging};
use clash_verge_service_ipc::{MacosProxyConfig, OwnerSessionProof, ProxyApplyOutcome};
use std::{
@@ -12,7 +23,146 @@ use std::{
sync::atomic::{AtomicU64, Ordering},
time::Duration,
};
use tokio::sync::Mutex;
use tokio::{sync::Mutex, time::timeout};
/// Actionable system-proxy failure attached to an `anyhow` chain.
///
/// Classification remains downcastable while the original error stays available for diagnostics.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SysproxyFailure {
/// A local `networksetup` write lacked privileges.
PrivilegeRequired,
/// The service failed to apply the proxy and attempted direct fallback.
DirectFallback { detail: String },
/// A ready service can replace the refused sidecar write.
SidecarWhileServiceReady,
/// The proxy guard stopped after repeated failures.
GuardStopped { detail: String },
}
impl SysproxyFailure {
/// Stable frontend error code.
#[inline]
pub const fn code(&self) -> &'static str {
match self {
Self::PrivilegeRequired => "SYSPROXY_PRIVILEGE_REQUIRED",
Self::DirectFallback { .. } => "SYSPROXY_DIRECT_FALLBACK",
Self::SidecarWhileServiceReady => "SYSPROXY_SIDECAR_WHILE_SERVICE_READY",
Self::GuardStopped { .. } => "SYSPROXY_GUARD_STOPPED",
}
}
/// Find a classification using `anyhow`'s context-aware downcast.
#[inline]
pub fn from_chain(error: &anyhow::Error) -> Option<&Self> {
error.downcast_ref::<Self>()
}
}
impl std::fmt::Display for SysproxyFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::PrivilegeRequired => f.write_str("system proxy write requires elevated privileges"),
Self::DirectFallback { detail } => {
write!(f, "system proxy failed and fell back to direct: {detail}")
}
Self::SidecarWhileServiceReady => {
f.write_str("system proxy write was refused locally while the service was ready")
}
Self::GuardStopped { detail } => {
write!(f, "system proxy guard stopped after repeated failures: {detail}")
}
}
}
}
impl std::error::Error for SysproxyFailure {}
/// Marker for a partially applied system proxy write.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SystemProxyStateUnknown;
impl SystemProxyStateUnknown {
/// Whether this marker appears in the error chain.
#[inline]
pub fn is_in(error: &anyhow::Error) -> bool {
error.downcast_ref::<Self>().is_some()
}
}
impl std::fmt::Display for SystemProxyStateUnknown {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("the system proxy was left partly applied; turning it off was attempted")
}
}
impl std::error::Error for SystemProxyStateUnknown {}
/// Whether the error carries a frontend-mappable classification.
#[inline]
fn carries_a_mappable_code(error: &anyhow::Error) -> bool {
SysproxyFailure::from_chain(error).is_some()
}
/// Combine a cause with rollback failure without hiding actionable classifications.
pub fn rollback_failure(caused_by: anyhow::Error, rollback: anyhow::Error) -> anyhow::Error {
let state_unknown = SystemProxyStateUnknown::is_in(&caused_by) || SystemProxyStateUnknown::is_in(&rollback);
let combined = if carries_a_mappable_code(&caused_by) || !carries_a_mappable_code(&rollback) {
caused_by.context(format!("the rollback that followed also failed: {rollback:#}"))
} else {
rollback.context(format!("this rollback followed an earlier failure: {caused_by:#}"))
};
if state_unknown && !SystemProxyStateUnknown::is_in(&combined) {
return combined.context(SystemProxyStateUnknown);
}
combined
}
/// Classify local macOS proxy failures without replacing their source chain.
fn classify_local_failure(error: anyhow::Error) -> anyhow::Error {
if !was_refused_locally(&error) {
return error;
}
error.context(SysproxyFailure::PrivilegeRequired)
}
/// Whether macOS refused a local proxy write.
fn was_refused_locally(error: &anyhow::Error) -> bool {
cfg!(target_os = "macos")
&& error.chain().any(|cause| {
matches!(
cause.downcast_ref::<sysproxy::Error>(),
Some(sysproxy::Error::RequiresAdminPrivileges)
)
})
}
/// Distinguish a misplaced sidecar from a missing privileged service.
async fn classify_local_apply_failure(error: anyhow::Error) -> anyhow::Error {
// Preserve any existing, more specific classification.
if SysproxyFailure::from_chain(&error).is_some() || !was_refused_locally(&error) {
return error;
}
let probed = timeout(SERVICE_PROBE_TIMEOUT, RUN_STATE.probe())
.await
.ok()
.and_then(|probe| probe.ok())
.map(|fresh| fresh.service_usable());
error.context(refusal_classification(probed))
}
/// Bound the diagnostic service probe.
const SERVICE_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
/// Classify a refusal from a fresh service probe only.
const fn refusal_classification(service_usable: Option<bool>) -> SysproxyFailure {
match service_usable {
Some(true) => SysproxyFailure::SidecarWhileServiceReady,
Some(false) | None => SysproxyFailure::PrivilegeRequired,
}
}
const LOOPBACK_HOST: &str = "127.0.0.1";
const MACOS_DEFAULT_BYPASS: &str =
@@ -56,20 +206,33 @@ impl ServiceProxyOperations {
self.run_service_operation(operation).await
}
async fn cancel_and_drain<F, Fut>(&self, cancel: F) -> u64
/// Invalidate the guard generation and wait for cancellation.
async fn cancel_and_drain<F, Fut, T>(&self, cancel: F) -> (u64, T)
where
F: FnOnce() -> Fut,
Fut: Future<Output = ()>,
Fut: Future<Output = T>,
{
let generation = self.invalidate_guard();
self.run_service_operation(cancel).await;
generation
let cancelled = self.run_service_operation(cancel).await;
(generation, cancelled)
}
/// Record under the operation lock only for the current guard generation.
async fn record_if_current<Record>(&self, captured_generation: u64, record: Record) -> bool
where
Record: FnOnce() -> bool,
{
self.run_service_operation(|| async move {
guard_generation_is_current(&self.guard_generation, captured_generation) && record()
})
.await
}
async fn run_guard_request<Mode, ActiveProof, Request, RequestFuture>(
&self,
captured_generation: u64,
captured_proof: &OwnerSessionProof,
requested: &MacosProxyConfig,
is_service_mode: Mode,
active_proof: ActiveProof,
request: Request,
@@ -87,7 +250,7 @@ impl ServiceProxyOperations {
{
return Ok(false);
}
service_apply_result(request().await?)?;
service_apply_result(requested, request().await?)?;
Ok(true)
})
.await
@@ -158,10 +321,14 @@ fn service_proxy_config(verge: &IVerge, mixed_port: u16, pac_port: u16) -> Resul
}
}
fn service_apply_result(outcome: ProxyApplyOutcome) -> Result<()> {
fn service_apply_result(requested: &MacosProxyConfig, outcome: ProxyApplyOutcome) -> Result<()> {
match outcome {
ProxyApplyOutcome::Applied | ProxyApplyOutcome::NotRequested => Ok(()),
ProxyApplyOutcome::DirectFallback { message } => bail!(message),
// Direct fallback satisfies a disable request.
ProxyApplyOutcome::DirectFallback { .. } if matches!(requested, MacosProxyConfig::Disabled) => Ok(()),
ProxyApplyOutcome::DirectFallback { message } => {
Err(SysproxyFailure::DirectFallback { detail: message }.into())
}
}
}
@@ -177,30 +344,140 @@ async fn current_service_proxy_config(verge: &IVerge) -> Result<MacosProxyConfig
service_proxy_config(verge, mixed_port, 0)
}
pub async fn apply() -> Result<()> {
let running_mode = CoreManager::global().get_running_mode();
match proxy_backend_route(cfg!(target_os = "macos"), &running_mode) {
ProxyBackendRoute::Local => Sysopt::global().update_sysproxy().await,
ProxyBackendRoute::Service => {
let verge = Config::verge().await.latest_arc();
let proxy = current_service_proxy_config(&verge).await?;
SERVICE_PROXY_OPERATIONS
.run_final_service_operation(|| async {
service_apply_result(service::set_system_proxy_by_service(&proxy).await?)
})
.await
/// Return whether a classified failure remains pending.
pub fn is_reportable(error: &anyhow::Error) -> bool {
is_reportable_given(error, notification::has_pending_failure)
}
fn is_reportable_given(error: &anyhow::Error, still_pending: impl FnOnce(&str) -> bool) -> bool {
SysproxyFailure::from_chain(error).is_some_and(|failure| still_pending(failure.code()))
}
pub fn report_failure(operation: FailedOperation, error: &anyhow::Error) -> bool {
let Some(failure) = SysproxyFailure::from_chain(error) else {
return false;
};
notification::record_failure(operation, failure.code(), format!("{error:#}"));
true
}
/// Drain any local guard before writing through the service.
async fn apply_through_service<Drain, DrainFuture, Write, WriteFuture>(
drain_local_guard: Drain,
write: Write,
) -> Result<()>
where
Drain: FnOnce() -> DrainFuture,
DrainFuture: Future<Output = bool>,
Write: FnOnce() -> WriteFuture,
WriteFuture: Future<Output = Result<()>>,
{
ensure!(
drain_local_guard().await,
"the system proxy guard did not stop in time; not writing the system proxy through the service"
);
write().await
}
#[derive(Debug)]
enum TableEffect<'a> {
Retire { enabled: bool },
File(FailedOperation, &'a anyhow::Error),
Nothing,
}
/// A successful clear does not resolve a failed request to enable the proxy.
fn clear_table_effect(result: &Result<()>) -> TableEffect<'_> {
match result {
Err(error) if SysproxyFailure::from_chain(error).is_some() => {
TableEffect::File(notification::what_was_asked(), error)
}
Ok(()) | Err(_) => TableEffect::Nothing,
}
}
fn settle_table<Retire, File>(effect: TableEffect<'_>, retire: Retire, file: File)
where
Retire: FnOnce(bool),
File: FnOnce(FailedOperation, &anyhow::Error),
{
match effect {
TableEffect::Retire { enabled } => retire(enabled),
TableEffect::File(operation, error) => file(operation, error),
TableEffect::Nothing => {}
}
}
fn table_effect(result: &Result<()>, enabled: bool) -> TableEffect<'_> {
match result {
Ok(()) => TableEffect::Retire { enabled },
Err(error) if SysproxyFailure::from_chain(error).is_some() => {
TableEffect::File(notification::what_was_asked(), error)
}
Err(_) => TableEffect::Nothing,
}
}
pub async fn apply() -> Result<()> {
let running_mode = CoreManager::global().get_running_mode();
// Use the pre-apply snapshot when retiring matching failures.
let verge = Config::verge().await.latest_arc();
let enabled = verge.enable_system_proxy.unwrap_or_default();
let result = match proxy_backend_route(cfg!(target_os = "macos"), &running_mode) {
ProxyBackendRoute::Local => match Sysopt::global().update_sysproxy().await {
Ok(()) => Ok(()),
Err(error) => Err(classify_local_apply_failure(error).await),
},
ProxyBackendRoute::Service => {
let proxy = current_service_proxy_config(&verge).await?;
apply_through_service(
|| Sysopt::global().stop_proxy_guard(),
|| async {
SERVICE_PROXY_OPERATIONS
.run_final_service_operation(|| async {
service_apply_result(&proxy, service::set_system_proxy_by_service(&proxy).await?)
})
.await
},
)
.await
}
};
// Settle failures where every proxy-apply path converges.
settle_table(
table_effect(&result, enabled),
notification::retire_system_proxy_failures,
|operation, error| {
report_failure(operation, error);
},
);
result
}
pub async fn clear() -> Result<()> {
let result = clear_inner().await;
// Clear failures must be filed even when a restart aborts before apply.
settle_table(
clear_table_effect(&result),
notification::retire_system_proxy_failures,
|operation, error| {
report_failure(operation, error);
},
);
result
}
async fn clear_inner() -> Result<()> {
let running_mode = CoreManager::global().get_running_mode();
match proxy_backend_route(cfg!(target_os = "macos"), &running_mode) {
ProxyBackendRoute::Local => Sysopt::global().reset_sysproxy().await,
ProxyBackendRoute::Local => Sysopt::global().reset_sysproxy().await.map_err(classify_local_failure),
ProxyBackendRoute::Service => {
SERVICE_PROXY_OPERATIONS
.run_final_service_operation(|| async {
service_apply_result(service::set_system_proxy_by_service(&MacosProxyConfig::Disabled).await?)
service_apply_result(
&MacosProxyConfig::Disabled,
service::set_system_proxy_by_service(&MacosProxyConfig::Disabled).await?,
)
})
.await
}
@@ -208,7 +485,7 @@ pub async fn clear() -> Result<()> {
}
pub async fn refresh_guard() -> Result<()> {
let generation = SERVICE_PROXY_OPERATIONS
let (generation, _drained) = SERVICE_PROXY_OPERATIONS
.cancel_and_drain(|| Sysopt::global().stop_proxy_guard())
.await;
let running_mode = CoreManager::global().get_running_mode();
@@ -216,12 +493,16 @@ pub async fn refresh_guard() -> Result<()> {
proxy_backend_route(cfg!(target_os = "macos"), &running_mode),
ProxyBackendRoute::Local
) {
Sysopt::global().refresh_guard().await;
// Retire the warning only after a guard is enforcing.
if Sysopt::global().refresh_guard().await {
notification::retire_guard_failures();
}
return Ok(());
}
let verge = Config::verge().await.latest_arc();
if !verge.enable_system_proxy.unwrap_or_default() || !verge.enable_proxy_guard.unwrap_or_default() {
notification::retire_guard_failures();
return Ok(());
}
@@ -229,31 +510,71 @@ pub async fn refresh_guard() -> Result<()> {
let proof = service::active_service_session()?;
let interval = Duration::from_secs(verge.proxy_guard_duration.unwrap_or(30).max(1));
AsyncHandler::spawn(move || async move {
let mut consecutive_failures = 0u32;
loop {
tokio::time::sleep(interval).await;
match SERVICE_PROXY_OPERATIONS
.run_guard_request(
generation,
&proof,
&proxy,
|| matches!(*CoreManager::global().get_running_mode(), RunningMode::Service),
service::active_service_session,
|| service::set_system_proxy_by_service_with_session(&proxy, &proof),
)
.await
{
Ok(true) => {}
Ok(true) => consecutive_failures = 0,
Ok(false) => break,
Err(_) => logging!(warn, Type::Core, "failed to refresh system proxy through Service"),
Err(error) => {
consecutive_failures += 1;
logging!(
warn,
Type::Core,
"failed to refresh system proxy through Service ({}/{}): {:#}",
consecutive_failures,
GUARD_FAILURES_BEFORE_STOPPING,
error
);
if consecutive_failures >= GUARD_FAILURES_BEFORE_STOPPING {
report_guard_stopped(generation, &error).await;
break;
}
}
}
}
});
// Retire only after the replacement guard starts successfully.
notification::retire_guard_failures();
Ok(())
}
pub async fn stop_guard() {
/// Consecutive failures allowed before the guard stops.
const GUARD_FAILURES_BEFORE_STOPPING: u32 = 3;
/// Report that the current guard stopped.
async fn report_guard_stopped(captured_generation: u64, error: &anyhow::Error) {
let stopped = anyhow::Error::new(SysproxyFailure::GuardStopped {
detail: format!("{error:#}"),
});
// Do not publish a late failure from a replaced guard.
let recorded = SERVICE_PROXY_OPERATIONS
.record_if_current(captured_generation, || {
report_failure(FailedOperation::SystemProxyGuard, &stopped)
})
.await;
if recorded && needs_system_notification(WindowManager::get_main_window_state()) {
notify_event(NotificationEvent::SystemProxyGuardStopped).await;
}
}
/// Stop the local guard and report whether it drained.
pub async fn stop_guard() -> bool {
SERVICE_PROXY_OPERATIONS
.cancel_and_drain(|| Sysopt::global().stop_proxy_guard())
.await;
.await
.1
}
#[cfg(test)]
@@ -272,6 +593,15 @@ mod tests {
use std::task::Poll;
use tokio::sync::Barrier;
/// Proxy state maintained by a running guard.
fn guarded_proxy() -> MacosProxyConfig {
MacosProxyConfig::Global {
host: "127.0.0.1".to_owned(),
port: 7897,
bypass: "localhost".to_owned(),
}
}
fn proof(generation: u64, token: &str) -> OwnerSessionProof {
OwnerSessionProof {
generation,
@@ -327,6 +657,7 @@ mod tests {
.run_guard_request(
generation,
&captured_proof,
&guarded_proxy(),
|| true,
|| Ok(active_proof.lock().clone()),
|| async {
@@ -379,9 +710,11 @@ mod tests {
blocker_started.wait().await;
let rpc_called = AtomicBool::new(false);
let guarded = guarded_proxy();
let mut guard_request = Box::pin(operations.run_guard_request(
generation,
&captured_proof,
&guarded,
|| true,
|| Ok(captured_proof.clone()),
|| async {
@@ -424,9 +757,11 @@ mod tests {
};
blocker_started.wait().await;
let guarded = guarded_proxy();
let mut guard_request = Box::pin(operations.run_guard_request(
generation,
&captured_proof,
&guarded,
|| true,
|| Ok(active_proof.lock().clone()),
|| async {
@@ -453,6 +788,7 @@ mod tests {
.run_guard_request(
generation,
&captured_proof,
&guarded_proxy(),
|| true,
|| Ok(captured_proof.clone()),
|| async {
@@ -466,6 +802,97 @@ mod tests {
assert!(refreshed.is_err());
}
#[tokio::test]
async fn a_replaced_guard_does_not_get_to_report_its_last_failure() {
let operations = ServiceProxyOperations::new();
let generation = operations.invalidate_guard();
operations.invalidate_guard();
assert!(!operations.record_if_current(generation, || true).await);
}
#[tokio::test]
async fn a_report_waiting_on_the_lock_says_nothing_if_it_is_invalidated_meanwhile() {
// The generation check must happen after acquiring the operation lock.
let operations = Arc::new(ServiceProxyOperations::new());
let generation = operations.invalidate_guard();
let blocker_started = Arc::new(Barrier::new(2));
let release_blocker = Arc::new(Barrier::new(2));
let blocker = {
let operations = Arc::clone(&operations);
let blocker_started = Arc::clone(&blocker_started);
let release_blocker = Arc::clone(&release_blocker);
tokio::spawn(async move {
operations
.run_service_operation(|| async {
blocker_started.wait().await;
release_blocker.wait().await;
})
.await
})
};
blocker_started.wait().await;
let recorded = AtomicBool::new(false);
let mut report = Box::pin(operations.record_if_current(generation, || {
recorded.store(true, Ordering::Release);
true
}));
assert!(matches!(futures::poll!(report.as_mut()), Poll::Pending));
operations.invalidate_guard();
release_blocker.wait().await;
assert!(blocker.await.is_ok());
assert!(!report.await);
assert!(!recorded.load(Ordering::Acquire));
}
#[tokio::test]
async fn a_current_guard_still_reports() {
let operations = ServiceProxyOperations::new();
let generation = operations.invalidate_guard();
assert!(operations.record_if_current(generation, || true).await);
}
async fn record_service_apply(drained: bool, write_succeeds: bool) -> (Vec<&'static str>, bool) {
let calls = Mutex::new(Vec::new());
let result = super::apply_through_service(
|| {
calls.lock().push("drain");
async move { drained }
},
|| {
calls.lock().push("write");
async move {
if write_succeeds {
Ok(())
} else {
anyhow::bail!("the helper refused")
}
}
},
)
.await;
(calls.into_inner(), result.is_ok())
}
#[tokio::test]
async fn the_service_writes_only_once_the_local_guard_is_gone() {
assert_eq!(record_service_apply(true, true).await, (vec!["drain", "write"], true));
}
#[tokio::test]
async fn a_write_the_service_refused_is_still_a_failure() {
assert_eq!(record_service_apply(true, false).await, (vec!["drain", "write"], false));
}
#[tokio::test]
async fn a_local_guard_that_would_not_stop_keeps_root_from_writing() {
assert_eq!(record_service_apply(false, true).await, (vec!["drain"], false));
}
#[test]
fn service_proxy_config_forces_loopback_targets_and_bounds_bypass() {
let verge = IVerge {
@@ -498,4 +925,323 @@ mod tests {
}
);
}
use super::{FailedOperation, SysproxyFailure, classify_local_failure};
fn wrapped_privilege_failure() -> anyhow::Error {
anyhow::Error::new(sysproxy::Error::RequiresAdminPrivileges).context("failed to apply the system proxy")
}
fn classified_privilege_failure() -> anyhow::Error {
wrapped_privilege_failure().context(SysproxyFailure::PrivilegeRequired)
}
#[test]
fn a_classification_survives_the_anyhow_layers_above_it() {
let classified = classified_privilege_failure()
.context("failed to apply system proxy after start")
.context("failed to restart the core");
assert_eq!(
SysproxyFailure::from_chain(&classified).map(SysproxyFailure::code),
Some("SYSPROXY_PRIVILEGE_REQUIRED")
);
}
#[test]
fn classifying_keeps_the_original_failure_underneath() {
let classified = classified_privilege_failure();
assert!(
classified.chain().any(|cause| matches!(
cause.downcast_ref::<sysproxy::Error>(),
Some(sysproxy::Error::RequiresAdminPrivileges)
)),
"the original error must survive classification: {classified:#}"
);
assert!(format!("{classified:#}").contains("failed to apply the system proxy"));
}
#[test]
fn an_unrelated_failure_is_left_alone() {
let untouched = classify_local_failure(anyhow::anyhow!("port already in use"));
assert!(SysproxyFailure::from_chain(&untouched).is_none());
assert_eq!(format!("{untouched:#}"), "port already in use");
}
#[cfg(target_os = "macos")]
#[test]
fn a_refusal_wrapped_in_write_progress_is_still_classified() {
let wrapped = anyhow::Error::new(sysproxy::Error::ProxyWrite {
progress: sysproxy::WriteProgress::new(0, 7),
source: Box::new(sysproxy::Error::RequiresAdminPrivileges),
})
.context("failed to apply the system proxy");
let classified = classify_local_failure(wrapped);
assert!(matches!(
SysproxyFailure::from_chain(&classified),
Some(SysproxyFailure::PrivilegeRequired)
));
}
#[cfg(target_os = "macos")]
#[test]
fn a_refused_write_is_classified_on_macos() {
let classified = classify_local_failure(wrapped_privilege_failure());
assert!(matches!(
SysproxyFailure::from_chain(&classified),
Some(SysproxyFailure::PrivilegeRequired)
));
assert!(
classified.chain().any(|cause| matches!(
cause.downcast_ref::<sysproxy::Error>(),
Some(sysproxy::Error::RequiresAdminPrivileges)
)),
"classifying must keep the original failure reachable: {classified:#}"
);
assert!(format!("{classified:#}").contains("failed to apply the system proxy"));
}
#[cfg(not(target_os = "macos"))]
#[test]
fn nothing_is_classified_off_macos() {
let classified = classify_local_failure(wrapped_privilege_failure());
assert!(SysproxyFailure::from_chain(&classified).is_none());
}
#[test]
fn only_a_classified_failure_is_worth_recording() {
let before = crate::core::notification::pending_failures().len();
assert!(!super::report_failure(
FailedOperation::SystemProxyRestore,
&anyhow::anyhow!("port already in use"),
));
assert_eq!(crate::core::notification::pending_failures().len(), before);
assert!(super::report_failure(
FailedOperation::SystemProxyRestore,
&classified_privilege_failure().context("failed to apply system proxy after start"),
));
let recorded = crate::core::notification::pending_failures();
let failure = recorded
.iter()
.find(|failure| failure.code == "SYSPROXY_PRIVILEGE_REQUIRED")
.unwrap_or_else(|| unreachable!("a classified failure must be recorded"));
assert!(
failure.detail.contains("failed to apply system proxy after start"),
"{failure:?}"
);
assert!(failure.detail.contains("admin privileges"), "{failure:?}");
}
#[test]
fn a_refusal_only_blames_the_sidecar_when_a_probe_actually_answered() {
use super::refusal_classification;
assert_eq!(
refusal_classification(Some(true)),
SysproxyFailure::SidecarWhileServiceReady
);
assert_eq!(refusal_classification(Some(false)), SysproxyFailure::PrivilegeRequired);
assert_eq!(refusal_classification(None), SysproxyFailure::PrivilegeRequired);
}
use super::{SystemProxyStateUnknown, is_reportable_given, rollback_failure};
fn mappable() -> anyhow::Error {
anyhow::anyhow!("networksetup refused").context(SysproxyFailure::PrivilegeRequired)
}
fn record_settlement(effect: super::TableEffect<'_>) -> Vec<String> {
let events = Mutex::new(Vec::new());
super::settle_table(
effect,
|enabled| events.lock().push(format!("retire:{enabled}")),
|operation, _| events.lock().push(format!("file:{operation:?}")),
);
events.into_inner()
}
#[test]
fn each_effect_reaches_the_table_it_names() {
assert_eq!(
record_settlement(super::TableEffect::Retire { enabled: true }),
vec!["retire:true"]
);
assert_eq!(
record_settlement(super::TableEffect::File(
FailedOperation::SystemProxyRestore,
&mappable()
)),
vec!["file:SystemProxyRestore"]
);
assert!(record_settlement(super::TableEffect::Nothing).is_empty());
}
#[test]
fn a_clear_files_its_failure_and_says_nothing_when_it_works() {
assert!(matches!(
super::clear_table_effect(&Err(mappable())),
super::TableEffect::File(FailedOperation::SystemProxyRestore, _)
));
assert!(matches!(
super::clear_table_effect(&Ok(())),
super::TableEffect::Nothing
));
assert!(matches!(
super::clear_table_effect(&Err(anyhow::anyhow!("the core was already gone"))),
super::TableEffect::Nothing
));
}
#[test]
fn a_clean_apply_retires_in_the_direction_it_reached() {
assert!(matches!(
super::table_effect(&Ok(()), true),
super::TableEffect::Retire { enabled: true }
));
assert!(matches!(
super::table_effect(&Ok(()), false),
super::TableEffect::Retire { enabled: false }
));
}
#[test]
fn a_failure_that_can_be_explained_is_filed_here_as_a_restore() {
assert!(matches!(
super::table_effect(&Err(mappable()), true),
super::TableEffect::File(FailedOperation::SystemProxyRestore, _)
));
}
#[tokio::test]
async fn a_failure_is_filed_as_whatever_the_caller_declared() {
let (applied, cleared) = super::notification::asking_for(FailedOperation::SystemProxyEnable, async {
let (apply_error, clear_error) = (mappable(), mappable());
(
format!("{:?}", super::table_effect(&Err(apply_error), true)),
format!("{:?}", super::clear_table_effect(&Err(clear_error))),
)
})
.await;
assert!(applied.contains("File(SystemProxyEnable"), "{applied}");
assert!(cleared.contains("File(SystemProxyEnable"), "{cleared}");
}
#[test]
fn telling_the_user_to_look_needs_something_there_to_find() {
assert!(is_reportable_given(&mappable(), |_| true));
assert!(!is_reportable_given(&mappable(), |_| false));
assert!(!is_reportable_given(&anyhow::anyhow!("the core exited"), |_| true));
}
#[test]
fn a_failure_with_nothing_to_say_is_not_filed() {
assert!(matches!(
super::table_effect(&Err(anyhow::anyhow!("the core exited immediately")), true),
super::TableEffect::Nothing
));
}
#[test]
fn a_rollback_that_can_be_explained_becomes_the_source() {
let combined = rollback_failure(anyhow::anyhow!("could not write the file"), mappable());
assert!(SysproxyFailure::from_chain(&combined).is_some());
assert!(format!("{combined:#}").contains("could not write the file"));
}
#[test]
fn the_causing_error_stays_the_source_when_it_is_the_one_that_can_be_explained() {
let combined = rollback_failure(mappable(), anyhow::anyhow!("could not restore the file"));
assert!(matches!(
SysproxyFailure::from_chain(&combined),
Some(SysproxyFailure::PrivilegeRequired)
));
assert!(format!("{combined:#}").contains("could not restore the file"));
}
#[test]
fn a_state_marker_does_not_outrank_a_code_but_is_not_lost_to_it() {
let combined = rollback_failure(
anyhow::anyhow!("half applied").context(SystemProxyStateUnknown),
mappable(),
);
assert!(SysproxyFailure::from_chain(&combined).is_some());
assert!(SystemProxyStateUnknown::is_in(&combined));
}
#[test]
fn a_state_marker_on_the_source_is_left_where_it_is() {
let combined = rollback_failure(
mappable().context(SystemProxyStateUnknown),
anyhow::anyhow!("could not restore the file"),
);
assert!(SystemProxyStateUnknown::is_in(&combined));
assert!(SysproxyFailure::from_chain(&combined).is_some());
}
#[test]
fn with_both_mappable_the_causing_error_wins() {
let combined = rollback_failure(
mappable(),
anyhow::anyhow!("the service could not set it").context(SysproxyFailure::DirectFallback {
detail: "fell back to direct".to_owned(),
}),
);
assert!(matches!(
SysproxyFailure::from_chain(&combined),
Some(SysproxyFailure::PrivilegeRequired)
));
assert!(format!("{combined:#}").contains("fell back to direct"));
}
#[test]
fn with_neither_mappable_the_causing_error_is_kept() {
let combined = rollback_failure(anyhow::anyhow!("persisting failed"), anyhow::anyhow!("rollback failed"));
assert!(SysproxyFailure::from_chain(&combined).is_none());
assert_eq!(
format!("{combined:#}"),
"the rollback that followed also failed: rollback failed: persisting failed"
);
}
#[test]
fn a_disable_that_ended_up_direct_is_what_was_asked_for() {
let outcome = ProxyApplyOutcome::DirectFallback {
message: "first attempt failed".to_owned(),
};
assert!(super::service_apply_result(&MacosProxyConfig::Disabled, outcome).is_ok());
}
#[test]
fn a_direct_fallback_reports_that_traffic_may_be_going_direct() {
let outcome = ProxyApplyOutcome::DirectFallback {
message: "service could not set the proxy".to_owned(),
};
let error = super::service_apply_result(&guarded_proxy(), outcome)
.err()
.unwrap_or_else(|| anyhow::anyhow!("a fallback outcome must be reported as a failure"));
assert_eq!(
SysproxyFailure::from_chain(&error).map(SysproxyFailure::code),
Some("SYSPROXY_DIRECT_FALLBACK")
);
assert!(format!("{error:#}").contains("service could not set the proxy"));
}
}
+793 -33
View File
@@ -1,5 +1,6 @@
use crate::{
config::{Config, MixedPort},
core::proxy_control::SystemProxyStateUnknown,
singleton,
utils::server,
};
@@ -34,6 +35,191 @@ const fn proxy_apply_steps(sys_enabled: bool, auto_enabled: bool) -> [ProxyApply
}
}
/// Maximum guard drain time before OS state is unknown.
const GUARD_DRAIN_TIMEOUT: Duration = Duration::from_secs(10);
/// Authoritative OS state after a failed proxy write.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AuthoritativeState {
/// No write is known to have landed.
Unchanged,
/// A partial write left no known target.
Unknown,
}
/// Choose state from reliable evidence that a write landed.
fn authoritative_state(error: &anyhow::Error, earlier_step_completed: bool) -> AuthoritativeState {
// Linux setter success is not reliable write evidence.
authoritative_state_from(!cfg!(target_os = "linux"), error, earlier_step_completed)
}
/// Testable form with explicit write-evidence reliability.
fn authoritative_state_from(
write_evidence_is_reliable: bool,
error: &anyhow::Error,
earlier_step_completed: bool,
) -> AuthoritativeState {
if !write_evidence_is_reliable {
return AuthoritativeState::Unchanged;
}
// A completed earlier setter is evidence outside the current error.
if earlier_step_completed {
return AuthoritativeState::Unknown;
}
match error.downcast_ref::<sysproxy::Error>() {
Some(sysproxy::Error::ProxyWrite { progress, .. }) if !progress.nothing_written() => {
AuthoritativeState::Unknown
}
_ => AuthoritativeState::Unchanged,
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum OsProxyState {
AlreadyApplied,
DifferentOrUnknown,
}
#[cfg(any(target_os = "macos", test))]
fn target_is_already_in_place(snapshot: &sysproxy::ProxySnapshot, sys: &Sysproxy, auto: &Autoproxy) -> bool {
if auto.enable {
// PAC writes bypass state too.
snapshot.matches_pac(auto) && snapshot.bypass_matches(&sys.bypass)
} else if sys.enable {
snapshot.matches_global(sys)
} else {
// Bypass state is inert while every proxy is disabled.
snapshot.is_all_disabled()
}
}
/// Treat failed reads as different so writes are skipped only with proof.
fn classify_os_proxy_state(snapshot: Result<bool>) -> OsProxyState {
match snapshot {
Ok(true) => OsProxyState::AlreadyApplied,
_ => OsProxyState::DifferentOrUnknown,
}
}
#[cfg(target_os = "macos")]
fn read_os_proxy_state(sys: &Sysproxy, auto: &Autoproxy) -> OsProxyState {
// Use the per-protocol snapshot rather than the synthesized proxy view.
let already_applied = Sysproxy::snapshot()
.map(|snapshot| target_is_already_in_place(&snapshot, sys, auto))
.map_err(anyhow::Error::from);
if let Err(err) = &already_applied {
logging!(warn, Type::Core, "failed to read OS proxy snapshot: {err:#}");
}
classify_os_proxy_state(already_applied)
}
#[cfg(not(target_os = "macos"))]
fn read_os_proxy_state(_sys: &Sysproxy, _auto: &Autoproxy) -> OsProxyState {
classify_os_proxy_state(Err(anyhow::anyhow!("per-protocol proxy snapshots are macOS only")))
}
/// Disable, drain a racing guard if needed, then disable again.
async fn disable_until_the_last_write_is_ours<Disable, DisableFuture, Drain, DrainFuture>(
drained: bool,
disable: Disable,
drain_again: Drain,
) -> Result<()>
where
Disable: Fn() -> DisableFuture,
DisableFuture: std::future::Future<Output = Result<()>>,
Drain: FnOnce() -> DrainFuture,
DrainFuture: std::future::Future<Output = bool>,
{
disable().await?;
if drained {
return Ok(());
}
// The final disable must happen after the last in-flight guard write.
if !drain_again().await {
anyhow::bail!(
"the system proxy guard did not finish within {GUARD_DRAIN_TIMEOUT:?}; the OS proxy state is unknown"
);
}
disable().await
}
/// Return the first failure after logging every attempted write.
fn first_failure<const N: usize>(attempts: [(&'static str, Result<()>); N]) -> Result<()> {
let mut first: Option<anyhow::Error> = None;
for (what, outcome) in attempts {
let Err(error) = outcome else { continue };
logging!(warn, Type::Core, "failed to turn the {what} off: {error:#}");
if first.is_none() {
first = Some(error);
}
}
first.map_or(Ok(()), Err)
}
/// Attempt to disable both proxy kinds even if the first fails.
fn disable_both<Global, Pac>(disable_global: Global, disable_pac: Pac) -> Result<()>
where
Global: FnOnce() -> Result<()>,
Pac: FnOnce() -> Result<()>,
{
let global = disable_global();
let pac = disable_pac();
first_failure([("global proxy", global), ("PAC", pac)])
}
/// Force both proxy kinds off, in one blocking hop.
async fn disable_all_proxies(sys: Sysproxy, auto: Autoproxy) -> Result<()> {
tokio::task::spawn_blocking(move || {
disable_both(
|| sys.set_system_proxy().map_err(anyhow::Error::from),
|| auto.set_auto_proxy().map_err(anyhow::Error::from),
)
})
.await?
}
/// Reconcile OS and guard state while preserving the original failure.
async fn recover_from_failed_write<Guard, Compensate, CompensateFuture>(
error: anyhow::Error,
earlier_step_completed: bool,
recover_guard: Guard,
compensate: Compensate,
) -> anyhow::Error
where
Guard: FnOnce(AuthoritativeState),
Compensate: FnOnce() -> CompensateFuture,
CompensateFuture: std::future::Future<Output = Result<()>>,
{
let state = authoritative_state(&error, earlier_step_completed);
recover_guard(state);
match state {
AuthoritativeState::Unchanged => error,
AuthoritativeState::Unknown => {
if let Err(compensation) = compensate().await {
logging!(
warn,
Type::Core,
"failed to force the system proxy off after a partial write: {compensation:#}"
);
}
error.context(SystemProxyStateUnknown)
}
}
}
async fn current_os_proxy_state(sys: Sysproxy, auto: Autoproxy) -> OsProxyState {
tokio::task::spawn_blocking(move || read_os_proxy_state(&sys, &auto))
.await
.unwrap_or_else(|join_error| {
logging!(warn, Type::Core, "failed to read OS proxy state: {join_error}");
OsProxyState::DifferentOrUnknown
})
}
pub(crate) struct Sysopt {
update_lock: TokioMutex<()>,
guard_operation_lock: TokioMutex<()>,
@@ -87,36 +273,41 @@ impl Sysopt {
Arc::clone(&self.guard)
}
async fn stop_proxy_guard_locked(&self) {
loop {
let state = self.access_guard().read().get_state();
if state.is_pendding() {
tokio::task::yield_now().await;
continue;
}
self.access_guard().write().stop();
return;
/// Stop the guard and return whether it drained.
async fn stop_proxy_guard_locked(&self) -> bool {
// Drop the parking_lot read guard before awaiting.
let idle = self.access_guard().read().shutdown();
let drained = idle.wait_timeout(GUARD_DRAIN_TIMEOUT).await;
if !drained {
logging!(
warn,
Type::Core,
"the system proxy guard did not finish within {GUARD_DRAIN_TIMEOUT:?}"
);
}
drained
}
pub(super) async fn stop_proxy_guard(&self) {
/// Stop the guard before handing OS proxy ownership elsewhere.
pub(super) async fn stop_proxy_guard(&self) -> bool {
let _operation = self.guard_operation_lock.lock().await;
self.stop_proxy_guard_locked().await;
self.stop_proxy_guard_locked().await
}
pub(super) async fn refresh_guard(&self) {
/// Reconcile guard state with configuration and report success.
pub(super) async fn refresh_guard(&self) -> bool {
logging!(info, Type::Core, "Refreshing system proxy guard...");
let verge = Config::verge().await.latest_arc();
let _operation = self.guard_operation_lock.lock().await;
if !verge.enable_system_proxy.unwrap_or_default() {
logging!(info, Type::Core, "System proxy is disabled.");
self.stop_proxy_guard_locked().await;
return;
let _drained = self.stop_proxy_guard_locked().await;
return true;
}
if !verge.enable_proxy_guard.unwrap_or_default() {
logging!(info, Type::Core, "System proxy guard is disabled.");
self.stop_proxy_guard_locked().await;
return;
let _drained = self.stop_proxy_guard_locked().await;
return true;
}
logging!(
info,
@@ -133,11 +324,56 @@ impl Sysopt {
logging!(info, Type::Core, "Starting system proxy guard...");
{
let guard = self.access_guard();
guard.write().start();
if !guard.read().start() {
logging!(
warn,
Type::Core,
"the system proxy guard refused to start; a previous run has not finished"
);
}
}
while self.access_guard().read().get_state().is_pendding() {
tokio::task::yield_now().await;
}
// `start()` false may also mean a guard was already running.
!self.access_guard().read().get_state().is_stopped()
}
/// Recover OS and guard state after a failed write.
async fn recover_from_failed_write(
&self,
error: anyhow::Error,
earlier_step_completed: bool,
guard_was_running: bool,
off: (Sysproxy, Autoproxy),
) -> anyhow::Error {
recover_from_failed_write(
error,
earlier_step_completed,
|state| self.recover_guard_after_failure(state, guard_was_running),
|| disable_all_proxies(off.0, off.1),
)
.await
}
/// Reconcile the guard with the authoritative state.
fn recover_guard_after_failure(&self, state: AuthoritativeState, was_running: bool) {
match state {
AuthoritativeState::Unchanged if was_running => {
let restarted = self.access_guard().read().start();
if !restarted {
logging!(
warn,
Type::Core,
"the system proxy guard refused to start again after a failed write; it is not running"
);
}
}
AuthoritativeState::Unchanged => {}
AuthoritativeState::Unknown => {
self.access_guard().write().set_guard_type(GuardType::None);
}
}
}
/// Wait for any in-progress `update_sysproxy` to finish, so that a
@@ -201,21 +437,69 @@ impl Sysopt {
};
let _guard_operation = self.guard_operation_lock.lock().await;
self.access_guard().write().set_guard_type(guard_type);
// Drain the guard before any OS read or write.
let guard_was_running = !self.access_guard().read().get_state().is_stopped();
let idle = self.access_guard().read().shutdown();
let drained = idle.wait_timeout(GUARD_DRAIN_TIMEOUT).await;
if !drained {
// A pending guard write makes OS state unknown.
self.access_guard().write().set_guard_type(GuardType::None);
anyhow::bail!(
"the system proxy guard did not finish within {GUARD_DRAIN_TIMEOUT:?}; the OS proxy state is unknown"
);
}
// Skip writes only when macOS exactly matches the target.
if cfg!(target_os = "macos")
&& current_os_proxy_state(sys.clone(), auto.clone()).await == OsProxyState::AlreadyApplied
{
self.access_guard().write().set_guard_type(guard_type);
return Ok(());
}
let apply_steps = proxy_apply_steps(sys.enable, auto.enable);
tokio::task::spawn_blocking(move || -> Result<()> {
for step in apply_steps {
match step {
ProxyApplyStep::Autoproxy => auto.set_auto_proxy()?,
ProxyApplyStep::Sysproxy => sys.set_system_proxy()?,
// Prepare the disabled state used to compensate a partial write.
let compensation = {
let (mut off_sys, mut off_auto) = (sys.clone(), auto.clone());
off_sys.enable = false;
off_auto.enable = false;
(off_sys, off_auto)
};
// Track whether an earlier setter already changed the OS.
let applied = tokio::task::spawn_blocking(move || {
for (index, step) in apply_steps.into_iter().enumerate() {
let written = match step {
ProxyApplyStep::Autoproxy => auto.set_auto_proxy(),
ProxyApplyStep::Sysproxy => sys.set_system_proxy(),
};
if let Err(error) = written {
return Err((index > 0, anyhow::Error::from(error)));
}
}
Ok(())
})
.await??;
.await;
match applied {
Ok(Ok(())) => {}
Ok(Err((earlier_step_completed, error))) => {
return Err(self
.recover_from_failed_write(error, earlier_step_completed, guard_was_running, compensation)
.await);
}
Err(join_error) => {
let error = anyhow::Error::from(join_error).context("the system proxy write task did not finish");
return Err(self
.recover_from_failed_write(error, false, guard_was_running, compensation)
.await);
}
}
// Never point the guard at a target that failed to reach the OS.
self.access_guard().write().set_guard_type(guard_type);
Ok(())
}
@@ -233,7 +517,7 @@ impl Sysopt {
}
let _lock = self.update_lock.lock().await;
let _guard_operation = self.guard_operation_lock.lock().await;
self.stop_proxy_guard_locked().await;
let drained = self.stop_proxy_guard_locked().await;
// 直接关闭所有代理
let (sys, auto) = {
@@ -243,20 +527,496 @@ impl Sysopt {
(sys.clone(), auto.clone())
};
tokio::task::spawn_blocking(move || -> Result<()> {
sys.set_system_proxy()?;
auto.set_auto_proxy()?;
Ok(())
})
.await??;
// The match is trustworthy only after the guard drained.
if cfg!(target_os = "macos")
&& drained
&& current_os_proxy_state(sys.clone(), auto.clone()).await == OsProxyState::AlreadyApplied
{
return Ok(());
}
Ok(())
disable_until_the_last_write_is_ours(
drained,
|| disable_all_proxies(sys.clone(), auto.clone()),
|| self.stop_proxy_guard_locked(),
)
.await
}
}
#[cfg(test)]
mod tests {
use super::{ProxyApplyStep, proxy_apply_steps};
use super::{
AuthoritativeState, OsProxyState, ProxyApplyStep, SystemProxyStateUnknown, authoritative_state,
authoritative_state_from, classify_os_proxy_state, disable_both, disable_until_the_last_write_is_ours,
first_failure, proxy_apply_steps, recover_from_failed_write, target_is_already_in_place,
};
use parking_lot::Mutex;
use std::collections::VecDeque;
use sysproxy::{Autoproxy, Sysproxy};
async fn record_disable_sequence(
drained: bool,
second_drain: bool,
disable_answers: &[bool],
) -> (Vec<&'static str>, bool) {
let calls = Mutex::new(Vec::new());
let remaining = Mutex::new(disable_answers.iter().copied().collect::<VecDeque<bool>>());
let result = disable_until_the_last_write_is_ours(
drained,
|| {
calls.lock().push("disable");
let succeeds = remaining.lock().pop_front().unwrap_or(true);
async move {
if succeeds {
Ok(())
} else {
anyhow::bail!("networksetup refused")
}
}
},
|| {
calls.lock().push("drain");
async move { second_drain }
},
)
.await;
(calls.into_inner(), result.is_ok())
}
fn failure_text(result: anyhow::Result<()>) -> std::string::String {
result.map_or_else(|error| format!("{error:#}"), |()| "no failure".to_owned())
}
#[test]
fn every_proxy_kind_is_attempted_even_after_one_of_them_failed() {
let attempted = Mutex::new(Vec::new());
let failed = disable_both(
|| {
attempted.lock().push("global proxy");
Err(anyhow::anyhow!("global refused"))
},
|| {
attempted.lock().push("PAC");
Err(anyhow::anyhow!("PAC refused"))
},
);
assert_eq!(&*attempted.lock(), &["global proxy", "PAC"]);
assert_eq!(failure_text(failed), "global refused");
}
#[test]
fn a_later_failure_is_still_reported_when_the_first_one_succeeded() {
let failed = first_failure([("global proxy", Ok(())), ("PAC", Err(anyhow::anyhow!("PAC refused")))]);
assert_eq!(failure_text(failed), "PAC refused");
}
#[test]
fn nothing_is_reported_when_every_kind_went_through() {
assert!(first_failure([("global proxy", Ok(())), ("PAC", Ok(()))]).is_ok());
}
struct Recovery {
guard_told: Option<AuthoritativeState>,
compensated: bool,
state_unknown: bool,
#[cfg(not(target_os = "linux"))]
message: String,
}
async fn record_recovery(error: anyhow::Error, earlier_step_completed: bool, compensation_works: bool) -> Recovery {
let guard_told = Mutex::new(None);
let compensated = Mutex::new(false);
let recovered = recover_from_failed_write(
error,
earlier_step_completed,
|state| *guard_told.lock() = Some(state),
|| {
*compensated.lock() = true;
async move {
if compensation_works {
Ok(())
} else {
anyhow::bail!("the compensation was refused too")
}
}
},
)
.await;
Recovery {
guard_told: *guard_told.lock(),
compensated: *compensated.lock(),
state_unknown: SystemProxyStateUnknown::is_in(&recovered),
#[cfg(not(target_os = "linux"))]
message: format!("{recovered:#}"),
}
}
fn refused() -> anyhow::Error {
anyhow::Error::new(sysproxy::Error::ProxyWrite {
progress: sysproxy::WriteProgress::new(0, 7),
source: Box::new(sysproxy::Error::RequiresAdminPrivileges),
})
}
fn partly_written() -> anyhow::Error {
anyhow::Error::new(sysproxy::Error::ProxyWrite {
progress: sysproxy::WriteProgress::new(3, 7),
source: Box::new(sysproxy::Error::RequiresAdminPrivileges),
})
}
#[tokio::test]
async fn a_failure_that_wrote_nothing_is_not_compensated_for() {
let recovery = record_recovery(refused(), false, true).await;
assert!(!recovery.compensated);
assert_eq!(recovery.guard_told, Some(AuthoritativeState::Unchanged));
assert!(!recovery.state_unknown);
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn nothing_on_linux_ever_reaches_the_destructive_row() {
for earlier_step_completed in [false, true] {
let recovery = record_recovery(partly_written(), earlier_step_completed, true).await;
assert!(!recovery.compensated, "{earlier_step_completed}");
assert_eq!(recovery.guard_told, Some(AuthoritativeState::Unchanged));
assert!(!recovery.state_unknown);
}
}
#[cfg(not(target_os = "linux"))]
#[tokio::test]
async fn a_failure_that_wrote_something_forces_the_proxy_off() {
let recovery = record_recovery(partly_written(), false, true).await;
assert!(recovery.compensated);
assert_eq!(recovery.guard_told, Some(AuthoritativeState::Unknown));
assert!(recovery.state_unknown);
}
#[cfg(not(target_os = "linux"))]
#[tokio::test]
async fn a_compensation_that_fails_does_not_replace_the_diagnosis() {
let recovery = record_recovery(partly_written(), false, false).await;
assert!(recovery.compensated);
assert!(recovery.message.contains("admin privileges"));
assert!(!recovery.message.contains("the compensation was refused too"));
assert!(recovery.state_unknown);
}
#[cfg(not(target_os = "linux"))]
#[tokio::test]
async fn an_earlier_setter_that_finished_reaches_the_second_row_too() {
let recovery = record_recovery(refused(), true, true).await;
assert!(recovery.compensated);
assert_eq!(recovery.guard_told, Some(AuthoritativeState::Unknown));
assert!(recovery.state_unknown);
}
#[tokio::test]
async fn a_drained_guard_needs_only_one_write() {
assert_eq!(
record_disable_sequence(true, true, &[true]).await,
(vec!["disable"], true)
);
}
#[tokio::test]
async fn a_write_that_raced_the_guard_is_repeated_after_it_finishes() {
assert_eq!(
record_disable_sequence(false, true, &[true, true]).await,
(vec!["disable", "drain", "disable"], true)
);
}
#[tokio::test]
async fn a_second_write_that_fails_on_its_own_is_still_reported() {
assert_eq!(
record_disable_sequence(false, true, &[true, false]).await,
(vec!["disable", "drain", "disable"], false)
);
}
#[tokio::test]
async fn a_guard_that_never_finishes_is_reported_rather_than_written_over() {
assert_eq!(
record_disable_sequence(false, false, &[true]).await,
(vec!["disable", "drain"], false)
);
}
#[tokio::test]
async fn a_refused_write_stops_before_waiting_on_anything() {
assert_eq!(
record_disable_sequence(false, true, &[false]).await,
(vec!["disable"], false)
);
}
#[test]
fn a_failure_with_nothing_written_leaves_the_old_guard_worth_restoring() {
let refused = anyhow::Error::new(sysproxy::Error::RequiresAdminPrivileges);
assert_eq!(authoritative_state(&refused, false), AuthoritativeState::Unchanged);
}
#[cfg(not(target_os = "linux"))]
#[test]
fn only_evidence_that_a_write_landed_stands_the_guard_down() {
let nothing_written = anyhow::Error::new(sysproxy::Error::ProxyWrite {
progress: sysproxy::WriteProgress::new(0, 7),
source: Box::new(sysproxy::Error::RequiresAdminPrivileges),
});
let something_written = anyhow::Error::new(sysproxy::Error::ProxyWrite {
progress: sysproxy::WriteProgress::new(3, 7),
source: Box::new(sysproxy::Error::RequiresAdminPrivileges),
});
assert_eq!(
authoritative_state(&nothing_written, false),
AuthoritativeState::Unchanged
);
assert_eq!(
authoritative_state(&something_written, false),
AuthoritativeState::Unknown
);
}
#[cfg(not(target_os = "linux"))]
#[test]
fn a_step_that_already_finished_stands_the_guard_down_whatever_the_error_says() {
let refused = anyhow::Error::new(sysproxy::Error::RequiresAdminPrivileges);
assert_eq!(authoritative_state(&refused, false), AuthoritativeState::Unchanged);
assert_eq!(authoritative_state(&refused, true), AuthoritativeState::Unknown);
}
#[test]
fn where_a_successful_setter_proves_nothing_no_failure_is_destructive() {
let strongest_evidence = anyhow::Error::new(sysproxy::Error::ProxyWrite {
progress: sysproxy::WriteProgress::new(3, 7),
source: Box::new(sysproxy::Error::RequiresAdminPrivileges),
});
for earlier_step_completed in [false, true] {
assert_eq!(
authoritative_state_from(false, &strongest_evidence, earlier_step_completed),
AuthoritativeState::Unchanged,
"{earlier_step_completed}"
);
}
}
#[test]
fn where_it_proves_something_both_kinds_of_evidence_count() {
let nothing_written = anyhow::Error::new(sysproxy::Error::ProxyWrite {
progress: sysproxy::WriteProgress::new(0, 7),
source: Box::new(sysproxy::Error::RequiresAdminPrivileges),
});
let partly_written = anyhow::Error::new(sysproxy::Error::ProxyWrite {
progress: sysproxy::WriteProgress::new(3, 7),
source: Box::new(sysproxy::Error::RequiresAdminPrivileges),
});
assert_eq!(
authoritative_state_from(true, &nothing_written, false),
AuthoritativeState::Unchanged
);
assert_eq!(
authoritative_state_from(true, &partly_written, false),
AuthoritativeState::Unknown
);
assert_eq!(
authoritative_state_from(true, &nothing_written, true),
AuthoritativeState::Unknown
);
}
fn holding_global(port: u16) -> sysproxy::ProxySnapshot {
let endpoint = || sysproxy::ProxyEndpoint {
host: "127.0.0.1".to_owned(),
port,
enable: true,
switched_on: true,
};
sysproxy::ProxySnapshot {
socks: endpoint(),
http: endpoint(),
https: endpoint(),
auto: sysproxy::Autoproxy {
url: std::string::String::new(),
enable: false,
},
auto_switched_on: false,
bypass: "localhost".to_owned(),
}
}
fn holding_nothing() -> sysproxy::ProxySnapshot {
let off = || sysproxy::ProxyEndpoint {
host: std::string::String::new(),
port: 0,
enable: false,
switched_on: false,
};
sysproxy::ProxySnapshot {
socks: off(),
http: off(),
https: off(),
auto: sysproxy::Autoproxy {
url: std::string::String::new(),
enable: false,
},
auto_switched_on: false,
bypass: std::string::String::new(),
}
}
fn global_target(port: u16) -> Sysproxy {
Sysproxy {
enable: true,
host: "127.0.0.1".to_owned(),
port,
bypass: "localhost".to_owned(),
}
}
fn pac_mode_target() -> Sysproxy {
Sysproxy {
enable: false,
..global_target(7897)
}
}
fn pac_off() -> Autoproxy {
Autoproxy {
url: std::string::String::new(),
enable: false,
}
}
#[test]
fn a_proxy_the_os_already_points_at_needs_no_write() {
assert!(target_is_already_in_place(
&holding_global(7897),
&global_target(7897),
&pac_off()
));
}
#[test]
fn a_proxy_pointing_at_a_different_port_still_has_to_be_written() {
assert!(!target_is_already_in_place(
&holding_global(7897),
&global_target(7898),
&pac_off()
));
}
#[test]
fn a_pac_target_asks_the_pac_question_and_not_the_global_one() {
let pac = Autoproxy {
url: "http://127.0.0.1:33333/commands/pac".to_owned(),
enable: true,
};
let mut os_holds_pac = holding_nothing();
os_holds_pac.auto = pac.clone();
os_holds_pac.auto_switched_on = true;
os_holds_pac.bypass = "localhost".to_owned();
assert!(target_is_already_in_place(&os_holds_pac, &pac_mode_target(), &pac));
}
#[test]
fn a_pac_target_whose_bypass_changed_still_has_to_be_written() {
let pac = Autoproxy {
url: "http://127.0.0.1:33333/commands/pac".to_owned(),
enable: true,
};
let mut os_holds_pac = holding_nothing();
os_holds_pac.auto = pac.clone();
os_holds_pac.auto_switched_on = true;
os_holds_pac.bypass = "localhost,example.com".to_owned();
assert!(!target_is_already_in_place(&os_holds_pac, &pac_mode_target(), &pac));
}
#[test]
fn a_switch_left_on_over_nothing_is_not_already_disabled() {
let mut stranded = holding_nothing();
stranded.http = sysproxy::ProxyEndpoint {
host: std::string::String::new(),
port: 0,
enable: false,
switched_on: true,
};
assert!(!target_is_already_in_place(
&stranded,
&Sysproxy {
enable: false,
..global_target(7897)
},
&pac_off()
));
}
#[test]
fn a_target_of_nothing_does_not_ask_about_the_bypass_list() {
let mut os_holds_nothing = holding_nothing();
os_holds_nothing.bypass = "something.else".to_owned();
assert!(target_is_already_in_place(
&os_holds_nothing,
&Sysproxy {
enable: false,
..global_target(7897)
},
&pac_off()
));
}
#[test]
fn a_target_of_nothing_asks_whether_the_os_holds_nothing() {
let nothing_wanted = Sysproxy {
enable: false,
..global_target(7897)
};
assert!(target_is_already_in_place(
&holding_nothing(),
&nothing_wanted,
&pac_off()
));
assert!(!target_is_already_in_place(
&holding_global(7897),
&nothing_wanted,
&pac_off()
));
}
#[test]
fn only_a_snapshot_that_agrees_with_the_target_skips_the_write() {
assert_eq!(classify_os_proxy_state(Ok(true)), OsProxyState::AlreadyApplied);
assert_eq!(classify_os_proxy_state(Ok(false)), OsProxyState::DifferentOrUnknown);
}
#[test]
fn a_failed_read_never_counts_as_agreement() {
assert_eq!(
classify_os_proxy_state(Err(anyhow::anyhow!("read failed"))),
OsProxyState::DifferentOrUnknown
);
}
#[test]
fn pure_sysproxy_mode_clears_pac_before_enabling_global_proxy() {
+1 -1
View File
@@ -949,7 +949,7 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
};
}
MenuIds::SYSTEM_PROXY => {
feat::toggle_system_proxy().await;
let _ = feat::toggle_system_proxy().await;
}
MenuIds::TUN_MODE => {
feat::toggle_tun_mode(None).await;
+5 -1
View File
@@ -1,6 +1,6 @@
use crate::{
config::{Config, IClashTemp, IProfiles, IVerge},
core::backup,
core::{backup, proxy_control::SystemProxyStateUnknown},
process::AsyncHandler,
utils::{
dirs::{PathBufExec as _, app_home_dir, local_backup_dir, verge_path},
@@ -63,6 +63,10 @@ async fn finalize_restored_verge_config(
// Use not_save_file = true to avoid extra I/O (we already persisted the restored file).
if let Err(err) = super::patch_verge(&restored, true).await {
logging!(error, Type::Backup, "Failed to apply restored verge config: {err:#?}");
// Propagate unknown proxy state; ordinary side-effect failures stay logged.
if SystemProxyStateUnknown::is_in(&err) {
return Err(err);
}
}
Ok(())
}
+2 -2
View File
@@ -31,8 +31,8 @@ pub async fn restart_clash_core() {
handle::Handle::notice_message("set_config::ok", "ok");
}
Err(err) => {
handle::Handle::notice_message("set_config::error", format!("{err}"));
logging!(error, Type::Core, "{err}");
handle::Handle::notice_message("set_config::error", format!("{err:#}"));
logging!(error, Type::Core, "{err:#}");
}
}
}
+80 -5
View File
@@ -1,6 +1,6 @@
use crate::{
config::{Config, IVerge},
core::{CoreManager, autostart, handle, hotkey, logger::Logger, tray},
core::{CoreManager, autostart, handle, hotkey, logger::Logger, proxy_control::SystemProxyStateUnknown, tray},
module::{auto_backup::AutoBackupManager, lightweight},
};
use anyhow::Result;
@@ -211,9 +211,6 @@ async fn process_terminated_flags(update_flags: UpdateFlags, patch: &IVerge) ->
CoreManager::global().update_config_checked().await?;
handle::Handle::refresh_clash();
}
if update_flags.contains(UpdateFlags::VERGE_CONFIG) {
handle::Handle::refresh_verge();
}
if update_flags.contains(UpdateFlags::LAUNCH) {
autostart::update_launch().await?;
}
@@ -306,8 +303,25 @@ pub(super) async fn apply_verge_patch(patch: &IVerge, not_save_file: bool) -> Re
let update_flags = determine_update_flags(patch);
logging!(debug, Type::Setup, "Determined update flags: {:?}", update_flags);
process_terminated_flags(update_flags, patch).await?;
if let Err(error) = process_terminated_flags(update_flags, patch).await {
if !may_commit_system_proxy_disabled(patch, &error) {
return Err(error);
}
// Commit the safe state after a user-requested proxy patch partly lands.
verge.edit_draft(|d| d.enable_system_proxy = Some(false));
transaction.commit();
announce_verge_change();
// Persist the forced-off value even when the original patch was pre-saved.
let unsaved = verge.data_arc().save_file().await.err();
return Err(match unsaved {
Some(save) => error.context(format!(
"the system proxy state became unknown and the disabled safety value could not be saved: {save:#}"
)),
None => error,
});
}
transaction.commit();
announce_verge_change();
logging_error!(Type::Backup, AutoBackupManager::global().refresh_settings().await);
if !not_save_file {
@@ -319,8 +333,69 @@ pub(super) async fn apply_verge_patch(patch: &IVerge, not_save_file: bool) -> Re
Ok(())
}
fn announce_verge_change() {
handle::Handle::refresh_verge();
}
/// Whether a user proxy patch may commit disabled after a partial write.
fn may_commit_system_proxy_disabled(patch: &IVerge, error: &anyhow::Error) -> bool {
patch.enable_system_proxy.is_some() && SystemProxyStateUnknown::is_in(error)
}
pub async fn fetch_verge_config() -> Result<SharedDraft<IVerge>> {
let draft = Config::verge().await;
let data = draft.data_arc();
Ok(data)
}
#[cfg(test)]
mod tests {
use super::{IVerge, SystemProxyStateUnknown, may_commit_system_proxy_disabled};
fn state_unknown() -> anyhow::Error {
anyhow::anyhow!("networksetup refused")
.context(SystemProxyStateUnknown)
.context("failed to apply the system proxy")
}
#[test]
fn the_user_turning_the_proxy_on_gets_it_committed_as_off_when_the_os_ended_up_unknown() {
let patch = IVerge {
enable_system_proxy: Some(true),
..IVerge::default()
};
assert!(may_commit_system_proxy_disabled(&patch, &state_unknown()));
}
#[test]
fn a_patch_about_something_else_never_rewrites_the_proxy_setting() {
let patch = IVerge {
enable_tun_mode: Some(true),
..IVerge::default()
};
assert!(!may_commit_system_proxy_disabled(&patch, &state_unknown()));
}
#[test]
fn nothing_is_committed_when_nothing_is_known_to_have_been_written() {
let patch = IVerge {
enable_system_proxy: Some(true),
..IVerge::default()
};
let refused = anyhow::anyhow!("networksetup refused").context("failed to apply the system proxy");
assert!(!may_commit_system_proxy_disabled(&patch, &refused));
}
#[test]
fn turning_the_proxy_off_and_failing_that_way_still_commits_off() {
let patch = IVerge {
enable_system_proxy: Some(false),
..IVerge::default()
};
assert!(may_commit_system_proxy_disabled(&patch, &state_unknown()));
}
}
+8 -12
View File
@@ -11,6 +11,7 @@ use crate::{
probe_listener as probe_listener_sync, probe_proxy_port_change,
},
manager::RunningMode,
proxy_control::rollback_failure,
validate::CoreConfigValidator,
},
process::AsyncHandler,
@@ -87,17 +88,15 @@ pub async fn save_proxy_ports(settings: ProxyPortSettings) -> Result<SaveProxyPo
transaction.rollback();
return match restore_files(&snapshots).await {
Ok(()) => Err(error).context("failed to persist candidate Runtime Configuration"),
Err(rollback_error) => Err(anyhow!("{error:#}; configuration rollback failed: {rollback_error:#}")),
Err(rollback_error) => Err(rollback_failure(error, rollback_error)),
};
}
if was_running && let Err(activation_error) = manager.restart_core_during_config_update().await {
transaction.rollback();
if let Err(rollback_error) = rollback_proxy_ports(&snapshots, was_running).await {
return Err(anyhow!(
"failed to activate proxy port configuration: {activation_error:#}; \
configuration rollback failed: {rollback_error:#}"
));
return Err(rollback_failure(activation_error, rollback_error)
.context("failed to activate the proxy port configuration"));
}
let post_failure_assessment = probe_proxy_ports(current, candidate, was_running).await?;
@@ -116,9 +115,7 @@ pub async fn save_proxy_ports(settings: ProxyPortSettings) -> Result<SaveProxyPo
transaction.rollback();
return match rollback_proxy_ports(&snapshots, was_running).await {
Ok(()) => Err(persist_error),
Err(rollback_error) => Err(anyhow!(
"{persist_error:#}; configuration rollback failed: {rollback_error:#}"
)),
Err(rollback_error) => Err(rollback_failure(persist_error, rollback_error)),
};
}
@@ -245,9 +242,8 @@ async fn rollback_proxy_ports(snapshots: &[FileSnapshot], was_running: bool) ->
(Ok(()), Ok(())) => Ok(()),
(Err(file_error), Ok(())) => Err(file_error),
(Ok(()), Err(lifecycle_error)) => Err(lifecycle_error).context("failed to restore the previous core"),
(Err(file_error), Err(lifecycle_error)) => Err(anyhow!(
"failed to restore configuration files: {file_error:#}; \
failed to restore the previous core: {lifecycle_error:#}"
)),
(Err(file_error), Err(lifecycle_error)) => Err(lifecycle_error
.context(format!("failed to restore configuration files: {file_error:#}"))
.context("failed to restore the previous core")),
}
}
+55 -16
View File
@@ -1,13 +1,21 @@
use crate::{
config::{Config, IVerge, MixedPort},
core::handle,
core::{
handle,
notification::{self, FailedOperation},
proxy_control,
},
utils::{
notification::{NotificationEvent, needs_system_notification, notify_event},
window_manager::WindowManager,
},
};
use clash_verge_logging::{Type, logging};
use std::env;
use tauri_plugin_clipboard_manager::ClipboardExt as _;
/// Toggle system proxy on/off
pub async fn toggle_system_proxy() -> bool {
/// Toggle system proxy, returning `None` when it does not change.
pub async fn toggle_system_proxy() -> Option<bool> {
let verge = Config::verge().await;
let current = verge.latest_arc().enable_system_proxy.unwrap_or(false);
let auto_close_connection = verge.latest_arc().auto_close_connection.unwrap_or(false);
@@ -21,27 +29,46 @@ pub async fn toggle_system_proxy() -> bool {
}
let requested = !current;
let patch_result = super::patch_verge(
&IVerge {
enable_system_proxy: Some(requested),
..IVerge::default()
},
false,
let patch_result = notification::asking_for(
toggle_operation(requested),
Box::pin(super::patch_verge(
&IVerge {
enable_system_proxy: Some(requested),
..IVerge::default()
},
false,
)),
)
.await;
match patch_result {
Ok(_) => {
handle::Handle::refresh_verge();
requested
}
// Patch processing refreshes every caller.
Ok(_) => Some(requested),
Err(err) => {
logging!(error, Type::ProxyMode, "{err}");
current
logging!(error, Type::ProxyMode, "{err:#}");
report_toggle_failure(&err).await;
None
}
}
}
/// Notify only when a classified failure remains pending.
async fn report_toggle_failure(error: &anyhow::Error) {
let recorded = proxy_control::is_reportable(error);
if recorded && needs_system_notification(WindowManager::get_main_window_state()) {
notify_event(NotificationEvent::SystemProxyFailed).await;
}
}
const fn toggle_operation(requested: bool) -> FailedOperation {
if requested {
FailedOperation::SystemProxyEnable
} else {
FailedOperation::SystemProxyDisable
}
}
/// Toggle TUN mode on/off
/// Returns the updated toggle state
pub async fn toggle_tun_mode(not_save_file: Option<bool>) -> bool {
@@ -67,7 +94,7 @@ pub async fn toggle_tun_mode(not_save_file: Option<bool>) -> bool {
Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false)
}
Err(err) => {
logging!(error, Type::ProxyMode, "{err}");
logging!(error, Type::ProxyMode, "{err:#}");
current
}
}
@@ -123,3 +150,15 @@ pub async fn copy_clash_env() {
logging!(error, Type::ProxyMode, "Failed to write to clipboard");
}
}
#[cfg(test)]
mod tests {
use super::toggle_operation;
use crate::core::notification::FailedOperation;
#[test]
fn a_failed_toggle_records_which_way_the_user_was_asking() {
assert_eq!(toggle_operation(true), FailedOperation::SystemProxyEnable);
assert_eq!(toggle_operation(false), FailedOperation::SystemProxyDisable);
}
}
+1
View File
@@ -141,6 +141,7 @@ mod app_init {
cmd::stop_core,
cmd::restart_core,
cmd::get_runtime_state,
cmd::get_pending_failures,
cmd::get_auto_launch_status,
cmd::entry_lightweight_mode,
cmd::exit_lightweight_mode,
+39 -1
View File
@@ -1,6 +1,6 @@
use std::borrow::Cow;
use crate::core::handle;
use crate::{core::handle, utils::window_manager::WindowState};
use clash_verge_i18n;
use tauri_plugin_notification::NotificationExt as _;
@@ -10,6 +10,10 @@ pub enum NotificationEvent<'a> {
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,
@@ -18,6 +22,14 @@ pub enum NotificationEvent<'a> {
AppHidden,
}
/// 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();
@@ -48,6 +60,16 @@ pub async fn notify_event<'a>(event: NotificationEvent<'a>) {
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 {
@@ -81,3 +103,19 @@ pub async fn notify_event<'a>(event: NotificationEvent<'a>) {
}
}
}
#[cfg(test)]
mod tests {
use super::needs_system_notification;
use crate::utils::window_manager::WindowState;
#[test]
fn a_system_notification_is_sent_exactly_where_the_window_cannot_speak() {
assert!(needs_system_notification(WindowState::Hidden));
assert!(needs_system_notification(WindowState::Minimized));
assert!(needs_system_notification(WindowState::NotExist));
assert!(!needs_system_notification(WindowState::VisibleFocused));
assert!(!needs_system_notification(WindowState::VisibleUnfocused));
}
}