fix(exit): bound session-ending cleanup with a hard deadline

Refs #8180
This commit is contained in:
wonfen
2026-10-06 23:18:17 +08:00
parent 2caff762b5
commit a1bebf6de1
5 changed files with 115 additions and 33 deletions
Generated
+1 -1
View File
@@ -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",
+1
View File
@@ -8,6 +8,7 @@
- 修复多个代理集合包含同名节点时,代理组中的节点变灰并显示 ambiguous、无法选择的问题
- 修复手动更新时重复下载后台已下载的安装包、启动时选「稍后」后每次启动都重新下载的问题
- 修复下载更新时镜像缓慢或卡住不会切换下载地址、点击「取消」无法中止下载的问题
- 修复从 Dock 退出、注销或关机时,应用可能长时间卡住无法退出的问题
**🖥️ Windows**
+4 -4
View File
@@ -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
+98 -18
View File
@@ -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<CleanupResult> {
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<F, Fut, T>(cleanup: F, deadline: Duration) -> anyhow::Result<T>
where
F: FnOnce() -> Fut + Send + 'static,
Fut: std::future::Future<Output = T>,
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()));
+11 -10
View File
@@ -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() {