fix: recover webview lazily and stop ws leak after render process ter… (#7410)

* 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
This commit is contained in:
GrainFull
2026-07-04 00:21:03 +00:00
committed by GitHub
parent 336bd9d735
commit 1f76c67afb
4 changed files with 101 additions and 1 deletions
+4 -1
View File
@@ -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);
}
};
+6
View File
@@ -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;
+81
View File
@@ -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<WebviewWindow, String> {
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;
+10
View File
@@ -218,6 +218,16 @@ impl WindowManager {
fn activate_window(window: &WebviewWindow<Wry>) -> 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. 如果窗口最小化,先取消最小化