From a3598fd97694d3ad4a1c5ea2507dc4880ca315e0 Mon Sep 17 00:00:00 2001 From: thisTom Date: Thu, 11 Jun 2026 17:24:06 +0800 Subject: [PATCH] fix: prevent deadlock when relaunching after in-app update (#4069) The updater's relaunch() (and app.restart()) triggers ExitRequested with code RESTART_EXIT_CODE, which the handler treated as a regular exit: it called api.prevent_exit() and spawned an async cleanup task. However Tauri silently ignores prevent_exit() for restart requests (see ExitRequestApi::prevent_exit docs), so the event loop keeps shutting down regardless and fires every plugin's RunEvent::Exit hook. Two threads then deadlock: - the spawned cleanup task runs save_window_state on a tokio worker, holding the window-state plugin's internal mutex while querying window geometry, which dispatches to the main thread and blocks; - the main thread, already inside the plugin's own RunEvent::Exit hook, blocks on that same mutex. The app freezes forever on the restarting screen with the update already installed; force-quit + reopen comes back on the new version (#3998). Confirmed on macOS by sampling the frozen process: main thread parked in tauri_plugin_window_state save_window_state mutex lock, tokio worker parked in is_maximized -> mpsc recv. Fix: classify exit requests (None / restart / user exit) and let restart requests fall through untouched to Tauri's default restart flow (RunEvent::Exit -> re-exec). On that path window state is saved by the plugin's exit hook on the main thread, the tray icon is cleaned up by Tauri's internal cleanup_before_exit, and proxy/live config restore is unnecessary because the new instance takes over immediately. The regular exit path (tray quit) is unchanged. With the fix, a simulated updater relaunch (request_restart) re-execs the new process in under 200ms, 3/3 runs; normal quit still performs full cleanup. Co-authored-by: thisTom <19346741+thisTom@users.noreply.github.com> --- src-tauri/src/lib.rs | 99 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 91 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c96b752e5..a93e6f8be 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1443,14 +1443,37 @@ pub fn run() { app.run(|app_handle, event| { // 处理退出请求(所有平台) if let RunEvent::ExitRequested { api, code, .. } = &event { - // code 为 None 表示运行时自动触发(如隐藏窗口的 WebView 被回收导致无存活窗口), - // 此时应仅阻止退出、保持托盘后台运行; - // code 为 Some(_) 表示用户主动调用 app.exit() 退出(如托盘菜单"退出"), - // 此时执行清理后退出。 - if code.is_none() { - log::info!("运行时触发退出请求(无存活窗口),阻止退出以保持托盘后台运行"); - api.prevent_exit(); - return; + match classify_exit_request(*code) { + // code 为 None 表示运行时自动触发(如隐藏窗口的 WebView 被回收导致无存活窗口), + // 此时应仅阻止退出、保持托盘后台运行。 + ExitRequestAction::StayInTray => { + log::info!("运行时触发退出请求(无存活窗口),阻止退出以保持托盘后台运行"); + api.prevent_exit(); + return; + } + // code 为 RESTART_EXIT_CODE:app.restart() / 自更新 relaunch 发起的重启。 + // 这条路径上 prevent_exit() 会被 Tauri 忽略,事件循环必定退出,随后由 + // Tauri 在 RunEvent::Exit 后用新二进制 re-exec(macOS 会按更新后的 + // Info.plist 解析可执行名)。 + // + // 绝不能复用下面的异步清理任务:该任务在 tokio 线程调 save_window_state, + // 持有 window-state 插件锁的同时向主线程查询窗口几何;而主线程此刻正在 + // 退出事件循环,并在插件自带的 RunEvent::Exit 钩子里等待同一把锁——双方 + // 互等造成进程永久卡死(更新已安装但应用冻结、不再重启,见 #3998)。 + // + // 重启路径交还 Tauri 默认流程即可: + // - 窗口状态:插件 Exit 钩子在主线程保存(同线程读取窗口几何,无死锁) + // - 托盘图标:Tauri 内部 cleanup_before_exit 清理,正常走 Drop + // - 代理/Live 配置:无需恢复,重启后新实例立即接管并恢复代理状态 + // - 100ms 落盘等待:重启前的 DB 写入均为命令驱动、此刻已完成, + // 与所有 Tauri 应用默认重启路径的行为一致,无需额外等待 + ExitRequestAction::DeferToTauriRestart => { + log::info!("收到重启请求 (code={code:?}),交由 Tauri 默认重启流程 re-exec"); + return; + } + // 其它 Some(_):用户主动调用 app.exit() 退出(如托盘菜单"退出"), + // 此时执行清理后退出。 + ExitRequestAction::CleanupAndExit => {} } log::info!("收到用户主动退出请求 (code={code:?}),开始清理..."); @@ -1891,6 +1914,36 @@ fn show_database_init_error_dialog( .blocking_show() } +// ============================================================ +// 退出请求分类 +// ============================================================ + +/// `RunEvent::ExitRequested` 的三类来源,处理方式必须区分。 +/// +/// 关键约束:重启请求(`code == RESTART_EXIT_CODE`)上 `prevent_exit()` 会被 +/// Tauri 静默忽略(见 `ExitRequestApi::prevent_exit` 文档),事件循环必定继续 +/// 退出并触发各插件的 `RunEvent::Exit` 钩子;任何与之并发的自定义清理任务都 +/// 可能与插件退出钩子争用同一状态而死锁。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ExitRequestAction { + /// `code` 为 `None`:运行时自动触发(如隐藏窗口的 WebView 被回收导致无存活 + /// 窗口),阻止退出、保持托盘后台运行。 + StayInTray, + /// `code` 为 `RESTART_EXIT_CODE`:`app.restart()` / 自更新 relaunch 发起的 + /// 重启,不拦截、不做自定义清理,交还 Tauri 默认 re-exec 流程。 + DeferToTauriRestart, + /// 其它 `Some(_)`:用户主动退出(托盘「退出」等),执行完整异步清理后结束进程。 + CleanupAndExit, +} + +fn classify_exit_request(code: Option) -> ExitRequestAction { + match code { + None => ExitRequestAction::StayInTray, + Some(tauri::RESTART_EXIT_CODE) => ExitRequestAction::DeferToTauriRestart, + Some(_) => ExitRequestAction::CleanupAndExit, + } +} + // ============================================================ // 在应用主动退出前显式持久化窗口状态 // ============================================================ @@ -1908,3 +1961,33 @@ pub fn save_window_state_before_exit(app_handle: &tauri::AppHandle) { log::info!("已在退出前保存窗口状态"); } } + +#[cfg(test)] +mod tests { + use super::{classify_exit_request, ExitRequestAction}; + + #[test] + fn no_code_keeps_app_alive_in_tray() { + assert_eq!(classify_exit_request(None), ExitRequestAction::StayInTray); + } + + #[test] + fn restart_exit_code_defers_to_tauri_default_restart() { + assert_eq!( + classify_exit_request(Some(tauri::RESTART_EXIT_CODE)), + ExitRequestAction::DeferToTauriRestart + ); + } + + #[test] + fn user_exit_codes_run_cleanup_then_exit() { + assert_eq!( + classify_exit_request(Some(0)), + ExitRequestAction::CleanupAndExit + ); + assert_eq!( + classify_exit_request(Some(1)), + ExitRequestAction::CleanupAndExit + ); + } +}