diff --git a/Cargo.lock b/Cargo.lock index b6457ab9c..32c453c88 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1106,6 +1106,7 @@ dependencies = [ "clash-verge-limiter", "clash-verge-logging", "clash-verge-media-unlock", + "clash-verge-signal", "clash_verge_service_ipc", "criterion", "dark-light", @@ -1225,6 +1226,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "clash-verge-signal" +version = "0.1.0" +dependencies = [ + "clash-verge-logging", + "log", + "tokio", +] + [[package]] name = "clash_verge_service_ipc" version = "2.7.6" diff --git a/Cargo.toml b/Cargo.toml index 65ba8e0c8..3dffbca78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/clash-verge-draft", "crates/clash-verge-media-unlock", "crates/clash-verge-logging", + "crates/clash-verge-signal", "crates/tauri-plugin-clash-verge-sysinfo", "crates/clash-verge-i18n", "crates/clash-verge-limiter", @@ -53,6 +54,7 @@ muda = { git = "https://github.com/wonfen/muda.git", rev = "547de49352791653fbdf clash-verge-draft = { path = "crates/clash-verge-draft" } clash-verge-media-unlock = { path = "crates/clash-verge-media-unlock" } 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" } tracing-estuary = { git = "https://github.com/Tunglies/tracing-estuary.git", branch = "main" } diff --git a/crates/clash-verge-signal/Cargo.toml b/crates/clash-verge-signal/Cargo.toml new file mode 100644 index 000000000..f8a03ac48 --- /dev/null +++ b/crates/clash-verge-signal/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "clash-verge-signal" +version = "0.1.0" +edition = "2024" +rust-version = "1.91" + +[dependencies] +clash-verge-logging = { workspace = true } +log = { workspace = true } +tokio = { workspace = true, features = ["signal"] } + +[lints] +workspace = true diff --git a/crates/clash-verge-signal/src/lib.rs b/crates/clash-verge-signal/src/lib.rs new file mode 100644 index 000000000..7ddc283aa --- /dev/null +++ b/crates/clash-verge-signal/src/lib.rs @@ -0,0 +1,88 @@ +//! Linux-only termination signal bridge. +//! +//! Upstream Tauri delivers session-end and termination events to the app on +//! Windows and macOS, but has no equivalent on Linux: a desktop session may +//! end without any window or run event reaching the process. Linux builds +//! keep this crate to listen for SIGTERM, SIGINT and SIGHUP and forward them +//! to the app quit flow, so system proxy and TUN cleanup still runs before +//! the process dies. Non-Linux builds compile this crate to nothing and must +//! not depend on it. + +#![cfg(target_os = "linux")] + +use std::sync::{ + OnceLock, + atomic::{AtomicBool, Ordering}, +}; + +use clash_verge_logging::{Type, logging}; + +mod unix; + +pub(crate) static RUNTIME: OnceLock> = OnceLock::new(); +pub(crate) static SHUTDOWN_LATCH: ShutdownLatch = ShutdownLatch::new(); + +/// Serializes signal-triggered cleanups: a signal arriving while a quit +/// attempt is still in flight is dropped instead of overlapping it. +pub(crate) struct ShutdownLatch { + is_cleaning_up: AtomicBool, +} + +impl ShutdownLatch { + const fn new() -> Self { + Self { + is_cleaning_up: AtomicBool::new(false), + } + } + + fn try_begin(&self) -> bool { + self.is_cleaning_up + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + } + + fn finish(&self) { + self.is_cleaning_up.store(false, Ordering::Release); + } +} + +/// Spawns the Linux signal loop on a dedicated runtime so it survives the +/// teardown of any app-owned runtime. The handler runs once per accepted +/// signal; the quit flow it invokes owns the exit-or-cancel decision. +pub fn register(f: F) +where + F: Fn() -> Fut + Send + Sync + 'static, + Fut: Future + Send + 'static, +{ + RUNTIME.get_or_init(|| match tokio::runtime::Runtime::new() { + Ok(rt) => Some(rt), + Err(e) => { + logging!( + info, + Type::SystemSignal, + "register shutdown signal failed, create tokio runtime error: {}", + e + ); + None + } + }); + + unix::register(f); +} + +#[cfg(test)] +mod tests { + use super::ShutdownLatch; + + #[test] + fn finished_attempt_releases_latch_for_the_next_signal() { + let latch = ShutdownLatch::new(); + + assert!(latch.try_begin()); + assert!(!latch.try_begin()); + + latch.finish(); + + assert!(latch.try_begin()); + } +} diff --git a/crates/clash-verge-signal/src/unix.rs b/crates/clash-verge-signal/src/unix.rs new file mode 100644 index 000000000..5bbcb779a --- /dev/null +++ b/crates/clash-verge-signal/src/unix.rs @@ -0,0 +1,74 @@ +use clash_verge_logging::{Type, logging}; +use tokio::signal::unix::{SignalKind, signal}; + +use crate::{RUNTIME, SHUTDOWN_LATCH}; + +pub fn register(f: F) +where + F: Fn() -> Fut + Send + Sync + 'static, + Fut: Future + Send + 'static, +{ + if let Some(Some(rt)) = RUNTIME.get() { + rt.spawn(async move { + let mut sigterm = match signal(SignalKind::terminate()) { + Ok(s) => s, + Err(e) => { + logging!(error, Type::SystemSignal, "Failed to register SIGTERM: {}", e); + return; + } + }; + let mut sigint = match signal(SignalKind::interrupt()) { + Ok(s) => s, + Err(e) => { + logging!(error, Type::SystemSignal, "Failed to register SIGINT: {}", e); + return; + } + }; + let mut sighup = match signal(SignalKind::hangup()) { + Ok(s) => s, + Err(e) => { + logging!(error, Type::SystemSignal, "Failed to register SIGHUP: {}", e); + return; + } + }; + + loop { + let signal_name; + tokio::select! { + _ = sigterm.recv() => { + signal_name = "SIGTERM"; + } + _ = sigint.recv() => { + signal_name = "SIGINT"; + } + _ = sighup.recv() => { + signal_name = "SIGHUP"; + } + else => { + break; + } + } + + if !SHUTDOWN_LATCH.try_begin() { + logging!( + info, + Type::SystemSignal, + "Already shutting down, ignoring repeated signal: {}", + signal_name + ); + continue; + } + logging!(info, Type::SystemSignal, "Caught signal {}", signal_name); + + f().await; + SHUTDOWN_LATCH.finish(); + } + }); + } else { + logging!( + error, + Type::SystemSignal, + "register shutdown signal failed, RUNTIME is not available" + ); + } +} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 736e6c088..a6b6a53fe 100755 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -115,6 +115,10 @@ specta-typescript = "=0.0.12" [target.'cfg(unix)'.dependencies] libc = "0.2.190" +[target.'cfg(target_os = "linux")'.dependencies] +# Upstream has no session-end signal support on Linux; see crates/clash-verge-signal. +clash-verge-signal = { workspace = true } + [target.'cfg(target_os = "macos")'.dependencies] tauri-plugin-wdio-webdriver = { version = "=1.4.0", optional = true } objc2 = "0.6.5" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 04b8ee8ea..7e11e5c02 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -230,6 +230,8 @@ pub fn run() -> std::process::ExitCode { if let Err(panic) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { resolve::resolve_setup_async(); resolve::resolve_setup_sync(); + #[cfg(target_os = "linux")] + resolve::init_signal(); })) { log_setup_panic("window-core", panic); } diff --git a/src-tauri/src/utils/resolve/mod.rs b/src-tauri/src/utils/resolve/mod.rs index 0d9dc8ca5..7ba959ae3 100644 --- a/src-tauri/src/utils/resolve/mod.rs +++ b/src-tauri/src/utils/resolve/mod.rs @@ -188,6 +188,13 @@ async fn init_silent_updater() { logging!(info, Type::Setup, "Silent updater initialized"); } +// Upstream has no session-end signal support on Linux; see crates/clash-verge-signal. +#[cfg(target_os = "linux")] +pub(crate) fn init_signal() { + logging!(debug, Type::Setup, "Initializing signal handlers..."); + clash_verge_signal::register(feat::quit); +} + async fn init_work_config() { logging_error!(Type::Setup, init::init_config().await); }