269 lines
12 KiB
Markdown
269 lines
12 KiB
Markdown
# Proposal M9: browser / automation 拆出独立 utility process
|
||
|
||
> 来源:问题.md M9(行 272-276)、附录 I 第 3 条(行 589-590)
|
||
> 状态:proposed
|
||
> 提出:Roundtable 2026-04-20(Linus + Karpathy + Steve Jobs)
|
||
> Owner: TBD
|
||
> 估计工时:3 周
|
||
> Blocking dependencies:C2(RuntimeBus 就绪后,跨进程 dispatch 更容易)
|
||
|
||
## 问题陈述
|
||
|
||
`src/main/browser/` 和 `src/main/automation/` 两个子系统目前运行在主进程:
|
||
- `browser/` 跑 Electron 的 BrowserWindow / BrowserView,做网页自动化(填表单、点按钮、截图)
|
||
- `automation/` 跑 rule engine,触发 browser actions 或其他 side effects
|
||
|
||
后果:
|
||
1. **Crash blast radius 大**:browser page crash / 内存泄漏直接拖垮 main 进程,整个 desktop app 挂掉
|
||
2. **事件循环阻塞**:自动化脚本跑长时间同步操作(例如等待 CAPTCHA)时,main 进程 UI IPC 都受影响
|
||
3. **Cloudflare / CAPTCHA 等防爬虫对抗**:反爬逻辑跑在 main 进程意味着主程序有"机器人"指纹,可能被下游服务整体封禁
|
||
4. **资源占用不可见**:browser 内存和 CPU 混在 main 进程里,不便于监控 / 限额
|
||
5. **Attack surface**:自动化脚本触达外部网站,一旦被攻击(XSS / sandbox escape)直接污染 main 进程的数据库连接和文件系统权限
|
||
|
||
## 根因分析
|
||
|
||
Linus 视角:**crash blast radius 是进程边界的第一目的**。把 browser 跑在 main 进程 = 把不稳定的组件和稳定的组件绑一起了。Electron 提供 utility process 正是为此——crash 就让它 crash,主进程重启它即可。
|
||
|
||
当前架构把"永远不 crash 的组件(main)"和"经常 crash 的组件(browser automation)"放在同一个进程里——这是 colocation 反模式。
|
||
|
||
Karpathy 视角:agent 跑 browser automation 是典型的 stochastic task——成功率不是 100%。需要**独立的失败域 + 重试机制 + resource cap**。跑在 main 进程无法给 browser 设单独的 memory limit / CPU quota。
|
||
|
||
## 设计方案
|
||
|
||
### 架构
|
||
|
||
```
|
||
Main process
|
||
├── Orchestration / DB / IPC to renderer
|
||
│
|
||
├──[fork]──> Worker utility process (agent run loop)
|
||
│
|
||
├──[fork]──> Browser utility process (browser automation)
|
||
│ ├── Electron BrowserWindow / Puppeteer
|
||
│ ├── 独立内存 / CPU quota
|
||
│ └── 通过 bus dispatch "browserAction.dispatch" 消费
|
||
│
|
||
└──[fork]──> Automation utility process (rule engine)
|
||
├── Rule evaluation loop
|
||
├── Scheduled triggers
|
||
└── 通过 bus dispatch 发送到 browser / agent
|
||
```
|
||
|
||
### 对 C2 bus 的依赖
|
||
|
||
本 proposal 依赖 C2 的 RuntimeBus——拆进程后跨进程 dispatch 最自然的实现是:
|
||
- Main 的 bus 是 hub
|
||
- utility process 和 main 之间通过 `process.parentPort.postMessage` 传递 command/event
|
||
- Bus 在 main 侧做 multiplexing:根据 command kind 路由到对应 utility process
|
||
|
||
这让 C2 的抽象同时覆盖 in-process 和 cross-process 两种 dispatch,**代码层面完全一致**。
|
||
|
||
### Browser utility process 实现
|
||
|
||
新建 `src/main/browser-process/entry.ts`:
|
||
|
||
```ts
|
||
import { parentPort } from "node:worker_threads"; // 或 utilityProcess API
|
||
|
||
const browser = new BrowserAutomationService(...);
|
||
|
||
parentPort.on("message", async (msg) => {
|
||
if (msg.kind === "browserAction.dispatch") {
|
||
try {
|
||
const result = await browser.execute(msg.payload);
|
||
parentPort.postMessage({ kind: "browserAction.result", runId: msg.runId, result });
|
||
} catch (e) {
|
||
parentPort.postMessage({ kind: "browserAction.error", runId: msg.runId, error: serializeError(e) });
|
||
}
|
||
}
|
||
});
|
||
```
|
||
|
||
### Automation utility process
|
||
|
||
rule engine 比 browser 轻量,但同样的道理——独立进程 = 独立故障域。新建 `src/main/automation-process/entry.ts`:
|
||
|
||
```ts
|
||
const engine = new AutomationRuleEngine(db);
|
||
|
||
engine.on("fire", (payload) => {
|
||
parentPort.postMessage({ kind: "automation.fired", ...payload });
|
||
});
|
||
|
||
// tick loop
|
||
setInterval(() => engine.evaluate(), 1000);
|
||
```
|
||
|
||
### DB 访问
|
||
|
||
utility process **不直接访问 DB**——所有 DB 操作通过 bus 让 main 进程的 writer actor 处理(与 B4 的 single-writer actor 配合)。
|
||
|
||
这避免 SQLite 跨进程连接的复杂度(SQLite 支持跨进程但要处理 WAL 同步)。
|
||
|
||
### Resource cap
|
||
|
||
- Electron utility process 可设 `memoryLimit`(v27+)
|
||
- 配置 `maxOldSpaceSize` 控制 V8 heap
|
||
- Main 进程定期 ping utility process,超时自动 kill + restart
|
||
|
||
### Crash recovery
|
||
|
||
Browser utility process crash 时:
|
||
1. Main 收到 `process.exit` 事件
|
||
2. 发"browser 不可用"告警
|
||
3. 根据 restart policy 重启进程
|
||
4. 重启后已在运行的 browser action 状态从 DB 恢复
|
||
|
||
Automation utility process 同理。
|
||
|
||
## Migration 计划
|
||
|
||
1. **Phase 0(前置)**:C2 RuntimeBus 完成
|
||
2. **Phase 1(1 周)**:设计 cross-process bus 协议(message envelope / error serialization / backpressure)
|
||
3. **Phase 2(1 周)**:browser 拆进程——browser automation 代码搬到 `browser-process/`,main 侧改为通过 bus dispatch
|
||
4. **Phase 3(3 天)**:automation 拆进程——同样方式
|
||
5. **Phase 4(3 天)**:monitoring & restart policy—— main 侧加 utility process health check + telemetry
|
||
6. **Phase 5(1 周观察期)**:生产压力测试 / crash 注入测试,确认 blast radius 隔离
|
||
|
||
## 风险与回滚
|
||
|
||
| 风险 | 触发条件 | 回滚方法 |
|
||
|------|---------|---------|
|
||
| IPC latency 升高 | browserAction 从本地函数调用变成跨进程 | 每次 action 多 1-2ms 可忽略;如果某 action 需要高频通信则重新评估 |
|
||
| State 丢失 | utility process crash 时正在执行的 action 状态 | 所有 action 状态进 DB 持久化,crash 后恢复 |
|
||
| 开发体验差 | 调试需要 attach 多个进程 | VS Code 多 process debug configuration |
|
||
| macOS entitlements | utility process 需要特殊 entitlement 才能跑某些 Electron API | 研究 Apple 文档,提前在 entitlements.plist 加 |
|
||
|
||
**回滚路径**:拆进程过程保留旧 in-process 代码路径作为 feature flag(`BROWSER_USE_UTILITY_PROCESS=true/false`),回滚时切 flag。
|
||
|
||
## 验证标准
|
||
|
||
1. **Blast radius 验证**:故意在 browser utility process 内 `process.exit(1)`,main 进程应不受影响,UI 仍可用
|
||
2. **Memory isolation**:browser automation 跑长时间任务,main 进程 memory 稳定不增长
|
||
3. **Restart 自动化**:kill -9 browser process,main 检测到后自动重启,丢失 ≤1 个 in-flight action
|
||
4. **Latency baseline**:browserAction.dispatch 的 P99 latency 在可接受区间(< 50ms 额外开销)
|
||
5. **Crash rate telemetry**:生产观察一周,browser process crash 数据可见且独立于 main 进程崩溃
|
||
|
||
## 不在范围
|
||
|
||
- **不把 worker 也一起重构**:worker 已经是 utility process(见 worker-entry.ts),本 proposal 只补 browser / automation
|
||
- **不实现跨机器 dispatch**:bus 仍是单机进程间,不做分布式
|
||
- **不重构 rule engine 的规则 DSL**:只是搬进程
|
||
- **不做 browser farm**:单个 browser utility process 就够,不是并发 N 个实例
|
||
- **不改 renderer**:renderer 层对 browser / automation 的交互仍走 main → utility process 中转
|
||
|
||
## 参考文件清单
|
||
|
||
- `src/main/browser/`(当前 browser automation 代码)
|
||
- `src/main/automation/`(当前 automation rule engine 代码)
|
||
- `src/main/automation-service.ts`(当前 rule engine 入口)
|
||
- `src/main/browser/browser-action-service.ts`(action 执行)
|
||
- `src/main/browser/browser-action-executor.ts`(具体 action)
|
||
- `src/main/service-graph.ts:110-113`(browserActionRef / automationRef 的 LateBinding,C2 会改)
|
||
- Electron utilityProcess 文档:https://www.electronjs.org/docs/latest/api/utility-process
|
||
|
||
## 附:为什么现在修
|
||
|
||
单个 browser page 的崩溃已经是生产痛点——任何 agent 的网页操作触发 chromium crash 都拖垮用户整个 desktop app。随着 agent 被用于更多 web automation 场景(社交登录、paid subscription 填表、CAPTCHA 对抗),crash 频率只会升。
|
||
|
||
C2 做完后,跨进程 bus 成本极低——本 proposal 是把 C2 的 bus 抽象延伸到进程边界,很自然。
|
||
|
||
Jobs 视角:**"app 不能崩"是 desktop 软件的基础承诺**。当前架构把 desktop 稳定性和 agent browser 稳定性绑定了,这违反了承诺。
|
||
|
||
## 附:gating matrix
|
||
|
||
| Gate | 条件 | 放行判断人 |
|
||
|------|------|----------|
|
||
| G1 | C2 RuntimeBus 完成 + 跨进程 dispatch 协议定稿 | Linus |
|
||
| G2 | browser utility process 跑 long-running automation 1 小时无 main 进程回压 | Owner |
|
||
| G3 | Kill utility process 后 main 进程自动重启,in-flight action 损失 ≤ 1 | Owner |
|
||
| G4 | Browser process 内存隔离验证:独立 heap,不与 main 共享 | Linus |
|
||
| G5 | macOS entitlements 配置完整,通过 notarization | Owner |
|
||
|
||
## 附:跨进程 message envelope
|
||
|
||
```ts
|
||
interface CrossProcessMessage {
|
||
id: string; // correlation id
|
||
kind: CommandKind | EventKind;
|
||
payload: unknown; // 由 kind 决定 shape
|
||
source: "main" | "browser-process" | "automation-process" | "worker";
|
||
timestamp: number;
|
||
// Error case
|
||
error?: { name: string; message: string; stack?: string };
|
||
}
|
||
```
|
||
|
||
序列化走 JSON(Electron utilityProcess 默认);大 payload 用 MessagePort + structured clone。
|
||
|
||
## 附:跨进程 DB 访问禁令
|
||
|
||
Utility process 内禁止直接 `new DatabaseSync(...)` ——所有 DB 操作走 bus dispatch 到 main 的 writer actor(B4)。原因:
|
||
1. SQLite WAL 跨进程同步复杂
|
||
2. Main 的 writer actor 保持单 writer 不变式
|
||
3. Utility process crash 不会导致 DB 文件锁残留
|
||
|
||
## 附:反模式对比
|
||
|
||
**Before**:
|
||
- Main 进程 import browser automation service
|
||
- 浏览器 crash → main 进程 crash → desktop app 关闭
|
||
- 用户 log off,30+ 秒后重新登录才能恢复
|
||
|
||
**After**:
|
||
- Main 进程通过 bus dispatch 到 browser utility process
|
||
- 浏览器 crash → utility process 独立退出 → main 进程 2 秒内重启它
|
||
- 用户看到 "browser automation recovering",UI 其他部分无感
|
||
|
||
## 附:为什么不选其他方案
|
||
|
||
**方案 A(当前 proposal)**:Electron utilityProcess 隔离。
|
||
|
||
**方案 B(完全拆出 desktop app 外,跑独立 daemon)**:过度工程——用户不希望后台跑多个进程。
|
||
|
||
**方案 C(保留 main 进程但加 try-catch)**:JS 层的 try-catch 无法 catch native crash(chromium renderer crash)。
|
||
|
||
方案 A 是 Electron 官方推荐的隔离路径。
|
||
|
||
## 附:restart policy 细节
|
||
|
||
| 情况 | 行为 |
|
||
|-----|------|
|
||
| Browser utility process 正常退出(exit 0) | 不重启(可能是主动 stop) |
|
||
| 异常退出(exit ≠ 0) | 立即重启,最多 3 次/分钟 |
|
||
| 超过 3 次/分钟 | 进入 backoff(冷却 5 分钟) |
|
||
| 冷却期间 browserAction.dispatch | 直接返回 `BROWSER_UNAVAILABLE` 错误 |
|
||
| 冷却结束 | 自动重启;成功则清 backoff 计数 |
|
||
| 连续 3 次 backoff 失败 | 告警 + 停止重启,等用户手动恢复 |
|
||
|
||
## 附:resource cap 配置
|
||
|
||
```ts
|
||
const browserProcess = utilityProcess.fork(entryPath, args, {
|
||
serviceName: "agent-company-browser",
|
||
stdio: "pipe",
|
||
execArgv: ["--max-old-space-size=2048"], // 2GB heap 上限
|
||
});
|
||
```
|
||
|
||
macOS 的 resource monitor 可以看到 `agent-company-browser` 的 CPU/memory 独立使用,用户也能直接感知 automation 占用。
|
||
|
||
## 附:与 C2 的跨进程扩展
|
||
|
||
C2 的 InProcessRuntimeBus 扩展为支持 cross-process:
|
||
|
||
```ts
|
||
class CrossProcessRuntimeBus implements RuntimeBus {
|
||
constructor(private child: UtilityProcess, private local: InProcessRuntimeBus) {}
|
||
|
||
async dispatch(kind, payload) {
|
||
if (this.isLocalKind(kind)) {
|
||
return this.local.dispatch(kind, payload);
|
||
}
|
||
return this.sendToChild(kind, payload);
|
||
}
|
||
}
|
||
```
|
||
|
||
消费者代码不变——bus 抽象隐藏了进程边界。
|