技术方案报告
Playwright 并发资源调度方案
全局浏览器锁 + pw-schedule 统一调度:2 核 3.6G VPS 上的多会话资源治理
一、背景与现象
Hermes 网关运行在 2 核 CPU / 3.6GB 内存的 VPS 上。当多个会话/多个项目同时运行时,各会话独立启动浏览器与本地服务,资源互相打架,出现「交通拥堵」——所有并发项目都无法按时完成任务。
典型现象(用户 2026-08-04 反馈)
- Playwright 测试大量失败或卡死
- 浏览器操作 30 秒超时
- 本地页面服务访问到「旧项目」的页面(端口串台)
- 用户原话:「应该是被抢占用来做其他任务了」
二、根因分析(日志实锤)
2.1 硬件天花板
| 资源 | 数值 | 含义 |
|---|---|---|
| CPU | 2 核 | 单个 Chromium 即可占满一个核 |
| 内存 | 3.6GB(swap 1.9GB) | 每个 Chromium 实例 300-800MB |
| 浏览器实例 | 每会话独立 | _active_sessions 按 task_id 隔离 |
2.2 三层根因
根因 1:调度层无全局闸门
tools/browser_tool.py:每个 task_id 独立创建 agent-browser daemon + Chromium 会话(_create_local_session)- 只有进程内线程锁
_cleanup_lock,没有跨会话的全局并发限制 - 8/4 18:21-18:24 三个 swarm 子会话(365b5c/7705a7/cc878b)同时跑 browser → cc878b 在 18:22:47 出现
browser 'eval' timed out after 30s
根因 2:操作层端口无治理
- 8/2 充电宝调研会话:8099 端口被 4 个残留 http.server 进程轮番占用,Playwright 测试全部访问到旧项目「微信小程序」页面而非新报告
- 各项目端口随意(8080/8099/8100/8101),无分配规范、无冲突检测
根因 3:清理层残留累积
- /tmp 下堆积十几个
.org.chromium.Chromium.*残留目录 - errors.log 记录 15+ 次 browser timed out
- 进程崩溃/会话异常退出后,Chromium 与 http.server 无人回收
2.3 一句话结论
有限资源(2 核 3.6G)被无界并发请求争抢——三套独立体系(agent-browser 会话、npx playwright test、http.server)互不感知、无排队、无端口分配、无残留清理。
三、方案设计(A+B 组合)
3.1 方案 A:全局浏览器闸门(源码层)
目标:保证同一时刻只有一个 Chromium 实例运行。
实现要点:
- 在
browser_tool.py增加跨进程文件锁:flock /tmp/hermes-browser.lock _get_session_info()创建本地会话前先尝试获取锁- 获取失败 → 排队等待(默认上限 300s),期间轮询
- 会话关闭/空闲超时(120s)→ 释放锁
- 排队超时 → 返回明确错误「浏览器繁忙,另一会话正在使用(已等待 300s)」
关键设计:
- 锁粒度 = 全局互斥(2 核物理上限,任何两个 Chromium 同跑都会抢 CPU)
- 排队而非直接失败(并发时等一等而不是任务中断)
- 非浏览器操作(终端/文件/网络)不受锁影响
3.2 方案 B:pw-schedule 统一调度(包装命令)
目标:统一治理 Playwright 测试与本地 HTTP 服务,端口永不串台,残留自动清理。
命令设计:
pw-schedule test [dir] # 包装 npx playwright test:排队 → 跑 → 释放 pw-schedule server [dir] # 端口池(8080-8105)分配 → 启动 http.server → 登记 → 退出清理 pw-schedule status # 查看当前占用 pw-schedule clean # 清理残留(孤儿 chromium / 残留 http.server / 过期注册项)
注册表:~/.hermes/state/playwright-registry.json
{
"entries": [
{"pid": 12345, "port": 8080, "dir": "/home/ubuntu/proj", "started_at": "...", "owner": "session-xxx", "type": "http.server"},
{"pid": 12346, "port": null, "dir": "/home/ubuntu/proj2", "started_at": "...", "owner": "session-yyy", "type": "playwright"}
]
}
端口分配:从 8080 起扫描,lsof 检测空闲即占用;冲突自动换下一个端口,不报错。
残留清理:孤儿判定 = 注册表有记录但进程不存在 → 删;socket 目录无 owner_pid 或 owner 进程已死 → 删。
3.3 实施步骤
阶段 1(立即,0 代码):运维纪律
- Playwright 测试统一
workers=1串行 - 每项目固定端口 + 启动前 lsof 检查
- 每日清理残留(手动或简单 cron)
阶段 2(源码层,1-2 小时):方案 A
- 改 browser_tool.py 加文件锁
- 配置项
browser.max_concurrent_browsers(默认 1) - 回归测试:并发调用验证排队
阶段 3(包装命令,2-3 小时):方案 B
- 写
pw-schedulePython CLI - 全局注册表 + 端口池 + 清理器
- 接入各项目测试命令(建议在 CLAUDE.md 中声明)
3.4 验收标准
| # | 验收用例 | 方案 |
|---|---|---|
| T1 | 两个会话同时调 browser_navigate,第二个排队而非立刻失败 | A |
| T2 | 排队超 300s 返回「浏览器繁忙」明确错误 | A |
| T3 | 会话结束后锁释放,下一个会话立即可用 | A |
| T4 | 两个项目同时起 http.server,端口自动错开不串台 | B |
| T5 | 进程崩溃后残留 chromium 可被 clean 清理 | B |
| T6 | status 命令实时反映端口/进程占用 | B |
| T7 | 非浏览器操作(终端命令)不受锁影响 | A |
四、风险与对策
| 风险 | 对策 |
|---|---|
| 全局互斥导致浏览器任务串行变慢 | 2 核机器上串行是唯一正确选择;单会话多页面不受影响 |
| 文件锁在进程崩溃时未释放 | flock 由内核自动释放(进程退出即解锁),天然安全 |
| 排队 300s 太久 | 可配置;默认值兼顾「等一会 vs 别无限等」 |
| 历史项目不记得用 pw-schedule | 提供 wrapper 兼容(pw-schedule test 内直接透传原参数);CLAUDE.md 声明 |
五、附录:关键日志证据
# 8/4 18:22:47 三子会话并发,浏览器 eval 超时 2026-08-04 18:22:47,278 WARNING [20260804_182137_cc878b] tools.browser_tool: browser 'eval' timed out after 30s (task=sa-1-01455396, socket_dir=/tmp/agent-browser-h_22f115714e) # 8/2 充电宝会话:8099 端口被 4 个残留 http.server 轮番占用 "8099 端口被 4 个残留的旧 http.server 进程轮番占用,导致 Playwright 测试大量失败(访问到的是旧项目的「微信小程序」页面而不是我们的报告)" # 用户现场描述 2026-08-04 18:42:32 [20260804_005024_310376fe] msg='[grootwu] 应该是被抢占用来做其他任务了' # 并发窗口:8/4 18:21-18:24 三个会话同时使用 browser 2026-08-04 18:21 20260804_182137_365b5c 2026-08-04 18:21 20260804_182137_7705a7 2026-08-04 18:21 20260804_182137_cc878b
六、结论
Playwright 并发打架的根因是资源无调度:2 核 3.6G 的机器上,多个会话独立启动 Chromium/测试/本地服务,互不感知、无排队、无端口分配、无残留清理。方案 A(源码层全局浏览器锁)+ 方案 B(pw-schedule 统一调度)双管齐下:A 让浏览器会话串行排队不互相饿死,B 让端口统一分配不串台、残留自动清理。实施分三阶段,从零代码运维纪律到源码改造,每阶段有明确验收标准,可逐步落地。