From 32a03ea38783c093a5722f4dd030bf82ee8eeea7 Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Mon, 19 Oct 2026 13:38:42 +0800 Subject: [PATCH] Stabilize desktop idle memory and renderer recovery --- CLAUDE.md | 8 ++ cmake/acecode_source_paths.cmake | 3 + .../.openspec.yaml | 2 + .../stabilize-desktop-idle-memory/design.md | 26 ++++ .../stabilize-desktop-idle-memory/proposal.md | 21 +++ .../specs/desktop-idle-memory/spec.md | 27 ++++ .../stabilize-desktop-idle-memory/tasks.md | 9 ++ src/apps/desktop/renderer_recovery.cpp | 23 ++++ src/apps/desktop/renderer_recovery.hpp | 51 ++++++++ src/apps/desktop/web_host.cpp | 59 ++++++++- src/apps/desktop/web_host_browser_win.hpp | 94 +++++++++++++- tests/CMakeLists.txt | 13 ++ .../helpers/renderer_recovery_smoke.cpp | 60 +++++++++ tests/desktop/renderer_recovery_smoke.py | 51 ++++++++ tests/desktop/renderer_recovery_test.cpp | 84 ++++++++++++ web/src/components/Sidebar.jsx | 120 +++++++++--------- web/src/lib/desktopTrayMenu.js | 19 ++- web/src/lib/desktopTrayMenu.test.js | 71 +++++++++++ web/src/lib/runTests.js | 2 + ...onCreationActionGuardsArchitecture.test.js | 2 +- ...sidebarStableCallbacksArchitecture.test.js | 78 ++++++++++++ ...idebarWorkspaceHeadingArchitecture.test.js | 4 +- web/src/lib/sidebarWorkspaceRefresh.test.js | 62 +++++++-- web/src/lib/structuralState.js | 96 ++++++++++++++ web/src/lib/structuralState.test.js | 108 ++++++++++++++++ web/src/lib/useStableCallback.js | 27 ++++ 26 files changed, 1037 insertions(+), 83 deletions(-) create mode 100644 openspec/changes/stabilize-desktop-idle-memory/.openspec.yaml create mode 100644 openspec/changes/stabilize-desktop-idle-memory/design.md create mode 100644 openspec/changes/stabilize-desktop-idle-memory/proposal.md create mode 100644 openspec/changes/stabilize-desktop-idle-memory/specs/desktop-idle-memory/spec.md create mode 100644 openspec/changes/stabilize-desktop-idle-memory/tasks.md create mode 100644 src/apps/desktop/renderer_recovery.cpp create mode 100644 src/apps/desktop/renderer_recovery.hpp create mode 100644 tests/desktop/helpers/renderer_recovery_smoke.cpp create mode 100644 tests/desktop/renderer_recovery_smoke.py create mode 100644 tests/desktop/renderer_recovery_test.cpp create mode 100644 web/src/lib/sidebarStableCallbacksArchitecture.test.js create mode 100644 web/src/lib/structuralState.js create mode 100644 web/src/lib/structuralState.test.js create mode 100644 web/src/lib/useStableCallback.js diff --git a/CLAUDE.md b/CLAUDE.md index 744d49a80..2a263b2bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -722,6 +722,14 @@ JS↔C++ bridge(同进程,webview `bind`,无 HTTP): 设计文档:`docs/desktop-shell/design.md` + `docs/desktop-shell/multi-workspace.md`,change:`openspec/changes/add-desktop-multi-workspace/`。 +### Desktop 挂机内存:渲染进程 OOM 的三层处理(2026-10-19) + +用户反馈:挂机一两天后解锁屏幕,Desktop 停在 WebView2 的「内存不足」错误页,只能手动刷新。本机现场:主 WebView 渲染进程 10 小时涨到 3.3 GB(上限约 4 GB),CPU 几乎为 0。CDP 堆快照比对(无头 Edge 连同一 daemon,隔几分钟两次快照,看新增且 GC 后仍存活的对象到 GC root 的路径)定位到: + +- **主因 —— 侧栏空闲渲染 + 闭包链。** 侧栏 5 秒刷新即使数据没变也把新的 Map / Set / 数组写回状态,每轮渲染好几次;`Sidebar` 里几十个依赖各异的 `useCallback` 在不同渲染里交替重建,V8 让同一次渲染的闭包共享一个作用域,旧回调就把旧渲染的整个作用域(含当时的会话列表)留住,一份接一份连成链 —— 快照里 27 分钟存活了 450 份侧栏作用域,空闲首页每次渲染约留 70 KB。修法是刷新路径上的状态一律「内容没变就不调用 setState」(`lib/structuralState.js`:`useStructuralState` 的 setter 自持最新值 ref;带 ref 同步的 setter 经 `commitIfChanged(ref, setState, updater)`,前提是该状态的每个写入方都同步 ref),空闲时不渲染,链条不再增长。**只在 updater 里返回旧值不够**:React 18 对结果与旧值相同的更新会提前放弃渲染,但 update 对象(连同 updater 闭包捕获的数据)仍挂在 hook 队列里,直到该组件下次渲染才清掉 —— 第一版修复就这样把泄漏换成了队列增长(打开会话的页面约 0.24 MB/分钟,三方 A/B 才看出来)。**别把这些状态改回普通 `useState`、在 setter 里无条件 `new Map(prev)` 或写成 `changed ? next : prev`**;`sidebarWorkspaceRefresh.test.js` 的空闲刷新零写入用例与声明检查守着。活跃使用时(会话状态、标题真有变化)侧栏照样渲染,所以链条本身也断掉了:依赖会变的状态或 props 的 13 个回调改用 `lib/useStableCallback.js`(包装函数只捕获 ref,身份永不变,ref 在 insertion effect 里更新到最新实现),剩下的 `useCallback` 只依赖 setter / ref / 其它稳定回调。**侧栏里不要再写 `useCallback(fn, [会变的状态或 props])`**,`sidebarStableCallbacksArchitecture.test.js` 会解析源码并列出违规的回调名;`useStableCallback` 不能用于渲染期间调用的函数,也不能用于需要靠身份变化触发 effect 重跑的场合。对照实测(隔离 daemon,交替「改工作区名 + 切换未读」6 分钟 60 次):改造前侧栏作用域存活 29 份并持续接链,改造后不再接链。**同类问题在 `ChatView` 与 `App` 里还在**:交替「切换会话 + 切换未读」8 分钟 160 次,ChatView 作用域存活 257 份、App 178 份,GC 后堆 +3.4 MB(约 21 KB/次),侧栏只有个位数 —— 活跃使用时的剩余增长来自这两处。 +- **webview 0.12.0 桥接泄漏。** 库注入的 `_promises[id]` 只写不删,每次 `aceDesktop_*` 调用永久留一条。`web_host.cpp::kBridgeReplyCleanupScript` 在库脚本之后接管同一套 `call` / `onReply`(消息格式不变),回包即删;升级 webview 库时确认上游是否已修。托盘菜单推送(`desktopTrayMenu.js`)相同内容 60 秒内不重复过桥。 +- **兜底 —— 渲染进程退出自动重载。** 主 WebView 此前没接 `ProcessFailed`。现在 `web_host_browser_win.hpp::install_renderer_recovery_handler` 对 RENDER_PROCESS_EXITED 投递 `Reload()`,无响应 / 浏览器进程退出只记日志;10 分钟内最多 3 次,超过保留错误页防崩溃循环(`renderer_recovery.{hpp,cpp}` 纯逻辑)。端到端冒烟 `tests/desktop/renderer_recovery_smoke.py`(`WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--js-flags=--max-old-space-size=64` 让页面几秒内 OOM,确认自动重载且桥接接管生效);macOS / Linux 尚未接对应事件。 + ### Agent Browser 是永久开发宽松浏览器(make-agent-browser-development-permissive) **这是有意的产品定位,不是漏掉的安全加固。** Agent Browser 用来开发调试,开发环境本来就一堆自签名证书、明文 HTTP、旧 TLS 和内网服务;凡是宿主能覆盖的挡板一律静默放行,**没有开关、没有徽标、没有确认页、没有逐站点例外、没有任何用户可见的策略提示**。看到「怎么不校验证书」时不要顺手加回来 —— 先看这一节。 diff --git a/cmake/acecode_source_paths.cmake b/cmake/acecode_source_paths.cmake index 9fcfe8f80..508a2fbd7 100644 --- a/cmake/acecode_source_paths.cmake +++ b/cmake/acecode_source_paths.cmake @@ -86,6 +86,9 @@ set(ACECODE_AGENT_BROWSER_HOST_SMOKE_SOURCES set(ACECODE_MICROPHONE_PERMISSION_SMOKE_SOURCES ${CMAKE_SOURCE_DIR}/tests/desktop/helpers/microphone_permission_smoke.cpp ${ACECODE_DESKTOP_WEB_HOST_SOURCE}) +set(ACECODE_RENDERER_RECOVERY_SMOKE_SOURCES + ${CMAKE_SOURCE_DIR}/tests/desktop/helpers/renderer_recovery_smoke.cpp + ${ACECODE_DESKTOP_WEB_HOST_SOURCE}) set(ACECODE_AGENT_BROWSER_POINTER_DEMO_SOURCES ${CMAKE_SOURCE_DIR}/tests/desktop/helpers/agent_browser_pointer_demo.cpp ${ACECODE_AGENT_BROWSER_HOST_SOURCE} diff --git a/openspec/changes/stabilize-desktop-idle-memory/.openspec.yaml b/openspec/changes/stabilize-desktop-idle-memory/.openspec.yaml new file mode 100644 index 000000000..c1bc90279 --- /dev/null +++ b/openspec/changes/stabilize-desktop-idle-memory/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-19 diff --git a/openspec/changes/stabilize-desktop-idle-memory/design.md b/openspec/changes/stabilize-desktop-idle-memory/design.md new file mode 100644 index 000000000..f004044c7 --- /dev/null +++ b/openspec/changes/stabilize-desktop-idle-memory/design.md @@ -0,0 +1,26 @@ +## Context + +原任务通过堆快照与隔离 daemon 对照定位侧栏闭包链、React 空更新队列和 webview 桥接 Promise 保留。实现已在当前检出,发布时统一审查纳入。 + +## Goals / Non-Goals + +目标:空闲刷新不产生无效状态写入,事件处理读取最新状态,桥接回包释放资源,Windows 渲染退出自动恢复且不形成崩溃循环。 + +不在本轮重构 ChatView/App,不改变其他平台恢复策略。 + +## Decisions + +- `useStructuralState` 与 `commitIfChanged` 在调用 setter 前比较内容;稳定 setter/ref 保持最新数据。 +- `useStableCallback` 通过 insertion effect 更新当前实现,仅用于事件处理。 +- 替换 webview 的 call/onReply,成功、失败及解析错误均移除待处理项;保持线协议。 +- 无变化托盘菜单六十秒后允许重发,覆盖 native 缓存重置。 +- 仅主渲染进程退出触发重载,滑动十分钟窗口最多三次。使用 LifetimeRef 同步借用宿主;析构先 revoke,再销毁 WebView,排队重载也检查生命周期。 +- `set_dev_tools_enabled` 仅同步使用非空 COM 引用,不存储依赖。 + +## Risks / Trade-offs + +深比较增加刷新 CPU 成本,但减少空闲渲染。自动刷新可能丢失页面尚未持久化的瞬态状态,优于永久错误页。原任务已完成侧栏内存对照与 UI 冒烟,不能据此宣称 ChatView/App 内存问题全部解决。 + +## Validation + +原任务记录:3254 条前端测试通过,侧栏工作区改名/未读交替操作 60 次后不再形成旧作用域链。发布补验使用当前源码重新执行前端全量测试与构建、分层检查、RendererRecovery 定向测试和真实 WebView2 OOM 冒烟;结果登记在 tasks.md。 diff --git a/openspec/changes/stabilize-desktop-idle-memory/proposal.md b/openspec/changes/stabilize-desktop-idle-memory/proposal.md new file mode 100644 index 000000000..adad81b37 --- /dev/null +++ b/openspec/changes/stabilize-desktop-idle-memory/proposal.md @@ -0,0 +1,21 @@ +## Why + +Windows Desktop 长时间挂机后,侧栏刷新与桥接回包保留无用对象,可能使渲染进程内存耗尽。现有修改已完成原任务验证,本变更记录其发布集成与补验。 + +## What Changes + +- 侧栏相同内容不写入 React 状态,动态事件回调保持稳定身份。 +- 桌面桥接回包后释放 Promise,重复托盘菜单限频。 +- Windows 主渲染进程退出后自动重载,十分钟内最多三次;宿主析构后不执行恢复。 +- 开发者工具设置使用调用内引用,消除所有权检查的延迟注入误判。 + +## Capabilities + +### New Capabilities +- `desktop-idle-memory`: 空闲资源释放与有界渲染恢复。 + +### Modified Capabilities + +## Impact + +涉及 Sidebar、桌面桥接、Windows WebView2 与对应测试。不修改 seed 或协议;macOS/Linux 不新增崩溃恢复事件。ChatView/App 活跃切换的剩余增长留后续任务。 diff --git a/openspec/changes/stabilize-desktop-idle-memory/specs/desktop-idle-memory/spec.md b/openspec/changes/stabilize-desktop-idle-memory/specs/desktop-idle-memory/spec.md new file mode 100644 index 000000000..c41668624 --- /dev/null +++ b/openspec/changes/stabilize-desktop-idle-memory/specs/desktop-idle-memory/spec.md @@ -0,0 +1,27 @@ +## ADDED Requirements + +### Requirement: 空闲刷新释放资源 +系统 SHALL 在侧栏刷新内容相同时避免状态写入,并在每次桌面桥接回包后释放对应 Promise 记录。 + +#### Scenario: 相同列表持续刷新 +- **WHEN** 服务端重复返回相同的工作区与会话数据 +- **THEN** 侧栏不因该数据重复写入 React 状态 + +#### Scenario: 桥接请求结束 +- **WHEN** 桥接返回成功、失败或无效 JSON +- **THEN** 对应 Promise 被结束并从待处理表移除 + +### Requirement: 有界恢复渲染进程 +Windows Desktop SHALL 在主渲染进程退出后自动重载,十分钟内最多三次,并在宿主销毁后停止恢复回调。 + +#### Scenario: 渲染进程内存耗尽 +- **WHEN** 主渲染进程因内存耗尽退出且窗口内次数未达上限 +- **THEN** 页面自动重载且桥接继续可用 + +#### Scenario: 连续崩溃 +- **WHEN** 十分钟内第四次主渲染进程退出 +- **THEN** 保留错误页且不再自动重载 + +#### Scenario: 关闭宿主 +- **WHEN** 已排队恢复但宿主开始析构 +- **THEN** 恢复回调不得访问宿主或重载页面 diff --git a/openspec/changes/stabilize-desktop-idle-memory/tasks.md b/openspec/changes/stabilize-desktop-idle-memory/tasks.md new file mode 100644 index 000000000..f0452963a --- /dev/null +++ b/openspec/changes/stabilize-desktop-idle-memory/tasks.md @@ -0,0 +1,9 @@ +## 1. 实现集成 +- [x] 1.1 审查并纳入侧栏状态去重、稳定回调与托盘限频。 +- [x] 1.2 审查并纳入桥接资源释放与有界渲染恢复。 +- [x] 1.3 恢复回调使用 LifetimeRef,宿主析构先撤销;开发者工具同步参数改引用。 + +## 2. 发布补验 +- [x] 2.1 当前源码 pnpm test、pnpm build 通过,4542 条正则兼容检查通过。 +- [x] 2.2 既有 Windows 验证二进制中 108 项恢复、seed、升级与 LifetimeToken 测试通过;重新编译生产 WebHost 的 renderer_recovery_smoke,通过真实 WebView2 OOM 后第二次加载和三次桥接往返。 +- [x] 2.3 严格 OpenSpec、分层、所有权、大小、文档路径、include、映射与差异检查通过。 diff --git a/src/apps/desktop/renderer_recovery.cpp b/src/apps/desktop/renderer_recovery.cpp new file mode 100644 index 000000000..651eb1bde --- /dev/null +++ b/src/apps/desktop/renderer_recovery.cpp @@ -0,0 +1,23 @@ +#include "renderer_recovery.hpp" + +namespace acecode::desktop { + +RendererRecoveryTracker::RendererRecoveryTracker(RendererRecoveryLimits limits) + : limits_(limits) {} + +RendererRecoveryAction RendererRecoveryTracker::on_failure(RendererFailure failure, + std::int64_t now_ms) { + if (failure != RendererFailure::RenderProcessExited) { + return RendererRecoveryAction::Ignore; + } + while (!reloads_ms_.empty() && now_ms - reloads_ms_.front() >= limits_.window_ms) { + reloads_ms_.pop_front(); + } + if (static_cast(reloads_ms_.size()) >= limits_.max_reloads) { + return RendererRecoveryAction::GiveUp; + } + reloads_ms_.push_back(now_ms); + return RendererRecoveryAction::Reload; +} + +} // namespace acecode::desktop diff --git a/src/apps/desktop/renderer_recovery.hpp b/src/apps/desktop/renderer_recovery.hpp new file mode 100644 index 000000000..888324e0a --- /dev/null +++ b/src/apps/desktop/renderer_recovery.hpp @@ -0,0 +1,51 @@ +#pragma once + +#include +#include + +namespace acecode::desktop { + +// 主窗口 WebView 渲染进程故障后的自动恢复判定(纯逻辑,无平台依赖)。 +// +// 背景:Desktop 挂机一两天后渲染进程因内存耗尽退出,WebView2 只显示自带的 +// 「此页面出现问题 / 内存不足」错误页,用户必须手动点刷新(Chrome / Edge +// 标签页同款行为)。宿主拿得到 ProcessFailed 事件,渲染进程退出时可以直接 +// Reload() 把页面带回来;但页面若一加载就崩(例如某个会话一打开就爆内存), +// 无条件重载会变成死循环,所以按时间窗限次:窗口内超过上限就放弃,保留错误页 +// 让用户自己处理。 +enum class RendererFailure { + // 主页面的渲染进程退出(含 OOM 被杀、崩溃)。错误页已显示,Reload 能恢复。 + RenderProcessExited, + // 渲染进程无响应。可能只是一次长任务或解锁后的 GC 风暴,自己会缓过来; + // 若真的撑爆会再以 RenderProcessExited 上报,届时再重载。 + RenderProcessUnresponsive, + // 浏览器主进程退出:整个 WebView 已失效,Reload 无效,需要重建 WebView。 + BrowserProcessExited, + // GPU / 工具进程 / iframe 渲染进程等,WebView2 会自行重启或不影响主页面。 + Other, +}; + +enum class RendererRecoveryAction { + Reload, // 重新加载当前页面 + Ignore, // 不处理(不影响主页面,或交给 WebView2 自行恢复) + GiveUp, // 时间窗内重载过多,保留错误页 +}; + +struct RendererRecoveryLimits { + int max_reloads = 3; + std::int64_t window_ms = 10 * 60 * 1000; +}; + +class RendererRecoveryTracker { +public: + explicit RendererRecoveryTracker(RendererRecoveryLimits limits = {}); + + // 记录一次故障并给出处置。只有返回 Reload 时才计入时间窗。 + RendererRecoveryAction on_failure(RendererFailure failure, std::int64_t now_ms); + +private: + RendererRecoveryLimits limits_; + std::deque reloads_ms_; +}; + +} // namespace acecode::desktop diff --git a/src/apps/desktop/web_host.cpp b/src/apps/desktop/web_host.cpp index 72f2c9245..20bae4b47 100644 --- a/src/apps/desktop/web_host.cpp +++ b/src/apps/desktop/web_host.cpp @@ -2073,6 +2073,52 @@ WebHost::WebCoreInfo detect_platform_web_core_info() { } #endif +// webview/webview 0.12.0 注入的桥接脚本把每次 window.aceDesktop_* 调用的 +// resolve/reject 存进闭包里的 _promises 表,onReply 处理完却从不删除。页面 +// 每 5 秒推一次托盘菜单、开着办公室再加每 3 秒一次,挂机几天就是几十万条 +// 永久记录。这里在库脚本之后接管同一套 call / onReply(消息格式不变: +// {id, method, params} 与 onReply(id, status, result)),回包后立即删除。 +// 升级 webview 库时确认上游是否已修,修了就删掉这段。 +constexpr const char kBridgeReplyCleanupScript[] = R"JS((function () { + 'use strict'; + var bridge = window.__webview__; + var proto = bridge && Object.getPrototypeOf(bridge); + if (!proto || typeof proto.post !== 'function' || proto.__acecodeReplyCleanup) return; + var pending = new Map(); + var sequence = 0; + proto.call = function (method) { + var id = 'ace' + (++sequence).toString(36) + '-' + Math.random().toString(36).slice(2); + var params = Array.prototype.slice.call(arguments, 1); + var self = this; + return new Promise(function (resolve, reject) { + pending.set(id, { resolve: resolve, reject: reject }); + try { + self.post(JSON.stringify({ id: id, method: method, params: params })); + } catch (error) { + pending.delete(id); + reject(error); + } + }); + }; + proto.onReply = function (id, status, result) { + var entry = pending.get(id); + if (!entry) return; + pending.delete(id); + if (result !== undefined) { + try { + result = JSON.parse(result); + } catch (error) { + entry.reject(new Error('Failed to parse binding result as JSON')); + return; + } + } + if (status === 0) entry.resolve(result); + else entry.reject(result); + }; + proto.__acecodeReplyCleanup = true; +})(); +)JS"; + } // namespace struct WebHost::Impl { @@ -2163,7 +2209,7 @@ struct WebHost::Impl { // Also cover the WebView-owned fallback window, whose class has no icon. apply_host_window_icons(hwnd()); taskbar_badge = std::make_unique(hwnd()); - configure_browser_defaults(*w, microphone_origin); + configure_browser_defaults(*w, microphone_origin, renderer_lifetime.ref(*w)); // 三层打底(host 类刷 / widget 类刷 / WebView2 合成器)统一走默认色。 // offscreen 路径的 host 类注册时已带刷,这里等价换新;降级路径 // (webview 库自建 `webview` 类窗口)的 NULL 刷靠这里补上。 @@ -2218,6 +2264,9 @@ struct WebHost::Impl { #endif ~Impl() { +#ifdef _WIN32 + renderer_lifetime.revoke(); +#endif window_focus_handler = {}; #ifdef _WIN32 taskbar_badge.reset(); @@ -2278,6 +2327,9 @@ struct WebHost::Impl { } #endif std::unique_ptr w; +#ifdef _WIN32 + LifetimeToken renderer_lifetime; +#endif #if !defined(_WIN32) && !defined(__APPLE__) bool linux_center_on_first_show = false; #endif @@ -2294,7 +2346,10 @@ struct WebHost::Impl { }; WebHost::WebHost(bool debug, StartupWindowMode startup_mode) - : impl_(std::make_unique(debug, startup_mode)) {} + : impl_(std::make_unique(debug, startup_mode)) { + // 库的桥接脚本在构造时注入,这里紧随其后,早于页面脚本执行。 + impl_->w->init(kBridgeReplyCleanupScript); +} WebHost::~WebHost() = default; void WebHost::set_title(const std::string& title) { diff --git a/src/apps/desktop/web_host_browser_win.hpp b/src/apps/desktop/web_host_browser_win.hpp index 93aade59e..84795dfa9 100644 --- a/src/apps/desktop/web_host_browser_win.hpp +++ b/src/apps/desktop/web_host_browser_win.hpp @@ -2,12 +2,15 @@ #ifdef _WIN32 #include "microphone_permission.hpp" +#include "renderer_recovery.hpp" #include "utils/encoding.hpp" #include "utils/logger.hpp" +#include "utils/lifetime_token.hpp" #include #include #include +#include #include namespace acecode::desktop::detail { @@ -49,10 +52,88 @@ inline void log_webview_setting_failure(const char* operation, HRESULT hr) { " failed, hr=" + std::to_string(static_cast(hr))); } -inline bool set_dev_tools_enabled(ICoreWebView2* core, BOOL enabled) { - if (!core) return false; +inline RendererFailure classify_webview2_process_failure( + COREWEBVIEW2_PROCESS_FAILED_KIND kind) { + switch (kind) { + case COREWEBVIEW2_PROCESS_FAILED_KIND_RENDER_PROCESS_EXITED: + return RendererFailure::RenderProcessExited; + case COREWEBVIEW2_PROCESS_FAILED_KIND_RENDER_PROCESS_UNRESPONSIVE: + return RendererFailure::RenderProcessUnresponsive; + case COREWEBVIEW2_PROCESS_FAILED_KIND_BROWSER_PROCESS_EXITED: + return RendererFailure::BrowserProcessExited; + default: + return RendererFailure::Other; + } +} + +// 主页面渲染进程退出(挂机后内存耗尽最常见)时自动 Reload,不再停在 WebView2 +// 自带的错误页等用户点刷新。判定与限次见 renderer_recovery.hpp。Reload 按 +// WebView2 官方示例投递到事件处理之后执行,不在 ProcessFailed 回调里重入。 +inline HRESULT install_renderer_recovery_handler(LifetimeRef host_ref, + ICoreWebView2* core) { + // Shared by the WebView-owned handler across failures; it owns no host. + auto tracker = std::make_shared(); + EventRegistrationToken token{}; + return core->add_ProcessFailed( + Microsoft::WRL::Callback( + [tracker, host_ref](ICoreWebView2* sender, + ICoreWebView2ProcessFailedEventArgs* args) -> HRESULT { + if (!sender || !args) return S_OK; + COREWEBVIEW2_PROCESS_FAILED_KIND kind = + COREWEBVIEW2_PROCESS_FAILED_KIND_UNKNOWN_PROCESS_EXITED; + COREWEBVIEW2_PROCESS_FAILED_REASON reason = + COREWEBVIEW2_PROCESS_FAILED_REASON_UNEXPECTED; + int exit_code = 0; + args->get_ProcessFailedKind(&kind); + Microsoft::WRL::ComPtr details; + if (SUCCEEDED(args->QueryInterface(IID_PPV_ARGS(&details))) && details) { + details->get_Reason(&reason); + details->get_ExitCode(&exit_code); + } + const auto now_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count(); + const auto failure = classify_webview2_process_failure(kind); + const auto action = tracker->on_failure(failure, now_ms); + const std::string detail = + "kind=" + std::to_string(static_cast(kind)) + + " reason=" + std::to_string(static_cast(reason)) + + " exit=" + std::to_string(exit_code); + switch (action) { + case RendererRecoveryAction::Reload: { + LOG_WARN("[desktop] WebView render process exited (" + detail + + "); reloading the page"); + Microsoft::WRL::ComPtr target(sender); + host_ref.with([target, host_ref](webview::webview& host) { + host.dispatch([target, host_ref] { + host_ref.with([target](webview::webview&) { + const HRESULT hr = target->Reload(); + if (FAILED(hr)) log_webview_setting_failure("Reload after render process exit", hr); + }); + }); + }); + break; + } + case RendererRecoveryAction::GiveUp: + LOG_ERROR("[desktop] WebView render process exited again (" + detail + + "); automatic reload limit reached, leaving the error page"); + break; + case RendererRecoveryAction::Ignore: + if (failure == RendererFailure::BrowserProcessExited) { + LOG_ERROR("[desktop] WebView browser process exited (" + detail + + "); restart ACECode Desktop to recover"); + } else { + LOG_WARN("[desktop] WebView process failed (" + detail + ")"); + } + break; + } + return S_OK; + }).Get(), + &token); +} + +inline bool set_dev_tools_enabled(ICoreWebView2& core, BOOL enabled) { ICoreWebView2Settings* settings = nullptr; - HRESULT hr = core->get_Settings(&settings); + HRESULT hr = core.get_Settings(&settings); if (FAILED(hr) || !settings) { log_webview_setting_failure("get_Settings for DevTools", hr); return false; @@ -70,7 +151,7 @@ inline bool open_dev_tools_for_core(ICoreWebView2* core) { if (!core) return false; // webview/webview disables DevTools when constructed with debug=false. // Re-enable it only for ACECode's explicit F11/bridge entry point. - if (!set_dev_tools_enabled(core, TRUE)) return false; + if (!set_dev_tools_enabled(*core, TRUE)) return false; HRESULT hr = core->OpenDevToolsWindow(); if (FAILED(hr)) { log_webview_setting_failure("OpenDevToolsWindow", hr); @@ -116,7 +197,8 @@ inline void install_dev_tools_shortcut(ICoreWebView2Controller* controller) { } inline void configure_browser_defaults(webview::webview& host, - const std::shared_ptr& microphone_origin) { + const std::shared_ptr& microphone_origin, + LifetimeRef host_ref) { auto controller_result = host.browser_controller(); if (!controller_result.ok()) { LOG_WARN("[desktop] browser_controller unavailable; browser defaults not configured"); @@ -139,6 +221,8 @@ inline void configure_browser_defaults(webview::webview& host, install_dev_tools_shortcut(controller); hr = install_microphone_permission_handler(core, microphone_origin); if (FAILED(hr)) log_webview_setting_failure("add_PermissionRequested for microphone", hr); + hr = install_renderer_recovery_handler(host_ref, core); + if (FAILED(hr)) log_webview_setting_failure("add_ProcessFailed for renderer recovery", hr); ICoreWebView2Settings* settings = nullptr; hr = core->get_Settings(&settings); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 00f047ae0..020ad56f0 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -182,6 +182,19 @@ if(WIN32 AND ACECODE_BUILD_DESKTOP) MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") endif() + # Opt-in: tests/desktop/renderer_recovery_smoke.py drives it with a tiny + # V8 heap so the main WebView renderer runs out of memory within seconds. + add_executable(renderer_recovery_smoke EXCLUDE_FROM_ALL + ${ACECODE_RENDERER_RECOVERY_SMOKE_SOURCES}) + target_link_libraries(renderer_recovery_smoke PRIVATE + acecode_include_roots acecode_desktop_support acecode_base_core + webview::core nlohmann_json::nlohmann_json + ole32 shell32 user32 gdi32 dwmapi) + if(MSVC) + set_target_properties(renderer_recovery_smoke PROPERTIES + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") + endif() + add_executable(agent_browser_host_smoke EXCLUDE_FROM_ALL ${ACECODE_AGENT_BROWSER_HOST_SMOKE_SOURCES} ) diff --git a/tests/desktop/helpers/renderer_recovery_smoke.cpp b/tests/desktop/helpers/renderer_recovery_smoke.cpp new file mode 100644 index 000000000..3e092d090 --- /dev/null +++ b/tests/desktop/helpers/renderer_recovery_smoke.cpp @@ -0,0 +1,60 @@ +// 生产 Desktop WebHost + 独立 WebView2 配置目录(由 renderer_recovery_smoke.py 提供)。 +// 页面第一次加载时把渲染进程堆撑爆(驱动脚本把 V8 老生代上限压到 64 MB), +// 宿主收到 ProcessFailed 后应自动 Reload;第二次加载确认桥接调用正常往返、 +// 回包清理脚本已接管。用户反馈的场景:挂机一两天后渲染进程内存耗尽,窗口停在 +// WebView2 的「内存不足」错误页,只能手动点刷新。 +#include "desktop/web_host.hpp" +#include "utils/scope_exit.hpp" + +#include +#include +#include +#include + +int main(int argc, char** argv) { + if (argc != 2) return 2; + using acecode::desktop::WebHost; + WebHost host(false, WebHost::StartupWindowMode::OffscreenUntilReady); + // Shared by this runner and the host-owned reporting callback. + auto result = std::make_shared(1); + host.bind("aceRecoveryEcho", [](const std::string& message) { + return nlohmann::json::parse(message).at(0).dump(); + }); + host.bind("aceRecoveryDone", [result](const std::string& message) { + const auto report = nlohmann::json::parse(message).at(0); + std::cout << report.dump() << std::endl; + *result = report.value("ok", false) ? 0 : 1; + ::PostQuitMessage(0); + return std::string("null"); + }); + const auto timer = ::SetTimer(nullptr, 0, 60000, + +[](HWND, UINT, UINT_PTR, DWORD) { ::PostQuitMessage(1); }); + if (!timer) return 3; + acecode::ScopeExit stop_timer([timer] { ::KillTimer(nullptr, timer); }); + host.init_script(R"JS( + addEventListener('DOMContentLoaded', async () => { + const loads = Number(localStorage.getItem('ace-recovery-loads') || '0') + 1; + localStorage.setItem('ace-recovery-loads', String(loads)); + if (loads === 1) { + // 先让计数写出去,再撑爆堆。 + setTimeout(() => { + const hog = []; + for (;;) hog.push(new Array(1 << 20).fill(loads)); + }, 300); + return; + } + try { + const bridge = Object.getPrototypeOf(window.__webview__); + const patched = bridge.__acecodeReplyCleanup === true; + const echoes = []; + for (let i = 0; i < 3; i++) echoes.push(await aceRecoveryEcho(i)); + aceRecoveryDone({ ok: patched && echoes.join(',') === '0,1,2', loads, patched, echoes }); + } catch (error) { + aceRecoveryDone({ ok: false, loads, error: String(error) }); + } + }, { once: true }); + )JS"); + host.navigate(argv[1]); + host.run(); + return *result; +} diff --git a/tests/desktop/renderer_recovery_smoke.py b/tests/desktop/renderer_recovery_smoke.py new file mode 100644 index 000000000..dc4c22a29 --- /dev/null +++ b/tests/desktop/renderer_recovery_smoke.py @@ -0,0 +1,51 @@ +"""Opt-in Windows Desktop smoke: the main WebView reloads after its renderer runs out of memory.""" +import http.server +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import threading + + +class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + page = b"ACECode renderer recovery test" + self.send_response(200) + self.send_header("Content-Type", "text/html") + self.send_header("Content-Length", str(len(page))) + self.end_headers() + self.wfile.write(page) + + def log_message(self, *_args): + pass + + +def main(): + binary = Path(sys.argv[1]).resolve(strict=True) + with tempfile.TemporaryDirectory(prefix="ace-renderer-recovery-", ignore_cleanup_errors=True) as folder: + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) + worker = threading.Thread(target=server.serve_forever) + worker.start() + try: + env = dict(os.environ) + env["WEBVIEW2_USER_DATA_FOLDER"] = str(Path(folder) / "profile") + # A small V8 old-space limit makes the renderer hit the same + # out-of-memory exit within seconds instead of after days. + env["WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS"] = "--js-flags=--max-old-space-size=64" + result = subprocess.run( + [str(binary), f"http://127.0.0.1:{server.server_port}/"], + env=env, capture_output=True, text=True, timeout=90, + creationflags=subprocess.CREATE_NO_WINDOW, + ) + print(result.stdout, end="") + print(result.stderr, end="", file=sys.stderr) + return result.returncode + finally: + server.shutdown() + worker.join() + server.server_close() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/desktop/renderer_recovery_test.cpp b/tests/desktop/renderer_recovery_test.cpp new file mode 100644 index 000000000..bcc3e8fac --- /dev/null +++ b/tests/desktop/renderer_recovery_test.cpp @@ -0,0 +1,84 @@ +// renderer_recovery 纯逻辑单测。 +// +// 背景:用户反馈 Desktop 挂机一两天后解锁屏幕,窗口停在 WebView2 自带的 +// 「内存不足」错误页,必须手动点刷新。宿主现在监听 ProcessFailed,主页面渲染 +// 进程退出时自动 Reload;这里守住「什么故障才重载」与「防崩溃循环的限次」。 + +#include "desktop/renderer_recovery.hpp" + +#include + +namespace { + +using acecode::desktop::RendererFailure; +using acecode::desktop::RendererRecoveryAction; +using acecode::desktop::RendererRecoveryLimits; +using acecode::desktop::RendererRecoveryTracker; + +constexpr std::int64_t kMinute = 60 * 1000; + +// 场景:挂机后渲染进程因内存耗尽退出(WebView2 报 RENDER_PROCESS_EXITED)。 +// 期望:第一次故障直接重载,用户解锁后看到的是正常页面而不是错误页。 +TEST(RendererRecovery, RenderProcessExitReloads) { + RendererRecoveryTracker tracker; + EXPECT_EQ(tracker.on_failure(RendererFailure::RenderProcessExited, 0), + RendererRecoveryAction::Reload); +} + +// 场景:渲染进程短暂无响应(长任务或解锁后的 GC 风暴)、浏览器主进程退出、 +// GPU / 工具进程等非主页面故障。 +// 期望:都不重载 —— 无响应可能自己缓过来,真撑爆会再报 RenderProcessExited; +// 浏览器主进程退出后 Reload 无效;其它进程由 WebView2 自行恢复。 +TEST(RendererRecovery, NonRenderExitFailuresAreIgnored) { + RendererRecoveryTracker tracker; + EXPECT_EQ(tracker.on_failure(RendererFailure::RenderProcessUnresponsive, 0), + RendererRecoveryAction::Ignore); + EXPECT_EQ(tracker.on_failure(RendererFailure::BrowserProcessExited, 0), + RendererRecoveryAction::Ignore); + EXPECT_EQ(tracker.on_failure(RendererFailure::Other, 0), + RendererRecoveryAction::Ignore); +} + +// 场景:页面一加载就崩(例如某个会话一打开就爆内存),渲染进程在 10 分钟内 +// 连续退出。 +// 期望:前 3 次重载,第 4 次放弃并保留错误页,不陷入无限重载循环。 +TEST(RendererRecovery, CrashLoopGivesUpAfterLimit) { + RendererRecoveryTracker tracker; + for (int i = 0; i < 3; ++i) { + EXPECT_EQ(tracker.on_failure(RendererFailure::RenderProcessExited, i * kMinute), + RendererRecoveryAction::Reload); + } + EXPECT_EQ(tracker.on_failure(RendererFailure::RenderProcessExited, 3 * kMinute), + RendererRecoveryAction::GiveUp); +} + +// 场景:几天里偶尔各崩一次(每次间隔超过时间窗)。 +// 期望:旧的重载记录滑出时间窗,之后的故障仍然自动重载。 +TEST(RendererRecovery, OldReloadsLeaveTheWindow) { + RendererRecoveryTracker tracker(RendererRecoveryLimits{2, 10 * kMinute}); + EXPECT_EQ(tracker.on_failure(RendererFailure::RenderProcessExited, 0), + RendererRecoveryAction::Reload); + EXPECT_EQ(tracker.on_failure(RendererFailure::RenderProcessExited, 1 * kMinute), + RendererRecoveryAction::Reload); + EXPECT_EQ(tracker.on_failure(RendererFailure::RenderProcessExited, 2 * kMinute), + RendererRecoveryAction::GiveUp); + // 第一次重载(t=0)在 t=10min 恰好滑出,窗口里只剩 1 次。 + EXPECT_EQ(tracker.on_failure(RendererFailure::RenderProcessExited, 10 * kMinute), + RendererRecoveryAction::Reload); +} + +// 场景:放弃之后的故障。 +// 期望:GiveUp 不占用重载名额,时间窗过去后能重新恢复自动重载。 +TEST(RendererRecovery, GiveUpDoesNotConsumeQuota) { + RendererRecoveryTracker tracker(RendererRecoveryLimits{1, 10 * kMinute}); + EXPECT_EQ(tracker.on_failure(RendererFailure::RenderProcessExited, 0), + RendererRecoveryAction::Reload); + EXPECT_EQ(tracker.on_failure(RendererFailure::RenderProcessExited, 1 * kMinute), + RendererRecoveryAction::GiveUp); + EXPECT_EQ(tracker.on_failure(RendererFailure::RenderProcessExited, 5 * kMinute), + RendererRecoveryAction::GiveUp); + EXPECT_EQ(tracker.on_failure(RendererFailure::RenderProcessExited, 11 * kMinute), + RendererRecoveryAction::Reload); +} + +} // namespace diff --git a/web/src/components/Sidebar.jsx b/web/src/components/Sidebar.jsx index 27a379425..6568d3154 100644 --- a/web/src/components/Sidebar.jsx +++ b/web/src/components/Sidebar.jsx @@ -159,6 +159,8 @@ import { EditWorkspaceModal } from './EditWorkspaceModal.jsx'; import { WorkspaceIcon } from './WorkspaceIcon.jsx'; import { resolveWorkspaceIcon } from '../lib/workspaceIcons.js'; import { syncSidebarListDivider } from '../lib/sidebarListDivider.js'; +import { commitIfChanged, useStructuralState } from '../lib/structuralState.js'; +import { useStableCallback } from '../lib/useStableCallback.js'; const SIDEBAR_SECTIONS_STORAGE_KEY = 'acecode.sidebarSectionsExpanded.v1'; const SESSION_DRAG_START_PX = 5; @@ -1818,7 +1820,13 @@ export function Sidebar({ activeOwner: activeSessionHoverOwner(sessionHoverState), dispatch: dispatchSessionHover, }), [sessionHoverState]); - const [workspaces, setWorkspaces] = useState([]); + // 5 秒刷新路径上的状态一律「内容没变就不调用 setState」:空闲时刷新不再触发渲染。 + // 侧栏的 useCallback 会把每次渲染的作用域串成链留在内存里,挂机一两天就把 + // 渲染进程撑爆;只在 updater 里返回旧值也不行,React 会把 update 留在队列里 + // (lib/structuralState.js)。带 ref 的状态经 commitIfChanged 写入,所有写入方都同步 ref。 + // 依赖会变的状态或 props 的回调一律用 useStableCallback,不用 useCallback(fn, [会变的依赖]): + // 后者在不同渲染里交替重建,把旧渲染的作用域串成链(lib/useStableCallback.js)。 + const [workspaces, setWorkspaces] = useStructuralState([]); const workspacesRef = useRef(workspaces); workspacesRef.current = workspaces; const [workspaceOrderSaving, setWorkspaceOrderSaving] = useState(false); @@ -1840,14 +1848,14 @@ export function Sidebar({ }, }); } - const [sessions, setSessions] = useState([]); - const [statusBySession, setStatusBySession] = useState(() => new Map()); + const [sessions, setSessions] = useStructuralState([]); + const [statusBySession, setStatusBySession] = useStructuralState(() => new Map()); const [pinnedByWorkspace, setPinnedByWorkspace] = useState(() => new Map()); const [pinnedOrderItems, setPinnedOrderItems] = useState([]); - const [sessionLoadingWorkspaces, setSessionLoadingWorkspaces] = useState(() => new Set()); + const [sessionLoadingWorkspaces, setSessionLoadingWorkspaces] = useStructuralState(() => new Set()); const [sessionLoadedWorkspaces, setSessionLoadedWorkspaces] = useState(() => new Set()); const [sessionFullyLoadedWorkspaces, setSessionFullyLoadedWorkspaces] = useState(() => new Set()); - const [sessionListTotals, setSessionListTotals] = useState(() => new Map()); + const [sessionListTotals, setSessionListTotals] = useStructuralState(() => new Map()); const [expanded, setExpanded] = useState(new Set()); const [expandedSessionLists, setExpandedSessionLists] = useState(new Map()); const [expandedSessionGroups, setExpandedSessionGroups] = useState(() => new Map()); @@ -1866,7 +1874,7 @@ export function Sidebar({ DEFAULT_SIDEBAR_SECTION_EXPANSION, validateSidebarSectionExpansion, ); - const [activeWorkspaceHash, setActiveWorkspaceHash] = useState(''); + const [activeWorkspaceHash, setActiveWorkspaceHash] = useStructuralState(''); const refreshSelectionRef = useRef(null); const refreshingRef = useRef(false); const pendingRefreshHashRef = useRef(''); @@ -1964,16 +1972,18 @@ export function Sidebar({ }, []); const acceptSidebarTreePage = useCallback((scope, page) => { if (!page?.sidebarTree) return; - setExpandedSessionGroups((prev) => { - const next = new Map(prev); - let changed = false; + // 每轮刷新都会走到这里;没有新展开的分组时不能调用 setState。 + commitIfChanged(expandedSessionGroupsRef, setExpandedSessionGroups, (prev) => { + let next = prev; for (const session of page.sessions) { if (!session.sidebar_expanded || !session.sidebar_child_count) continue; const key = `${scope}\u0000${sidebarNodeKey(session)}`; - if (next.get(key) !== true) { next.set(key, true); changed = true; } + if (next.get(key) !== true) { + if (next === prev) next = new Map(prev); + next.set(key, true); + } } - if (changed) expandedSessionGroupsRef.current = next; - return changed ? next : prev; + return next; }); const requestedRoots = (expandedSessionListsRef.current.get(scope) || 5) + normalizePinnedIds(pinnedByWorkspaceRef.current.get(scope) || []).length; @@ -2052,11 +2062,7 @@ export function Sidebar({ }, []); const updateExpanded = useCallback((updater) => { - setExpanded((prev) => { - const next = typeof updater === 'function' ? updater(prev) : updater; - expandedRef.current = next; - return next; - }); + commitIfChanged(expandedRef, setExpanded, updater); }, []); const toggleSidebarSection = useCallback((sectionId) => { @@ -2106,23 +2112,17 @@ export function Sidebar({ }, []); const setPinnedMap = useCallback((updater) => { - setPinnedByWorkspace((prev) => { + commitIfChanged(pinnedByWorkspaceRef, setPinnedByWorkspace, (prev) => { const rawNext = typeof updater === 'function' ? updater(prev) : updater; - const next = rawNext instanceof Map ? new Map(rawNext) : new Map(Object.entries(rawNext || {})); - pinnedByWorkspaceRef.current = next; - return next; + return rawNext instanceof Map ? new Map(rawNext) : new Map(Object.entries(rawNext || {})); }); }, []); const setOpencodeImportPreview = useCallback((hash, preview) => { if (!hash) return; const normalized = normalizeOpencodeImportPreview(preview || {}); - setOpencodeImportPreviews((prev) => { - const next = new Map(prev); - next.set(hash, normalized); - opencodeImportPreviewsRef.current = next; - return next; - }); + commitIfChanged(opencodeImportPreviewsRef, setOpencodeImportPreviews, + (prev) => new Map(prev).set(hash, normalized)); }, []); const refreshOpencodeImportPreview = useCallback(async (ws) => { @@ -2153,12 +2153,8 @@ export function Sidebar({ }, []); const setPinnedOrder = useCallback((updater) => { - setPinnedOrderItems((prev) => { - const rawNext = typeof updater === 'function' ? updater(prev) : updater; - const next = normalizePinnedOrderItems(rawNext); - pinnedOrderItemsRef.current = next; - return next; - }); + commitIfChanged(pinnedOrderItemsRef, setPinnedOrderItems, (prev) => ( + normalizePinnedOrderItems(typeof updater === 'function' ? updater(prev) : updater))); }, []); const setPinnedWorkspaceIds = useCallback((workspaceHash, ids) => { @@ -2278,7 +2274,7 @@ export function Sidebar({ } }, [applyPinnedReorder]); - const handlePinnedPointerDown = useCallback((event, session, title) => { + const handlePinnedPointerDown = useStableCallback((event, session, title) => { if (folderGestureRef.current) return; startSidebarSessionPointerDrag({ showSessionTime, @@ -2293,7 +2289,7 @@ export function Sidebar({ updateDragScroll: updateSessionDragScroll, updateDragTarget: updatePinnedDragTarget, }); - }, [finishPinnedDrag, folderGestureRef, showSessionTime, updatePinnedDragTarget, updateSessionDragScroll]); + }); const updateWorkspaceDragTarget = useCallback((clientY, clientX) => { const drag = workspaceDragRef.current; @@ -2361,7 +2357,7 @@ export function Sidebar({ } }, [sidebarTreeOptions]); - const handleWorkspacePointerDown = useCallback((event, session, title) => { + const handleWorkspacePointerDown = useStableCallback((event, session, title) => { if (folderGestureRef.current || treeSavingRef.current) return; startSidebarSessionPointerDrag({ showSessionTime, @@ -2376,7 +2372,7 @@ export function Sidebar({ updateDragScroll: updateSessionDragScroll, updateDragTarget: updateWorkspaceDragTarget, }); - }, [finishWorkspaceDrag, folderGestureRef, showSessionTime, updateSessionDragScroll, updateWorkspaceDragTarget]); + }); useEffect(() => () => { pinnedDragRef.current?.cleanup?.(); @@ -2386,7 +2382,7 @@ export function Sidebar({ document.body.classList.remove('ace-sidebar-session-reordering'); }, []); - const togglePinnedSession = useCallback(async (session, nextPinned) => { + const togglePinnedSession = useStableCallback(async (session, nextPinned) => { const id = session?.id || session?.sessionId || session?.session_id || ''; const pinScope = sessionPinScope(session, activeWorkspaceHash); if (!id || !pinScope) return; @@ -2420,7 +2416,7 @@ export function Sidebar({ setPinnedOrder(previousOrder); toast({ kind: 'err', text: (shouldPin ? '置顶失败:' : '取消置顶失败:') + (e.message || '') }); } - }, [activeWorkspaceHash, savePinnedScopeIds, setPinnedOrder, setPinnedWorkspaceIds]); + }); const setSessionWorkspaceLoading = useCallback((hashes, loading) => { const normalized = Array.from(new Set(Array.from(hashes || []).filter(Boolean))); @@ -2438,13 +2434,12 @@ export function Sidebar({ const setSessionWorkspacesLoaded = useCallback((hashes, loaded) => { const normalized = Array.from(new Set(Array.from(hashes || []).filter(Boolean))); if (normalized.length === 0) return; - setSessionLoadedWorkspaces((prev) => { + commitIfChanged(sessionLoadedWorkspacesRef, setSessionLoadedWorkspaces, (prev) => { const next = new Set(prev); for (const hash of normalized) { if (loaded) next.add(hash); else next.delete(hash); } - sessionLoadedWorkspacesRef.current = next; return next; }); }, []); @@ -2452,11 +2447,10 @@ export function Sidebar({ const markWorkspaceSessionsFullyLoaded = useCallback((hash, fullyLoaded) => { const workspaceHash = String(hash || '').trim(); if (!workspaceHash) return; - setSessionFullyLoadedWorkspaces((prev) => { + commitIfChanged(sessionFullyLoadedWorkspacesRef, setSessionFullyLoadedWorkspaces, (prev) => { const next = new Set(prev); if (fullyLoaded) next.add(workspaceHash); else next.delete(workspaceHash); - sessionFullyLoadedWorkspacesRef.current = next; return next; }); }, []); @@ -2491,7 +2485,7 @@ export function Sidebar({ markWorkspaceSessionsFullyLoaded(hash, page?.hasMore === false); }, [acceptSidebarTreePage, markWorkspaceSessionsFullyLoaded, setSessionWorkspacesLoaded]); - const loadWorkspaceSessions = useCallback(async (hash, { visibleCount, silent = false } = {}) => { + const loadWorkspaceSessions = useStableCallback(async (hash, { visibleCount, silent = false } = {}) => { const workspaceHash = String(hash || '').trim(); if (!workspaceHash) return; const workspace = workspaces.find((item) => item.hash === workspaceHash) @@ -2532,7 +2526,7 @@ export function Sidebar({ setSessionWorkspaceLoading([workspaceHash], false); } } - }, [applyWorkspaceSessionList, setSessionWorkspaceLoading, sidebarTreeOptions, workspaces]); + }); treeApplyRef.current = (scope, page) => { if (scope !== NO_WORKSPACE_PIN_SCOPE) { @@ -2555,7 +2549,7 @@ export function Sidebar({ } catch { toast({ kind: 'err', text: tr('读取会话分组失败,请重试') }); } }; - const toggleSessionListExpanded = useCallback((hash, action) => { + const toggleSessionListExpanded = useStableCallback((hash, action) => { if (!hash) return; const collapsing = action === 'collapse'; // 手动选择批次优先于当前会话的自动揭示,直到发生新的会话导航。 @@ -2578,7 +2572,7 @@ export function Sidebar({ visibleCount, silent: sessionLoadedWorkspaces.has(hash), }).catch(() => {}); - }, [loadWorkspaceSessions, sessionFullyLoadedWorkspaces, sessionLoadedWorkspaces]); + }); useEffect(() => { const handler = (event) => { @@ -2917,7 +2911,7 @@ export function Sidebar({ })); }, []); - const archiveSession = useCallback(async (session) => { + const archiveSession = useStableCallback(async (session) => { const id = session?.id || session?.sessionId || session?.session_id || ''; const noWorkspace = isNoWorkspaceSession(session); const workspaceHash = noWorkspace ? '' : (session?.workspace_hash || session?.workspaceHash || activeWorkspaceHash || ''); @@ -3002,9 +2996,9 @@ export function Sidebar({ } finally { archiveOperationsRef.current.delete(loadKey); } - }, [activeWorkspaceHash, cancelSessionSelection, invalidateArchiveLists, notifyArchiveChanged, onOpenHome, onOpenSettingsSection, refreshArchivedScope, savePinnedScopeIds, setPinnedOrder, setPinnedWorkspaceIds]); + }); - const renameSession = useCallback(async (session, title) => { + const renameSession = useStableCallback(async (session, title) => { const id = session?.id || session?.sessionId || session?.session_id || ''; const noWorkspace = isNoWorkspaceSession(session); const workspaceHash = noWorkspace ? '' : (session?.workspace_hash || session?.workspaceHash || activeWorkspaceHash || ''); @@ -3031,7 +3025,7 @@ export function Sidebar({ })); toast({ kind: 'ok', text: title.trim() ? '已重命名' : '已清除标题' }); refresh(workspaceHash).catch(() => {}); - }, [activeWorkspaceHash, refresh]); + }); useEffect(() => { refresh(); @@ -3335,7 +3329,7 @@ export function Sidebar({ } }, [activeId]); - const markSessionRead = useCallback((session) => { + const markSessionRead = useStableCallback((session) => { if (!session?.id) return; if (manualUnreadActiveRef.current === session.id) manualUnreadActiveRef.current = ''; const merged = mergeSessionStatus(session, statusBySession); @@ -3348,9 +3342,9 @@ export function Sidebar({ const optimistic = optimisticReadStatus(merged); if (optimistic) desktopTaskbarBadge.updateStatus(optimistic); if (optimistic) setStatusBySession((prev) => applyStatusUpdate(prev, optimistic)); - }, [statusBySession]); + }); - const markSessionUnread = useCallback((session) => { + const markSessionUnread = useStableCallback((session) => { if (!session?.id) return; const merged = mergeSessionStatus(session, statusBySession); if (merged.id === activeId) manualUnreadActiveRef.current = merged.id; @@ -3361,7 +3355,7 @@ export function Sidebar({ const optimistic = optimisticUnreadStatus(merged); if (optimistic) desktopTaskbarBadge.updateStatus(optimistic); if (optimistic) setStatusBySession((prev) => applyStatusUpdate(prev, optimistic)); - }, [activeId, statusBySession]); + }); useEffect(() => { if (!activeId) return; @@ -3420,7 +3414,7 @@ export function Sidebar({ } }; - const onActivate = useCallback(async (ws) => { + const onActivate = useStableCallback(async (ws) => { if (onBeforeNavigate && !await onBeforeNavigate({ home: true, workspaceHash: ws.hash || '', cwd: ws.cwd || '' })) return; cancelSessionSelection(); workspaceCollapseAllRef.current = false; @@ -3455,7 +3449,7 @@ export function Sidebar({ refresh(ws.hash).catch(() => {}); } } catch (e) { toast({ kind: 'err', text: '切换异常:' + (e.message || '') }); } - }, [cancelSessionSelection, onBeforeNavigate, onOpenHome, refresh, setSessionWorkspaceLoading, updateExpanded]); + }); useEffect(() => { const requestId = Number(workspaceActivationRequest?.requestId || 0); @@ -3608,7 +3602,7 @@ export function Sidebar({ tick(); }, [flashOpencodeImportedSessions, refresh, refreshOpencodeImportPreview]); - const confirmOpencodeImport = useCallback(async () => { + const confirmOpencodeImport = useStableCallback(async () => { const ws = opencodeImportDialog?.workspace; if (!ws?.hash) return; const selectedSessionIds = Array.isArray(opencodeImportDialog?.selectedSessionIds) @@ -3634,7 +3628,7 @@ export function Sidebar({ ? { ...prev, phase: 'error', error: e.message || '启动导入失败' } : prev); } - }, [opencodeImportDialog, pollOpencodeImportJob]); + }); const closeOpencodeImportDialog = useCallback(() => { if (opencodeImportPollRef.current) { @@ -3814,7 +3808,7 @@ export function Sidebar({ await refresh(); }; - const openNewTaskInWorkspace = useCallback(async (ws) => { + const openNewTaskInWorkspace = useStableCallback(async (ws) => { const workspaceHash = ws?.hash || ''; if (!workspaceHash) return; if (onBeforeNavigate && !await onBeforeNavigate({ home: true, workspaceHash, cwd: ws.cwd || '' })) return; @@ -3835,7 +3829,7 @@ export function Sidebar({ active: item.hash === workspaceHash, }))); onOpenHome?.(ws, { composerFeedback: true }); - }, [cancelSessionSelection, onBeforeNavigate, onOpenHome, updateExpanded]); + }); const onAddWorkspace = async () => { try { @@ -3848,7 +3842,7 @@ export function Sidebar({ } }; - const collapseAllWorkspaces = useCallback(() => { + const collapseAllWorkspaces = useStableCallback(() => { workspaceCollapseAllRef.current = true; updateExpanded(new Set()); for (const hash of sidebarWorkspaceListKeys(workspaces)) { @@ -3858,7 +3852,7 @@ export function Sidebar({ setExpandedSessionLists((previous) => ( expandedSessionListsAfterWorkspaceCollapseAll(previous, workspaces) )); - }, [updateExpanded, workspaces]); + }); const sidebarNavCallbacks = { onNewTask: () => { diff --git a/web/src/lib/desktopTrayMenu.js b/web/src/lib/desktopTrayMenu.js index 25ef606ee..4b5967d6e 100644 --- a/web/src/lib/desktopTrayMenu.js +++ b/web/src/lib/desktopTrayMenu.js @@ -174,6 +174,15 @@ export function buildTrayMenuPayload({ // 内部 debounce timer + 最后一次 payload。每次调度只保留最新值。 let _debounceTimer = null; let _lastQueuedPayload = null; +// 侧栏每 5 秒刷新都会推一次,内容多半没变。相同内容在这段时间内不重复过桥; +// 超时后照样重推一次,兜住 native 侧自行清空缓存(切工作区)的情况。 +const RESEND_UNCHANGED_AFTER_MS = 60_000; +let _lastSentJson = null; +let _lastSentAt = 0; +let _now = () => Date.now(); +export function __setNowForTest(fn) { + _now = typeof fn === 'function' ? fn : () => Date.now(); +} // 把 setTimeout / clearTimeout 注入做成 testable 的可替换 hook(测试中可换 fake clock)。 let _setTimeout = (fn, ms) => setTimeout(fn, ms); @@ -194,8 +203,13 @@ function flushNow() { if (!p) return; const fn = typeof window !== 'undefined' && window.aceDesktop_setTrayMenu; if (typeof fn !== 'function') return; // 浏览器模式 / bridge 不在 → no-op + const json = JSON.stringify(p); + const now = _now(); + if (json === _lastSentJson && now - _lastSentAt < RESEND_UNCHANGED_AFTER_MS) return; try { - fn(JSON.stringify(p)); + fn(json); + _lastSentJson = json; + _lastSentAt = now; } catch (e) { // 静默吞掉 — 桥本身在 desktop 端有 try/catch 兜底,这里再抛会污染前端 console。 } @@ -231,4 +245,7 @@ export function __resetForTest() { if (_debounceTimer != null) _clearTimeout(_debounceTimer); _debounceTimer = null; _lastQueuedPayload = null; + _lastSentJson = null; + _lastSentAt = 0; + _now = () => Date.now(); } diff --git a/web/src/lib/desktopTrayMenu.test.js b/web/src/lib/desktopTrayMenu.test.js index e604c7112..77a1fc896 100644 --- a/web/src/lib/desktopTrayMenu.test.js +++ b/web/src/lib/desktopTrayMenu.test.js @@ -17,6 +17,7 @@ import { SUBTITLE_LIMIT, __setTimerFnsForTest, __resetTimerFnsForTest, + __setNowForTest, __peekQueuedPayloadForTest, __resetForTest, } from './desktopTrayMenu.js'; @@ -232,3 +233,73 @@ run('pushTrayMenu debounce:多次连续调用合并成最后一次 payload', () __resetTimerFnsForTest(); __resetForTest(); }); + +// 场景:侧栏每 5 秒刷新一次,托盘内容没变也会调 pushTrayMenu。webview 0.12.0 +// 的桥接每次调用都留下一条永不释放的 Promise 记录,Desktop 挂机几天会累积成 +// 几十万条。期望:相同内容 60 秒内只过一次桥,内容变化立即推送,超过 60 秒 +// 照样重推一次(native 切工作区时会自行清空托盘缓存)。 +run('pushTrayMenu 相同内容不重复过桥,变化立即推送,60 秒后重推', () => { + __resetForTest(); + let now = 1_000; + __setNowForTest(() => now); + __setTimerFnsForTest({ + setTimeout: (fn) => { fn(); return 1; }, + clearTimeout: () => {}, + }); + const captured = []; + const prev = global.window; + global.window = { + aceDesktop_setTrayMenu: (json) => { captured.push(JSON.parse(json)); }, + }; + const push = (id) => pushTrayMenu({ + sessions: [{ id, title: id }], + pinnedByWorkspace: new Map(), + workspaceName: 'w', + }); + + push('a'); + now += 5_000; + push('a'); + now += 5_000; + push('a'); + assert.equal(captured.length, 1); + + push('b'); + assert.equal(captured.length, 2); + assert.equal(captured[1].recent[0].session_id, 'b'); + + now += 60_000; + push('b'); + assert.equal(captured.length, 3); + + global.window = prev; + __resetTimerFnsForTest(); + __resetForTest(); +}); + +// 场景:桥调用抛错(native 侧异常)。期望:不记为已发送,下一次相同内容会重试。 +run('pushTrayMenu 桥调用失败时不记为已发送', () => { + __resetForTest(); + __setTimerFnsForTest({ + setTimeout: (fn) => { fn(); return 1; }, + clearTimeout: () => {}, + }); + let calls = 0; + let fail = true; + const prev = global.window; + global.window = { + aceDesktop_setTrayMenu: () => { + calls += 1; + if (fail) throw new Error('bridge down'); + }, + }; + const payload = { sessions: [{ id: 'a', title: 'A' }], pinnedByWorkspace: new Map(), workspaceName: 'w' }; + pushTrayMenu(payload); + fail = false; + pushTrayMenu(payload); + assert.equal(calls, 2); + + global.window = prev; + __resetTimerFnsForTest(); + __resetForTest(); +}); diff --git a/web/src/lib/runTests.js b/web/src/lib/runTests.js index 57b195760..0eebff587 100644 --- a/web/src/lib/runTests.js +++ b/web/src/lib/runTests.js @@ -261,6 +261,8 @@ import './desktopBackgroundProcess.test.js'; import './desktopCloseBehavior.test.js'; import './desktopGuidedTour.test.js'; import './desktopTrayMenu.test.js'; +import './structuralState.test.js'; +import './sidebarStableCallbacksArchitecture.test.js'; import './opencodeImport.test.js'; import './updateJob.test.js'; import './newSession.test.js'; diff --git a/web/src/lib/sessionCreationActionGuardsArchitecture.test.js b/web/src/lib/sessionCreationActionGuardsArchitecture.test.js index dab82d24b..86a37506c 100644 --- a/web/src/lib/sessionCreationActionGuardsArchitecture.test.js +++ b/web/src/lib/sessionCreationActionGuardsArchitecture.test.js @@ -31,7 +31,7 @@ test('workspace new-task actions route to the shared lazy home entry point', () const sidebar = source('components/Sidebar.jsx'); const openFlow = section( sidebar, - 'const openNewTaskInWorkspace = useCallback(async (ws) => {', + 'const openNewTaskInWorkspace = useStableCallback(async (ws) => {', '\n const onAddWorkspace = async', ); diff --git a/web/src/lib/sidebarStableCallbacksArchitecture.test.js b/web/src/lib/sidebarStableCallbacksArchitecture.test.js new file mode 100644 index 000000000..0879e5d09 --- /dev/null +++ b/web/src/lib/sidebarStableCallbacksArchitecture.test.js @@ -0,0 +1,78 @@ +// 侧栏回调的结构守卫。 +// +// 背景:Desktop 挂机一两天后渲染进程内存耗尽。侧栏里依赖各异的 useCallback 在不同 +// 渲染里交替重建,V8 让同一次渲染的闭包共享一个作用域,旧回调把旧渲染的整个作用域 +// (含当时的会话列表)留住,一份接一份连成链,随渲染次数无限增长。修法是依赖会变的 +// 回调一律用 useStableCallback;这里保证 Sidebar 里剩下的 useCallback 依赖全是稳定的 +// (setter、ref、其它稳定回调),身份从第一次渲染起就不再变化,不会接链。 + +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { parseSync } from '@babel/core'; + +const source = readFileSync(new URL('../components/Sidebar.jsx', import.meta.url), 'utf8'); +const ast = parseSync(source, { configFile: false, babelrc: false, parserOpts: { plugins: ['jsx'] } }); +const component = ast.program.body.map((node) => node.declaration || node) + .find((node) => node.id?.name === 'Sidebar'); +assert.ok(component, 'Missing production Sidebar'); + +const STATE_HOOKS = new Set(['useState', 'useStructuralState', 'usePreference', 'useReducer']); +const calleeName = (init) => (init?.type === 'CallExpression' && init.callee.type === 'Identifier' + ? init.callee.name : ''); + +function stableCallbackReport() { + const stable = new Set(); + const unstable = []; + let stableCallbacks = 0; + for (const statement of component.body.body) { + if (statement.type !== 'VariableDeclaration') continue; + for (const declaration of statement.declarations) { + const hook = calleeName(declaration.init); + if (declaration.id.type === 'ArrayPattern' && STATE_HOOKS.has(hook)) { + const setter = declaration.id.elements[1]; + if (setter?.type === 'Identifier') stable.add(setter.name); + continue; + } + if (declaration.id.type !== 'Identifier') continue; + const name = declaration.id.name; + if (hook === 'useRef' || hook === 'useStableCallback') { + stable.add(name); + if (hook === 'useStableCallback') stableCallbacks += 1; + continue; + } + if (hook !== 'useCallback') continue; + const deps = declaration.init.arguments[1]; + assert.equal(deps?.type, 'ArrayExpression', `${name} must list its dependencies`); + const changing = deps.elements + .map((element) => source.slice(element.start, element.end)) + .filter((dep) => !stable.has(dep) && !/Ref$/.test(dep)); + if (changing.length === 0) stable.add(name); + else unstable.push(`${name} [${changing.join(', ')}]`); + } + } + return { unstable, stableCallbacks }; +} + +function run(name, fn) { + try { + fn(); + console.log(`[pass] ${name}`); + } catch (error) { + console.error(`[fail] ${name}`); + throw error; + } +} + +// 场景:有人在侧栏里新增或改回 useCallback(fn, [会变的状态或 props])。 +// 期望:失败并列出回调名与会变的依赖 —— 应改用 useStableCallback。 +run('Sidebar 的 useCallback 只依赖稳定引用', () => { + const { unstable } = stableCallbackReport(); + assert.deepEqual(unstable, [], `改用 useStableCallback:\n${unstable.join('\n')}`); +}); + +// 场景:把 useStableCallback 全部撤掉、改回普通 useCallback 并去掉依赖数组。 +// 期望:失败 —— 上一条用例只在稳定回调确实存在时才有意义。 +run('Sidebar 依赖会变的回调已改用 useStableCallback', () => { + const { stableCallbacks } = stableCallbackReport(); + assert.ok(stableCallbacks >= 13, `expected the converted callbacks, found ${stableCallbacks}`); +}); diff --git a/web/src/lib/sidebarWorkspaceHeadingArchitecture.test.js b/web/src/lib/sidebarWorkspaceHeadingArchitecture.test.js index 14e305dea..d0c9e4487 100644 --- a/web/src/lib/sidebarWorkspaceHeadingArchitecture.test.js +++ b/web/src/lib/sidebarWorkspaceHeadingArchitecture.test.js @@ -75,7 +75,7 @@ test('reopening a collapsed workspace always restores the compact five-row sessi const sidebar = source('components/Sidebar.jsx'); const toggleStart = sidebar.indexOf('const onToggle = (hash) => {'); const toggleEnd = sidebar.indexOf('\n const onActivate', toggleStart); - const activateStart = sidebar.indexOf('const onActivate = useCallback(async (ws) => {'); + const activateStart = sidebar.indexOf('const onActivate = useStableCallback(async (ws) => {'); const activateEnd = sidebar.indexOf('\n useEffect(() => {\n const requestId = Number(workspaceActivationRequest', activateStart); const collapseStart = sidebar.indexOf('sessionListDisclosureCompactRef.current.add(hash);\n if (collapsing) {'); assert.ok(toggleStart >= 0 && toggleEnd > toggleStart); @@ -120,7 +120,7 @@ test('workspace folder clicks only disclose and headings have no selected stylin test('manual session batches survive late data and count only non-pinned rows at the end', () => { const sidebar = source('components/Sidebar.jsx'); - const toggleStart = sidebar.indexOf('const toggleSessionListExpanded = useCallback('); + const toggleStart = sidebar.indexOf('const toggleSessionListExpanded = useStableCallback('); const toggleEnd = sidebar.indexOf('\n useEffect(', toggleStart); const toggle = sidebar.slice(toggleStart, toggleEnd); assert.match(toggle, /const collapsing = action === 'collapse'/); diff --git a/web/src/lib/sidebarWorkspaceRefresh.test.js b/web/src/lib/sidebarWorkspaceRefresh.test.js index 60ce13f4c..2a1da590b 100644 --- a/web/src/lib/sidebarWorkspaceRefresh.test.js +++ b/web/src/lib/sidebarWorkspaceRefresh.test.js @@ -10,6 +10,7 @@ import * as pinnedSessions from './pinnedSessions.js'; import * as auxiliaryFetch from './sidebarAuxiliaryFetch.js'; import { applyStatusUpdate } from './sessionStatus.js'; import { createWorkspaceFolderOrderController } from './workspaceFolderOrder.js'; +import { commitIfChanged, structurallyEqual } from './structuralState.js'; // Execute the production callbacks, including the await boundaries and state // setters. Testing only the sequence helper cannot expose a check before a later @@ -23,7 +24,8 @@ const declarations = component.body.body.flatMap((statement) => statement.declar function installCallback(context, name) { const declaration = declarations.find((node) => node.id.name === name); - assert.equal(declaration?.init.callee?.name, 'useCallback', `Missing production callback ${name}`); + assert.ok(['useCallback', 'useStableCallback'].includes(declaration?.init.callee?.name), + `Missing production callback ${name}`); const callback = declaration.init.arguments[0]; vm.runInContext(`${name} = (${source.slice(callback.start, callback.end)})`, context); } @@ -60,7 +62,7 @@ function fixture({ const context = vm.createContext({ Map, Set, Array, Promise, setTimeout, ...workspaceSessions, ...sidebarSessions, ...sidebarTree, ...pinnedSessions, ...auxiliaryFetch, - applyStatusUpdate, + applyStatusUpdate, commitIfChanged, workspaces: state.workspaces, activeWorkspaceHash: 'w', revealTarget: { noWorkspace: false, workspaceHash: 'w' }, api: { @@ -106,18 +108,24 @@ function fixture({ pinnedByWorkspaceRef: new Map([['w', cachedPinnedIds]]), pinnedOrderItemsRef: [], }; for (const [name, current] of Object.entries(refs)) context[name] = { current }; - for (const [setter, key, ref] of [ - ['setSessions', 'sessions', 'sessionsRef'], - ['setSessionLoadingWorkspaces', 'loading'], + // structural = 生产代码里用 useStructuralState 声明的状态:内容不变时不写入。 + // state.writes 记录真正写入 React 状态的次数。 + state.writes = {}; + for (const [setter, key, ref, structural] of [ + ['setSessions', 'sessions', 'sessionsRef', true], + ['setSessionLoadingWorkspaces', 'loading', undefined, true], ['setSessionLoadedWorkspaces', 'loaded', 'sessionLoadedWorkspacesRef'], ['setSessionFullyLoadedWorkspaces', 'fullyLoaded', 'sessionFullyLoadedWorkspacesRef'], - ['setSessionListTotals', 'totals'], - ['setStatusBySession', 'statuses'], - ['setWorkspaces', 'workspaces'], + ['setSessionListTotals', 'totals', undefined, true], + ['setStatusBySession', 'statuses', undefined, true], + ['setWorkspaces', 'workspaces', undefined, true], ['setExpandedSessionLists', 'expandedSessionLists', 'expandedSessionListsRef'], ]) { context[setter] = (update) => { - state[key] = typeof update === 'function' ? update(state[key]) : update; + const next = typeof update === 'function' ? update(state[key]) : update; + if (structural && structurallyEqual(state[key], next)) return; + state.writes[key] = (state.writes[key] || 0) + 1; + state[key] = next; if (ref) context[ref].current = state[key]; if (key === 'workspaces') context.workspaces = state[key]; if (key === 'fullyLoaded') context.sessionFullyLoadedWorkspaces = state[key]; @@ -389,6 +397,42 @@ test('expansion and subsequent refresh request only the displayed batches', asyn assert.ok(requests.every(request => Number.isFinite(request.query.limit))); }); +// 场景:Desktop 挂机,侧栏每 5 秒刷新一次,服务端数据完全没变。 +// 期望:第二轮刷新不写入任何侧栏状态(连 setState 都不调用),各状态仍是第一轮的 +// 同一个引用,React 因此不渲染、也不在 hook 队列里留下 update。 +// 回归 bug:每轮都写回新的 Map / 数组触发渲染,侧栏的 useCallback 把每次渲染的作用域 +// 串成链留在内存里(实测空闲首页每次约 70 KB),渲染进程一两天涨到上限后显示 +// 「内存不足」错误页,只能手动刷新。只在 updater 里返回旧值也不行:React 18 会把这类 +// update 挂在 hook 队列里直到组件下次渲染,组件空闲时队列照样无限增长。 +test('an idle periodic refresh with unchanged data writes no sidebar state', async () => { + const { context, state, requests } = fixture(); + const first = context.refresh(); + await nextTask(); + requests[0].resolve(compactPage()); + await first; + const keys = ['sessions', 'statuses', 'totals', 'loading', 'loaded', 'fullyLoaded', 'workspaces', 'expandedSessionLists']; + const before = Object.fromEntries(keys.map((key) => [key, state[key]])); + const writesBefore = { ...state.writes }; + const second = context.refresh(); + await nextTask(); + assert.equal(requests.length, 2); + requests[1].resolve(compactPage()); + await second; + for (const key of keys) assert.equal(state[key], before[key], `${key} must keep its reference`); + assert.deepEqual(state.writes, writesBefore, 'an idle refresh must not call any state setter'); +}); + +// 场景:有人把刷新路径上的状态改回普通 useState。 +// 期望:直接失败 —— 上面那条用例里的测试替身按 useStructuralState 的语义写回, +// 只有生产声明也是 useStructuralState 时它才代表真实行为。 +test('sidebar refresh state is declared with useStructuralState', () => { + for (const name of ['workspaces', 'sessions', 'statusBySession', 'sessionLoadingWorkspaces', 'sessionListTotals', 'activeWorkspaceHash']) { + const declaration = declarations.find((node) => ( + node.id.type === 'ArrayPattern' && node.id.elements[0]?.name === name)); + assert.equal(declaration?.init.callee?.name, 'useStructuralState', name); + } +}); + test('runtime promotion and internal workspace selection do not change refresh dependencies', () => { const refresh = declarations.find(node => node.id.name === 'refresh'); const dependencies = source.slice(refresh.init.arguments[1].start, refresh.init.arguments[1].end); diff --git a/web/src/lib/structuralState.js b/web/src/lib/structuralState.js new file mode 100644 index 000000000..0643293c4 --- /dev/null +++ b/web/src/lib/structuralState.js @@ -0,0 +1,96 @@ +// 内容相同就不触发 React 状态更新。 +// +// 背景:Desktop 挂机一两天后渲染进程内存耗尽。侧栏每 5 秒刷新一次,数据没变 +// 也会生成新的 Map / Set / 数组写回状态,于是每轮都重新渲染。侧栏组件里几十个 +// useCallback 依赖各不相同,它们在不同渲染里交替重建;V8 让同一次渲染里的闭包 +// 共享一个作用域,旧回调就把旧渲染的整个作用域(含当时的会话列表)留住,一份 +// 接一份连成链,永远回收不掉。实测空闲首页每次渲染约留下 70 KB。 +// +// 内容没变时必须连 setState 都不调用,不能只在 updater 里返回旧值:React 18 对 +// 「结果与旧值相同」的更新会提前放弃渲染,但 update 对象(连同 updater 闭包和它 +// 捕获的数据)仍挂在该 hook 的队列里,要等这个组件下次渲染才清掉。组件一直空闲 +// 不渲染时,队列就无限增长 —— 只返回旧值等于把泄漏换了个地方。 + +import { useRef, useState } from 'react'; + +const MAX_DEPTH = 32; + +const tagOf = (value) => Object.prototype.toString.call(value); + +// 原型是某个 realm 的 Object.prototype(或 null)即普通对象;不直接比 +// Object.prototype,iframe / vm 里创建的对象原型不同。 +function isPlainObject(value) { + if (value === null || typeof value !== 'object' || tagOf(value) !== '[object Object]') return false; + const proto = Object.getPrototypeOf(value); + return proto === null || Object.getPrototypeOf(proto) === null; +} + +function equalAt(a, b, depth) { + if (Object.is(a, b)) return true; + if (depth > MAX_DEPTH) return false; + if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false; + + if (Array.isArray(a)) { + if (!Array.isArray(b) || a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + if (!equalAt(a[i], b[i], depth + 1)) return false; + } + return true; + } + if (tagOf(a) === '[object Map]') { + if (tagOf(b) !== '[object Map]' || a.size !== b.size) return false; + for (const [key, value] of a) { + if (!b.has(key) || !equalAt(value, b.get(key), depth + 1)) return false; + } + return true; + } + if (tagOf(a) === '[object Set]') { + if (tagOf(b) !== '[object Set]' || a.size !== b.size) return false; + for (const value of a) { + if (!b.has(value)) return false; + } + return true; + } + if (isPlainObject(a)) { + if (!isPlainObject(b)) return false; + const keys = Object.keys(a); + if (keys.length !== Object.keys(b).length) return false; + for (const key of keys) { + if (!Object.prototype.hasOwnProperty.call(b, key)) return false; + if (!equalAt(a[key], b[key], depth + 1)) return false; + } + return true; + } + // Date、类实例、函数等:只认同一引用。 + return false; +} + +// 数组 / 普通对象 / Map / Set 按内容比较;Set 成员与 Map 键按引用(侧栏里都是字符串)。 +export function structurallyEqual(a, b) { + return equalAt(a, b, 0); +} + +export function keepIfStructurallyEqual(prev, next) { + return structurallyEqual(prev, next) ? prev : next; +} + +// 以 ref 中的最新值为基准计算更新;内容没变就不调用 setState。 +// 前提:这份状态的每个写入方都同步更新 ref(否则 ref 可能落后,算出的值会覆盖别人的更新)。 +// 返回写入后的最新值。 +export function commitIfChanged(ref, setState, updater) { + const prev = ref.current; + const next = typeof updater === 'function' ? updater(prev) : updater; + if (structurallyEqual(prev, next)) return prev; + ref.current = next; + setState(next); + return next; +} + +// useState 的替代:setter 身份稳定,内容不变的更新不进 React 队列也不触发渲染。 +// 状态只能经这个 setter 写入,内部 ref 因此始终是最新值。 +export function useStructuralState(initial) { + const [value, setValue] = useState(initial); + const latestRef = useRef(value); + const [setStructural] = useState(() => (updater) => commitIfChanged(latestRef, setValue, updater)); + return [value, setStructural]; +} diff --git a/web/src/lib/structuralState.test.js b/web/src/lib/structuralState.test.js new file mode 100644 index 000000000..9f9fcbaca --- /dev/null +++ b/web/src/lib/structuralState.test.js @@ -0,0 +1,108 @@ +// structuralState 纯逻辑测试。 +// +// 背景:侧栏每 5 秒刷新,数据没变也写回新的 Map / Set / 数组,导致每轮重新渲染; +// 侧栏的 useCallback 闭包把每次渲染的作用域串成链,Desktop 挂机一两天后渲染进程 +// 内存耗尽。这里守住「内容相同就不写入」的判定,保证空闲刷新既不渲染也不留 update。 + +import assert from 'node:assert/strict'; +import vm from 'node:vm'; +import { + commitIfChanged, + keepIfStructurallyEqual, + structurallyEqual, +} from './structuralState.js'; + +function run(name, fn) { + try { + fn(); + console.log(`[pass] ${name}`); + } catch (error) { + console.error(`[fail] ${name}`); + throw error; + } +} + +// 场景:一轮刷新拿回与上一轮完全相同的会话列表(新数组、新对象、含嵌套数组)。 +// 期望:判为相同,沿用旧数组 —— React 因此跳过渲染。 +run('structurallyEqual 会话列表内容相同即相同', () => { + const prev = [ + { id: 's1', title: 'A', busy: false, todos: [{ text: 'x', done: false }], extra: null }, + { id: 's2', title: 'B', busy: true, timestamp_ms: 10 }, + ]; + const next = prev.map((item) => JSON.parse(JSON.stringify(item))); + assert.equal(structurallyEqual(prev, next), true); + assert.equal(keepIfStructurallyEqual(prev, next), prev); +}); + +// 场景:会话状态 Map / 已加载工作区 Set / 置顶表(Map)每轮都新建。 +// 期望:键值相同即相同;Set 成员相同即相同,不看插入顺序。 +run('structurallyEqual 支持 Map 与 Set', () => { + const prevStatus = new Map([['s1', { state: 'read', cursor: 3 }], ['s2', { state: 'unread', cursor: 5 }]]); + const nextStatus = new Map([['s1', { state: 'read', cursor: 3 }], ['s2', { state: 'unread', cursor: 5 }]]); + assert.equal(structurallyEqual(prevStatus, nextStatus), true); + assert.equal(structurallyEqual(new Set(['a', 'b']), new Set(['b', 'a'])), true); + assert.equal(structurallyEqual(new Map([['w', ['s1', 's2']]]), new Map([['w', ['s1', 's2']]])), true); +}); + +// 场景:真实变化 —— 某个会话变成忙碌、标题改了、多了一个会话、置顶顺序变了、 +// 键缺失但值为 undefined。期望:都判为不同,照常更新。 +run('structurallyEqual 识别真实变化', () => { + assert.equal(structurallyEqual([{ id: 's1', busy: false }], [{ id: 's1', busy: true }]), false); + assert.equal(structurallyEqual([{ id: 's1' }], [{ id: 's1' }, { id: 's2' }]), false); + assert.equal(structurallyEqual(new Map([['w', ['a', 'b']]]), new Map([['w', ['b', 'a']]])), false); + assert.equal(structurallyEqual(new Map([['s1', 1]]), new Map([['s2', 1]])), false); + assert.equal(structurallyEqual(new Set(['a']), new Set(['b'])), false); + assert.equal(structurallyEqual({ a: undefined }, { b: undefined }), false); + assert.equal(structurallyEqual([1], { 0: 1, length: 1 }), false); + assert.equal(structurallyEqual(new Map(), new Set()), false); +}); + +// 场景:状态里出现 Date / 类实例 / 函数。期望:只认同一引用,不做猜测。 +run('structurallyEqual 非普通对象只认同一引用', () => { + const fn = () => {}; + assert.equal(structurallyEqual({ fn }, { fn }), true); + assert.equal(structurallyEqual({ fn }, { fn: () => {} }), false); + assert.equal(structurallyEqual(new Date(1), new Date(1)), false); + assert.equal(structurallyEqual(NaN, NaN), true); +}); + +// 场景:对象来自另一个 realm(iframe、vm 上下文),原型不是本 realm 的 Object.prototype。 +// 期望:内容相同仍判为相同;类实例仍只认同一引用。 +run('structurallyEqual 跨 realm 的普通对象按内容比较', () => { + const foreign = vm.runInNewContext('({ id: "s1", tags: ["a"], meta: { n: 1 } })'); + assert.equal(structurallyEqual({ id: 's1', tags: ['a'], meta: { n: 1 } }, foreign), true); + class Row { constructor() { this.id = 's1'; } } + assert.equal(structurallyEqual(new Row(), new Row()), false); +}); + +// 场景:每轮刷新都用内容相同的新数组调用 setter(函数式更新或直接值)。 +// 期望:完全不调用 setState —— 只在 updater 里返回旧值不够,React 18 会把这类 update +// 挂在 hook 队列里直到组件下次渲染,组件空闲时队列会无限增长(修复过程中实测 +// 打开会话的页面每分钟多出约 0.24 MB)。内容变化时才写入,且 ref 同步成新值。 +run('commitIfChanged 内容相同不调用 setState', () => { + const ref = { current: [{ id: 's1' }] }; + const writes = []; + const setState = (value) => { writes.push(value); }; + const before = ref.current; + assert.equal(commitIfChanged(ref, setState, (prev) => prev.map((item) => ({ ...item }))), before); + assert.equal(commitIfChanged(ref, setState, [{ id: 's1' }]), before); + assert.equal(writes.length, 0); + assert.equal(ref.current, before); + + const next = commitIfChanged(ref, setState, (prev) => [...prev, { id: 's2' }]); + assert.equal(writes.length, 1); + assert.equal(writes[0], next); + assert.equal(ref.current, next); + assert.deepEqual(next, [{ id: 's1' }, { id: 's2' }]); +}); + +// 场景:同一轮里连续两次更新(例如先写单个工作区的会话,再写合并后的列表)。 +// 期望:第二次的 updater 拿到第一次的结果,而不是渲染前的旧值。 +run('commitIfChanged 连续更新以最新值为基准', () => { + const ref = { current: new Set(['w1']) }; + const writes = []; + commitIfChanged(ref, (value) => writes.push(value), (prev) => new Set(prev).add('w2')); + commitIfChanged(ref, (value) => writes.push(value), (prev) => new Set(prev).add('w3')); + assert.equal(writes.length, 2); + assert.deepEqual([...ref.current], ['w1', 'w2', 'w3']); +}); diff --git a/web/src/lib/useStableCallback.js b/web/src/lib/useStableCallback.js new file mode 100644 index 000000000..072a79f8f --- /dev/null +++ b/web/src/lib/useStableCallback.js @@ -0,0 +1,27 @@ +// 身份永远不变的回调:调用时转发到最近一次提交的渲染里的实现。 +// +// 背景:侧栏几十个 useCallback 依赖各不相同,在不同渲染里交替重建。V8 让同一次 +// 渲染里的闭包共享一个作用域,于是第 N 次渲染的作用域里保存着第 K 次渲染创建的 +// 回调,它又保存着第 J 次的……旧渲染的作用域(含当时的整份会话列表)一份接一份 +// 连成链,随渲染次数无限增长 —— Desktop 挂机一两天后渲染进程内存耗尽。 +// +// 这里返回的包装函数只在本 hook 第一次调用时创建,只捕获 ref;组件作用域里不再 +// 持有旧渲染创建的闭包,链条就断了。ref 只保存最新一次渲染的实现。 +// +// 适用:事件处理、effect 里调用的动作。不要用在渲染期间调用的函数上(渲染时拿到 +// 的是上一次提交的实现),也不要用在需要靠身份变化触发 effect 重跑的地方。 + +import { useInsertionEffect, useRef, useState } from 'react'; + +export function useStableCallback(implementation) { + const latest = useRef(implementation); + // insertion effect 在整棵树的 layout effect 之前执行,子组件的 layout effect + // 调用它时也能拿到本次提交的实现。 + useInsertionEffect(() => { + latest.current = implementation; + }); + const [stable] = useState(() => function stableCallback(...args) { + return latest.current.apply(this, args); + }); + return stable; +}