diff --git a/Cargo.lock b/Cargo.lock index d4bb35416..29154d1cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7424,7 +7424,7 @@ dependencies = [ [[package]] name = "tauri-plugin-mihomo" version = "0.6.0" -source = "git+https://github.com/clash-verge-rev/tauri-plugin-mihomo?branch=main#ba8434c08c869916b8041d707ad66599bb5230b2" +source = "git+https://github.com/clash-verge-rev/tauri-plugin-mihomo?branch=main#7a03d16f4d39b1bb885b7babe66b9e153e34b615" dependencies = [ "arc-swap", "clashmap", diff --git a/Changelog.md b/Changelog.md index 6078a6885..d5c273e6f 100644 --- a/Changelog.md +++ b/Changelog.md @@ -8,6 +8,7 @@ - 修复多个代理集合包含同名节点时,代理组中的节点变灰并显示 ambiguous、无法选择的问题 - 修复手动更新时重复下载后台已下载的安装包、启动时选「稍后」后每次启动都重新下载的问题 - 修复下载更新时镜像缓慢或卡住不会切换下载地址、点击「取消」无法中止下载的问题 +- 修复从 Dock 退出、注销或关机时,应用可能长时间卡住无法退出的问题 **🖥️ Windows** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15cb9cd8e..5cdbcc21c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -297,7 +297,7 @@ importers: version: 2.5.1(react@19.3.0) tauri-plugin-mihomo-api: specifier: github:clash-verge-rev/tauri-plugin-mihomo#main - version: https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/ba8434c08c869916b8041d707ad66599bb5230b2 + version: https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/7a03d16f4d39b1bb885b7babe66b9e153e34b615 types-pac: specifier: ^1.0.3 version: 1.0.3 @@ -4865,8 +4865,8 @@ packages: resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} engines: {node: '>=18'} - tauri-plugin-mihomo-api@https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/ba8434c08c869916b8041d707ad66599bb5230b2: - resolution: {gitHosted: true, integrity: sha512-dxCM8Uw9QJGOicxNZwBQYnDScIIO8E6wlVLEcZ6RQGDfK8UMuFFL1tiQYWxBjUd9z5JBqjHKOCoYTGezxd/3Yg==, tarball: https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/ba8434c08c869916b8041d707ad66599bb5230b2} + tauri-plugin-mihomo-api@https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/7a03d16f4d39b1bb885b7babe66b9e153e34b615: + resolution: {gitHosted: true, integrity: sha512-1d1ELlQxqD6vb/77T7A9AKdLjPP1s9c+21i6D6Kw96DoCqxC5/aJFcG+ecYpSuSCggoYyVwWUuol1StgasB8Gw==, tarball: https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/7a03d16f4d39b1bb885b7babe66b9e153e34b615} version: 0.6.0 teex@1.0.1: @@ -10190,7 +10190,7 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 - tauri-plugin-mihomo-api@https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/ba8434c08c869916b8041d707ad66599bb5230b2: + tauri-plugin-mihomo-api@https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/7a03d16f4d39b1bb885b7babe66b9e153e34b615: dependencies: '@tauri-apps/api': 2.12.1 diff --git a/src-tauri/src/feat/window.rs b/src-tauri/src/feat/window.rs index d07fd0971..1fa9807a8 100644 --- a/src-tauri/src/feat/window.rs +++ b/src-tauri/src/feat/window.rs @@ -3,12 +3,32 @@ use crate::core::{CoreManager, handle}; use crate::module::lightweight; use crate::utils; use crate::utils::window_manager::WindowManager; +use anyhow::{Context as _, anyhow}; use clash_verge_logging::{Type, logging}; use parking_lot::Mutex; +use std::sync::mpsc::RecvTimeoutError; use tokio::time::Duration; #[cfg(target_os = "macos")] use tokio::time::timeout; +#[cfg(target_os = "windows")] +const SESSION_ENDING_STOP_TIMEOUT: Duration = Duration::from_secs(2); +// TODO(macOS): a DNS script already running can outlast this and write after exit. +#[cfg(not(target_os = "windows"))] +const SESSION_ENDING_STOP_TIMEOUT: Duration = Duration::from_secs(3); + +// Outlasts the script's own kill deadline. +#[cfg(target_os = "macos")] +const DNS_RESTORE_TIMEOUT: Duration = + crate::utils::resolve::dns::DNS_SCRIPT_TIMEOUT.saturating_add(Duration::from_secs(1)); +#[cfg(not(target_os = "macos"))] +const DNS_RESTORE_TIMEOUT: Duration = Duration::ZERO; + +// Backstop for when the cleanup's own deadlines cannot fire. +const SESSION_ENDING_HARD_DEADLINE: Duration = SESSION_ENDING_STOP_TIMEOUT + .saturating_add(DNS_RESTORE_TIMEOUT) + .saturating_add(Duration::from_secs(1)); + #[derive(Debug, Clone, Default)] pub struct CleanupResult { pub all_success: bool, @@ -81,13 +101,7 @@ where async fn restore_dns_after_core_stop() -> bool { #[cfg(target_os = "macos")] - // Outlasts the script's own deadline, so a hung restore is killed with its process group before exit. - match timeout( - crate::utils::resolve::dns::DNS_SCRIPT_TIMEOUT + Duration::from_secs(1), - crate::utils::resolve::dns::restore_public_dns(), - ) - .await - { + match timeout(DNS_RESTORE_TIMEOUT, crate::utils::resolve::dns::restore_public_dns()).await { Ok(Ok(())) => { logging!(debug, Type::Window, "DNS设置已恢复"); true @@ -193,15 +207,50 @@ pub async fn clean_async() -> CleanupResult { result } -pub async fn clean_session_ending_best_effort() -> CleanupResult { - #[cfg(target_os = "windows")] - let stop_timeout = Duration::from_secs(2); - // TODO(macOS): a DNS script already blocked when the session ends holds the lifecycle lock for up to - // DNS_SCRIPT_TIMEOUT, past this deadline; the stop is cancelled, the DNS restore skipped, and the - // script can still write after the app exits. - #[cfg(not(target_os = "windows"))] - let stop_timeout = Duration::from_secs(3); +pub fn clean_session_ending_with_hard_deadline() -> Option { + match run_with_hard_deadline(clean_session_ending_best_effort, SESSION_ENDING_HARD_DEADLINE) { + Ok(result) => Some(result), + Err(error) => { + logging!( + warn, + Type::System, + "Session-ending cleanup abandoned; exiting without it: {error:#}" + ); + None + } + } +} +/// Avoids the app runtime, whose workers may wait on the blocked main thread. +fn run_with_hard_deadline(cleanup: F, deadline: Duration) -> anyhow::Result +where + F: FnOnce() -> Fut + Send + 'static, + Fut: std::future::Future, + T: Send + 'static, +{ + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + std::thread::Builder::new() + .name("clash-verge-session-ending".into()) + .spawn(move || { + let runtime = match tokio::runtime::Builder::new_current_thread().enable_all().build() { + Ok(runtime) => runtime, + Err(error) => { + let _ = sender.send(Err(error)); + return; + } + }; + // Send first: dropping the runtime waits for blocking tasks. + let _ = sender.send(Ok(runtime.block_on(cleanup()))); + }) + .context("failed to spawn the cleanup thread")?; + match receiver.recv_timeout(deadline) { + Ok(result) => result.context("failed to build the cleanup runtime"), + Err(RecvTimeoutError::Timeout) => Err(anyhow!("still running after {} ms", deadline.as_millis())), + Err(RecvTimeoutError::Disconnected) => Err(anyhow!("cleanup thread ended without a result")), + } +} + +async fn clean_session_ending_best_effort() -> CleanupResult { logging!( info, Type::System, @@ -226,12 +275,12 @@ pub async fn clean_session_ending_best_effort() -> CleanupResult { } }, async move { - tokio::time::sleep(stop_timeout).await; + tokio::time::sleep(SESSION_ENDING_STOP_TIMEOUT).await; logging!( warn, Type::Window, "Session-ending best-effort core stop timed out after {} seconds; OS or session exit is already in progress", - stop_timeout.as_secs() + SESSION_ENDING_STOP_TIMEOUT.as_secs() ); }, restore_dns_after_core_stop, @@ -274,7 +323,8 @@ pub async fn hide() { #[cfg(test)] mod tests { use super::{ - run_interactive_cleanup_transition, run_session_ending_cleanup_transition, should_abort_exit_after_cleanup, + run_interactive_cleanup_transition, run_session_ending_cleanup_transition, run_with_hard_deadline, + should_abort_exit_after_cleanup, }; use parking_lot::Mutex; use std::{ @@ -282,8 +332,10 @@ mod tests { sync::{ Arc, atomic::{AtomicBool, Ordering}, + mpsc, }, task::Poll, + time::Duration, }; use tokio::sync::Barrier; @@ -306,6 +358,34 @@ mod tests { assert!(!should_abort_exit_after_cleanup(true)); } + #[test] + fn hard_deadline_returns_while_cleanup_blocks_its_thread() { + let (release, blocked) = mpsc::channel::<()>(); + + let result = run_with_hard_deadline( + move || async move { + let _ = blocked.recv(); + }, + Duration::from_millis(50), + ); + + assert!(matches!(result, Err(error) if error.to_string().starts_with("still running"))); + drop(release); + } + + #[test] + fn hard_deadline_cleanup_runs_its_own_timers() { + let result = run_with_hard_deadline( + || async { + tokio::time::sleep(Duration::from_millis(10)).await; + true + }, + Duration::from_secs(5), + ); + + assert!(result.is_ok_and(|done| done)); + } + #[tokio::test] async fn interactive_cleanup_awaits_barrier_controlled_stop_without_cancellation() { let calls = Arc::new(Mutex::new(Vec::new())); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index eebd401dd..507e36389 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -472,23 +472,24 @@ pub fn run() -> std::process::ExitCode { event_handlers::handle_reopen(has_visible_windows).await; }); } - tauri::RunEvent::Exit => AsyncHandler::block_on(async { + tauri::RunEvent::Exit => { // Windows session ending currently reaches Tao as WM_ENDSESSION and // destroys the loop without a preventable ExitRequested event. if !handle::Handle::global().is_exiting() { handle::Handle::global().set_is_exiting(); - let cleanup_result = feat::clean_session_ending_best_effort().await; - logging!( - info, - Type::System, - "Unpreventable session-ending best-effort cleanup returned - core stopped: {}, all cleanup successful: {}", - cleanup_result.core_stopped, - cleanup_result.all_success - ); + if let Some(cleanup_result) = feat::clean_session_ending_with_hard_deadline() { + logging!( + info, + Type::System, + "Unpreventable session-ending best-effort cleanup returned - core stopped: {}, all cleanup successful: {}", + cleanup_result.core_stopped, + cleanup_result.all_success + ); + } } logging!(info, Type::System, "Application exited"); crate::core::logger::Logger::global().flush_logs(); - }), + } #[allow(unused_variables)] tauri::RunEvent::ExitRequested { api, code, .. } => { if module::lightweight::is_in_lightweight_mode() && !handle::Handle::global().is_exiting() {