Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 和内网服务;凡是宿主能覆盖的挡板一律静默放行,**没有开关、没有徽标、没有确认页、没有逐站点例外、没有任何用户可见的策略提示**。看到「怎么不校验证书」时不要顺手加回来 —— 先看这一节。
Expand Down
3 changes: 3 additions & 0 deletions cmake/acecode_source_paths.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
2 changes: 2 additions & 0 deletions openspec/changes/stabilize-desktop-idle-memory/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-19
26 changes: 26 additions & 0 deletions openspec/changes/stabilize-desktop-idle-memory/design.md
Original file line number Diff line number Diff line change
@@ -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。
21 changes: 21 additions & 0 deletions openspec/changes/stabilize-desktop-idle-memory/proposal.md
Original file line number Diff line number Diff line change
@@ -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 活跃切换的剩余增长留后续任务。
Original file line number Diff line number Diff line change
@@ -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** 恢复回调不得访问宿主或重载页面
9 changes: 9 additions & 0 deletions openspec/changes/stabilize-desktop-idle-memory/tasks.md
Original file line number Diff line number Diff line change
@@ -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、映射与差异检查通过。
23 changes: 23 additions & 0 deletions src/apps/desktop/renderer_recovery.cpp
Original file line number Diff line number Diff line change
@@ -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<int>(reloads_ms_.size()) >= limits_.max_reloads) {
return RendererRecoveryAction::GiveUp;
}
reloads_ms_.push_back(now_ms);
return RendererRecoveryAction::Reload;
}

} // namespace acecode::desktop
51 changes: 51 additions & 0 deletions src/apps/desktop/renderer_recovery.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#pragma once

#include <cstdint>
#include <deque>

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<std::int64_t> reloads_ms_;
};

} // namespace acecode::desktop
59 changes: 57 additions & 2 deletions src/apps/desktop/web_host.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<WindowsTaskbarBadge>(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 刷靠这里补上。
Expand Down Expand Up @@ -2218,6 +2264,9 @@ struct WebHost::Impl {
#endif

~Impl() {
#ifdef _WIN32
renderer_lifetime.revoke();
#endif
window_focus_handler = {};
#ifdef _WIN32
taskbar_badge.reset();
Expand Down Expand Up @@ -2278,6 +2327,9 @@ struct WebHost::Impl {
}
#endif
std::unique_ptr<webview::webview> w;
#ifdef _WIN32
LifetimeToken renderer_lifetime;
#endif
#if !defined(_WIN32) && !defined(__APPLE__)
bool linux_center_on_first_show = false;
#endif
Expand All @@ -2294,7 +2346,10 @@ struct WebHost::Impl {
};

WebHost::WebHost(bool debug, StartupWindowMode startup_mode)
: impl_(std::make_unique<Impl>(debug, startup_mode)) {}
: impl_(std::make_unique<Impl>(debug, startup_mode)) {
// 库的桥接脚本在构造时注入,这里紧随其后,早于页面脚本执行。
impl_->w->init(kBridgeReplyCleanupScript);
}
WebHost::~WebHost() = default;

void WebHost::set_title(const std::string& title) {
Expand Down
Loading
Loading