From ae5d3c478a682d2623aa2f25869b7f0369ead970 Mon Sep 17 00:00:00 2001 From: Tunglies Date: Sat, 31 Jan 2026 17:23:20 +0800 Subject: [PATCH] fix: resolve issue with tray operations after system resume (#6216) * feat(limiter): add Limiter struct with clock interface and tests * feat(limiter): integrate Limiter into tray and window management for rate limiting * fix(tray, window_manager): update debounce timing for tray click and window operations * refactor(limiter): change time representation from u64 to u128 for improved precision * fix: resolve issue with tray operations after system resume * Revert "refactor(limiter): change time representation from u64 to u128 for improved precision" This reverts commit 2198f40f7fcecbb755deb38af005c28e993db970. --- Cargo.lock | 74 +----------- Cargo.toml | 2 + Changelog.md | 1 + crates/clash-verge-limiter/Cargo.toml | 9 ++ crates/clash-verge-limiter/src/lib.rs | 165 ++++++++++++++++++++++++++ src-tauri/Cargo.toml | 2 +- src-tauri/src/core/tray/mod.rs | 19 ++- src-tauri/src/utils/window_manager.rs | 21 ++-- 8 files changed, 199 insertions(+), 94 deletions(-) create mode 100644 crates/clash-verge-limiter/Cargo.toml create mode 100644 crates/clash-verge-limiter/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index fb57176ce..4f4c730ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1122,6 +1122,7 @@ dependencies = [ "chrono", "clash-verge-draft", "clash-verge-i18n", + "clash-verge-limiter", "clash-verge-logging", "clash-verge-signal", "clash_verge_logger", @@ -1137,7 +1138,6 @@ dependencies = [ "futures", "gethostname", "getrandom 0.3.4", - "governor", "log", "nanoid", "network-interface", @@ -1199,6 +1199,10 @@ dependencies = [ "sys-locale", ] +[[package]] +name = "clash-verge-limiter" +version = "0.1.0" + [[package]] name = "clash-verge-logging" version = "0.1.0" @@ -2640,12 +2644,6 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" -[[package]] -name = "futures-timer" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" - [[package]] name = "futures-util" version = "0.3.31" @@ -2978,29 +2976,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "governor" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8" -dependencies = [ - "cfg-if", - "dashmap 6.1.0", - "futures-sink", - "futures-timer", - "futures-util", - "getrandom 0.3.4", - "hashbrown 0.16.1", - "nonzero_ext", - "parking_lot", - "portable-atomic", - "quanta", - "rand 0.9.2", - "smallvec", - "spinning_top", - "web-time", -] - [[package]] name = "gtk" version = "0.18.2" @@ -4408,12 +4383,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "nonzero_ext" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" - [[package]] name = "normpath" version = "1.5.0" @@ -5580,21 +5549,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "quanta" -version = "0.12.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" -dependencies = [ - "crossbeam-utils", - "libc", - "once_cell", - "raw-cpuid", - "wasi 0.11.1+wasi-snapshot-preview1", - "web-sys", - "winapi", -] - [[package]] name = "quick-error" version = "2.0.1" @@ -5809,15 +5763,6 @@ dependencies = [ "rand_core 0.5.1", ] -[[package]] -name = "raw-cpuid" -version = "11.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" -dependencies = [ - "bitflags 2.10.0", -] - [[package]] name = "raw-window-handle" version = "0.6.2" @@ -7020,15 +6965,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "spinning_top" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" -dependencies = [ - "lock_api", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index 0f1c9d289..a5c87e9a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/clash-verge-signal", "crates/tauri-plugin-clash-verge-sysinfo", "crates/clash-verge-i18n", + "crates/clash-verge-limiter", ] resolver = "2" @@ -44,6 +45,7 @@ clash-verge-draft = { path = "crates/clash-verge-draft" } clash-verge-logging = { path = "crates/clash-verge-logging" } clash-verge-signal = { path = "crates/clash-verge-signal" } clash-verge-i18n = { path = "crates/clash-verge-i18n" } +clash-verge-limiter = { path = "crates/clash-verge-limiter" } tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sysinfo" } tauri = { version = "2.9.5" } diff --git a/Changelog.md b/Changelog.md index 23ff42e4c..11f7071a5 100644 --- a/Changelog.md +++ b/Changelog.md @@ -7,6 +7,7 @@ - 修复 WebDAV 页面重试逻辑 - 修复 Linux 通过 GUI 安装服务模式权限不符合预期 - 修复 macOS 因网口顺序导致无法正确设置代理 +- 修复恢复休眠后无法操作托盘
✨ 新增功能 diff --git a/crates/clash-verge-limiter/Cargo.toml b/crates/clash-verge-limiter/Cargo.toml new file mode 100644 index 000000000..1f856f1f2 --- /dev/null +++ b/crates/clash-verge-limiter/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "clash-verge-limiter" +version = "0.1.0" +edition = "2024" + +[dependencies] + +[lints] +workspace = true diff --git a/crates/clash-verge-limiter/src/lib.rs b/crates/clash-verge-limiter/src/lib.rs new file mode 100644 index 000000000..263771f08 --- /dev/null +++ b/crates/clash-verge-limiter/src/lib.rs @@ -0,0 +1,165 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +pub type SystemLimiter = Limiter; + +pub trait Clock: Send + Sync { + fn now_ms(&self) -> u64; +} + +impl Clock for &T { + fn now_ms(&self) -> u64 { + (**self).now_ms() + } +} + +impl Clock for Arc { + fn now_ms(&self) -> u64 { + (**self).now_ms() + } +} + +pub struct SystemClock; + +impl Clock for SystemClock { + fn now_ms(&self) -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 + } +} + +pub struct Limiter { + last_run_ms: AtomicU64, + period_ms: u64, + clock: C, +} + +impl Limiter { + pub const fn new(period: Duration, clock: C) -> Self { + Self { + last_run_ms: AtomicU64::new(0), + period_ms: period.as_millis() as u64, + clock, + } + } + + pub fn check(&self) -> bool { + let now = self.clock.now_ms(); + let last = self.last_run_ms.load(Ordering::Relaxed); + + if now < last + self.period_ms && now >= last { + return false; + } + + self.last_run_ms + .compare_exchange(last, now, Ordering::SeqCst, Ordering::Relaxed) + .is_ok() + } +} + +#[cfg(test)] +mod extra_tests { + use super::*; + use std::sync::Arc; + use std::thread; + + struct MockClock(AtomicU64); + impl Clock for MockClock { + fn now_ms(&self) -> u64 { + self.0.load(Ordering::SeqCst) + } + } + + #[test] + fn test_zero_period_always_passes() { + let mock = MockClock(AtomicU64::new(100)); + let limiter = Limiter::new(Duration::from_millis(0), &mock); + + assert!(limiter.check()); + assert!(limiter.check()); + } + + #[test] + fn test_boundary_condition() { + let period_ms = 100; + let mock = MockClock(AtomicU64::new(1000)); + let limiter = Limiter::new(Duration::from_millis(period_ms), &mock); + + assert!(limiter.check()); + + mock.0.store(1099, Ordering::SeqCst); + assert!(!limiter.check()); + + mock.0.store(1100, Ordering::SeqCst); + assert!(limiter.check(), "Should pass exactly at period boundary"); + } + + #[test] + fn test_high_concurrency_consistency() { + let period = Duration::from_millis(1000); + let mock = Arc::new(MockClock(AtomicU64::new(1000))); + let limiter = Arc::new(Limiter::new(period, Arc::clone(&mock))); + + assert!(limiter.check()); + + mock.0.store(2500, Ordering::SeqCst); + + let mut handles = vec![]; + for _ in 0..20 { + let l = Arc::clone(&limiter); + handles.push(thread::spawn(move || l.check())); + } + + #[allow(clippy::unwrap_used)] + let results: Vec = handles.into_iter().map(|h| h.join().unwrap()).collect(); + + let success_count = results.iter().filter(|&&x| x).count(); + assert_eq!(success_count, 1); + + assert_eq!(limiter.last_run_ms.load(Ordering::SeqCst), 2500); + } + + #[test] + fn test_extreme_time_jump() { + let mock = MockClock(AtomicU64::new(100)); + let limiter = Limiter::new(Duration::from_millis(100), &mock); + + assert!(limiter.check()); + + mock.0.store(u64::MAX - 10, Ordering::SeqCst); + assert!(limiter.check()); + } + + #[test] + fn test_system_clock_real_path() { + let clock = SystemClock; + let start = clock.now_ms(); + assert!(start > 0); + + std::thread::sleep(Duration::from_millis(10)); + assert!(clock.now_ms() >= start); + } + + #[test] + fn test_limiter_with_system_clock_default() { + let limiter = Limiter::new(Duration::from_millis(100), SystemClock); + assert!(limiter.check()); + } + + #[test] + fn test_coverage_time_backward() { + let mock = MockClock(AtomicU64::new(5000)); + let limiter = Limiter::new(Duration::from_millis(100), &mock); + + assert!(limiter.check()); + + mock.0.store(4000, Ordering::SeqCst); + + assert!(limiter.check(), "Should pass and reset when time moves backward"); + + assert_eq!(limiter.last_run_ms.load(Ordering::SeqCst), 4000); + } +} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5a71935bd..d11dae555 100755 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -35,6 +35,7 @@ clash-verge-draft = { workspace = true } clash-verge-logging = { workspace = true } clash-verge-signal = { workspace = true } clash-verge-i18n = { workspace = true } +clash-verge-limiter = { workspace = true } tauri-plugin-clash-verge-sysinfo = { workspace = true } tauri-plugin-clipboard-manager = { workspace = true } tauri = { workspace = true, features = [ @@ -105,7 +106,6 @@ arc-swap = "1.8.0" rust_iso3166 = "0.1.14" # Use the git repo until the next release after v2.0.0. dark-light = { git = "https://github.com/rust-dark-light/dark-light" } -governor = "0.10.4" [target.'cfg(windows)'.dependencies] deelevate = { workspace = true } diff --git a/src-tauri/src/core/tray/mod.rs b/src-tauri/src/core/tray/mod.rs index aa8fbed00..b6f18dd57 100644 --- a/src-tauri/src/core/tray/mod.rs +++ b/src-tauri/src/core/tray/mod.rs @@ -9,7 +9,7 @@ use crate::{ Type, cmd, config::Config, feat, logging, module::lightweight::is_in_lightweight_mode, utils::dirs::find_target_icons, }; -use governor::{DefaultDirectRateLimiter, Quota, RateLimiter}; +use clash_verge_limiter::{Limiter, SystemClock, SystemLimiter}; use tauri::tray::TrayIconBuilder; use tauri_plugin_clash_verge_sysinfo::is_current_app_handle_admin; use tauri_plugin_mihomo::models::Proxies; @@ -19,7 +19,6 @@ use super::handle; use anyhow::Result; use smartstring::alias::String; use std::collections::HashMap; -use std::num::NonZeroU32; use std::time::Duration; use tauri::{ AppHandle, Wry, @@ -33,13 +32,13 @@ use menu_def::{MenuIds, MenuTexts}; type ProxyMenuItem = (Option>, Vec>>); -const TRAY_CLICK_DEBOUNCE_MS: u64 = 1_275; +const TRAY_CLICK_DEBOUNCE_MS: u64 = 300; #[derive(Clone)] struct TrayState {} pub struct Tray { - limiter: DefaultDirectRateLimiter, + limiter: SystemLimiter, } impl TrayState { @@ -136,11 +135,7 @@ impl Default for Tray { #[allow(clippy::unwrap_used)] fn default() -> Self { Self { - limiter: RateLimiter::direct( - Quota::with_period(Duration::from_millis(TRAY_CLICK_DEBOUNCE_MS)) - .unwrap() - .allow_burst(NonZeroU32::new(1).unwrap()), - ), + limiter: Limiter::new(Duration::from_millis(TRAY_CLICK_DEBOUNCE_MS), SystemClock), } } } @@ -462,11 +457,11 @@ impl Tray { } fn should_handle_tray_click(&self) -> bool { - let res = self.limiter.check().is_ok(); - if !res { + let allow = self.limiter.check(); + if !allow { logging!(debug, Type::Tray, "tray click rate limited"); } - res + allow } } diff --git a/src-tauri/src/utils/window_manager.rs b/src-tauri/src/utils/window_manager.rs index aa9081f62..30ee4abcb 100644 --- a/src-tauri/src/utils/window_manager.rs +++ b/src-tauri/src/utils/window_manager.rs @@ -1,8 +1,7 @@ use crate::{core::handle, utils::resolve::window::build_new_window}; +use clash_verge_limiter::Limiter; use clash_verge_logging::{Type, logging}; -use governor::{DefaultDirectRateLimiter, Quota, RateLimiter}; use once_cell::sync::Lazy; -use std::num::NonZeroU32; use std::pin::Pin; use std::time::Duration; use tauri::{Manager as _, WebviewWindow, Wry}; @@ -40,22 +39,20 @@ pub enum WindowState { } // 窗口操作防抖机制 -const WINDOW_OPERATION_DEBOUNCE_MS: u64 = 1_275; -static WINDOW_OPERATION_LIMITER: Lazy = Lazy::new(|| { - #[allow(clippy::unwrap_used)] - RateLimiter::direct( - Quota::with_period(Duration::from_millis(WINDOW_OPERATION_DEBOUNCE_MS)) - .unwrap() - .allow_burst(NonZeroU32::new(1).unwrap()), +const WINDOW_OPERATION_DEBOUNCE_MS: u64 = 625; +static WINDOW_OPERATION_LIMITER: Lazy = Lazy::new(|| { + Limiter::new( + Duration::from_millis(WINDOW_OPERATION_DEBOUNCE_MS), + clash_verge_limiter::SystemClock, ) }); fn should_handle_window_operation() -> bool { - let res = WINDOW_OPERATION_LIMITER.check().is_ok(); - if !res { + let allow = WINDOW_OPERATION_LIMITER.check(); + if !allow { logging!(debug, Type::Window, "window operation rate limited"); } - res + allow } /// 统一的窗口管理器