From 1f76c67afbafde171335e8ad7d1e038609aa2818 Mon Sep 17 00:00:00 2001 From: GrainFull <651487447@qq.com> Date: Sat, 4 Jul 2026 08:21:03 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20recover=20webview=20lazily=20and=20stop?= =?UTF-8?q?=20ws=20leak=20after=20render=20process=20ter=E2=80=A6=20(#7410?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: recover webview lazily and stop ws leak after render process termination macOS 内存压力下 WKWebView 渲染进程可能被系统杀掉,导致: 1. 从托盘打开主窗口时白屏; 2. 前端无法清理 Mihomo WebSocket 订阅,孤儿订阅持续将 >1KB 的 payload 写入 tauri ChannelDataIpcQueue 且无人取走,主进程内存 以 ~150MB/h 持续增长。 注册 on_web_content_process_terminate 钩子: - 立即清理全部 Mihomo WebSocket 订阅阻断泄漏(托盘速率任务会自动重连); - 窗口可见时立即 reload 拉起渲染进程恢复页面;窗口隐藏/最小化时仅置位 待重载标记,等用户下次打开窗口时再 reload——系统正是因内存压力才杀掉 不可见窗口的渲染进程,立即重建既浪费内存也可能形成反复被杀的循环。 另修复 tray_event 配置为 tray_menu 时误报 invalid tray event 警告。 * fix: gate logging import to macos to satisfy clippy on other platforms --- src-tauri/src/core/tray/mod.rs | 5 +- src-tauri/src/lib.rs | 6 ++ src-tauri/src/utils/resolve/window.rs | 81 +++++++++++++++++++++++++++ src-tauri/src/utils/window_manager.rs | 10 ++++ 4 files changed, 101 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/core/tray/mod.rs b/src-tauri/src/core/tray/mod.rs index 0b69929c9..6981e83c7 100644 --- a/src-tauri/src/core/tray/mod.rs +++ b/src-tauri/src/core/tray/mod.rs @@ -933,7 +933,10 @@ fn on_tray_icon_event(_tray_icon: &TrayIcon, tray_event: TrayIconEvent) { WindowManager::show_main_window().await; }; } - _ => { + // tray_menu 模式下菜单由系统原生展示(见 set_show_menu_on_left_click), + // 左键点击事件无需额外处理 + TrayAction::TrayMenu => {} + TrayAction::Unknown => { logging!(warn, Type::Tray, "invalid tray event: {}", verge_tray_event); } }; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b2d28f15e..ed3fc7f7d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -289,6 +289,12 @@ pub fn run() { }) .invoke_handler(app_init::generate_handlers()); + // macOS 内存压力下 WKWebView 渲染进程可能被系统终止(表现为白屏), + // 注册恢复钩子:清理孤儿 WebSocket 订阅防止内存泄漏;窗口可见时立即 reload + // 恢复页面,不可见时延迟到用户下次打开窗口再 reload。 + #[cfg(target_os = "macos")] + let builder = builder.on_web_content_process_terminate(resolve::window::on_web_content_process_terminated); + mod event_handlers { #[cfg(target_os = "macos")] use crate::module::lightweight; diff --git a/src-tauri/src/utils/resolve/window.rs b/src-tauri/src/utils/resolve/window.rs index 260e4f82e..3917ef13a 100644 --- a/src-tauri/src/utils/resolve/window.rs +++ b/src-tauri/src/utils/resolve/window.rs @@ -5,6 +5,9 @@ use tauri::{Theme, WebviewWindow}; use crate::{config::Config, core::handle, utils::resolve::window_script::build_window_initial_script}; use clash_verge_logging::{Type, logging_error}; +// logging 仅在 macOS 的渲染进程恢复逻辑中使用 +#[cfg(target_os = "macos")] +use clash_verge_logging::logging; const DARK_BACKGROUND_COLOR: Color = Color(46, 48, 61, 255); // #2E303D const LIGHT_BACKGROUND_COLOR: Color = Color(245, 245, 245, 255); // #F5F5F5 @@ -109,12 +112,90 @@ pub async fn build_new_window() -> Result { Ok(window) => { logging_error!(Type::Window, window.set_background_color(Some(background_color))); restore_default_size_if_needed(&window); + // 全新窗口的页面即为最新状态,丢弃旧窗口遗留的待重载标记,避免多余 reload + #[cfg(target_os = "macos")] + take_webview_needs_reload(); Ok(window) } Err(e) => Err(e.to_string()), } } +/// 渲染进程死亡、页面待重载标记(macOS) +/// +/// 渲染进程在窗口不可见时被系统终止后置位,由下次激活窗口的路径取走并执行 reload。 +#[cfg(target_os = "macos")] +static WEBVIEW_NEEDS_RELOAD: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// 取出并清除"页面待重载"标记 +/// +/// # Returns +/// * `bool` - 渲染进程是否曾在窗口不可见时被系统终止、页面需要 reload +#[cfg(target_os = "macos")] +pub fn take_webview_needs_reload() -> bool { + WEBVIEW_NEEDS_RELOAD.swap(false, std::sync::atomic::Ordering::SeqCst) +} + +/// WebView 渲染进程被系统终止后的恢复处理(macOS) +/// +/// macOS 在内存压力下可能杀掉 WKWebView 的 WebContent 渲染进程: +/// 1. 页面内容层消失,窗口打开后表现为白屏; +/// 2. 前端 JS 状态随之丢失,无法调用 `ws_disconnect` 清理 Mihomo WebSocket 订阅, +/// 孤儿订阅持续把大于 1KB 的 payload(如 `/connections` 全量快照)塞进 tauri 的 +/// `ChannelDataIpcQueue`,且没有存活的页面来取走,导致主进程内存无限增长。 +/// +/// 恢复策略: +/// * 窗口可见(前台被杀的罕见场景)——立即 reload 恢复页面; +/// * 窗口隐藏/最小化(托盘常驻的常见场景)——只置位待重载标记,等用户下次打开 +/// 窗口时再 reload。系统正是因内存压力才杀掉不可见窗口的渲染进程,此时立即 +/// 重建渲染进程既浪费内存,也可能形成"系统杀→拉起→再杀"的循环。 +/// +/// 注意:应用层注册 `on_web_content_process_terminate` 后会覆盖 tauri-runtime-wry +/// 的默认自动 reload 行为,因此页面死亡状态会一直保持到我们主动 reload。 +/// +/// # Arguments +/// * `webview` - 渲染进程被终止的 WebView +#[cfg(target_os = "macos")] +pub fn on_web_content_process_terminated(webview: &tauri::Webview) { + if handle::Handle::global().is_exiting() { + return; + } + + logging!( + warn, + Type::Window, + "WebView 渲染进程已被系统终止(label={}),开始恢复", + webview.label() + ); + + // 关键步骤:清理 Rust 侧所有 Mihomo WebSocket 订阅。 + // 旧页面遗留的孤儿订阅(traffic/memory/connections/logs)自此停止向已死亡的 + // IPC Channel 推送数据,阻断 ChannelDataIpcQueue 泄漏; + // 托盘速率任务的订阅也会被一并关闭,但其内部循环会在约 1 秒后自动重连。 + crate::process::AsyncHandler::spawn(|| async move { + if let Err(err) = handle::Handle::mihomo().await.clear_all_ws_connections().await { + logging!(warn, Type::Window, "清理 Mihomo WebSocket 连接失败: {err}"); + } else { + logging!(info, Type::Window, "已清理全部 Mihomo WebSocket 连接"); + } + }); + + // is_user_visible:窗口是否处于用户可感知的可见状态(未隐藏且未最小化) + let window = webview.window(); + let is_user_visible = window.is_visible().unwrap_or(false) && !window.is_minimized().unwrap_or(false); + + if is_user_visible { + // 关键步骤:窗口正被用户看着,立即 reload。加载请求会让 WKWebView + // 自动重新拉起渲染进程,恢复页面内容(解决白屏)。 + logging_error!(Type::Window, webview.reload()); + } else { + // 关键步骤:窗口不可见,延迟到下次激活窗口时再 reload(见 + // WindowManager::activate_window),避免在内存压力下重建无人观看的页面。 + WEBVIEW_NEEDS_RELOAD.store(true, std::sync::atomic::Ordering::SeqCst); + logging!(info, Type::Window, "窗口不可见,页面将在下次打开窗口时重载"); + } +} + #[cfg(test)] mod tests { use super::restored_window_size_is_too_small; diff --git a/src-tauri/src/utils/window_manager.rs b/src-tauri/src/utils/window_manager.rs index c2b762f06..21d7eee7e 100644 --- a/src-tauri/src/utils/window_manager.rs +++ b/src-tauri/src/utils/window_manager.rs @@ -218,6 +218,16 @@ impl WindowManager { fn activate_window(window: &WebviewWindow) -> WindowOperationResult { logging!(info, Type::Window, "开始激活窗口"); + // 关键步骤:渲染进程曾在窗口不可见时被系统终止(macOS 内存压力), + // 页面当前是死亡的白屏状态,显示窗口前先 reload 重新拉起渲染进程。 + #[cfg(target_os = "macos")] + if crate::utils::resolve::window::take_webview_needs_reload() { + logging!(info, Type::Window, "渲染进程曾被系统终止,激活窗口前重载页面"); + if let Err(e) = window.reload() { + logging!(warn, Type::Window, "重载页面失败: {}", e); + } + } + let mut operations_successful = true; // 1. 如果窗口最小化,先取消最小化