浏览器 Evaluate CDP 重构计划

背景

act:evaluate 在页面中执行用户提供的 JavaScript。目前通过 Playwright(page.evaluatelocator.evaluate)运行。Playwright 按页面串行化 CDP 命令,所以一个卡住或长时间运行的 evaluate 会阻塞该页面的命令队列,让之后所有操作看起来都”卡住了”。

PR #13498 添加了一个务实的安全网(有界 evaluate、中止传播和尽力恢复)。本文档描述了一个更大的重构,让 act:evaluate 从本质上与 Playwright 隔离,使卡住的 evaluate 不能阻塞正常的 Playwright 操作。

目标

  • act:evaluate 不能永久阻塞同一标签页上后续的浏览器操作。
  • 超时是端到端的单一来源,调用方可以依赖预算。
  • 中止和超时在 HTTP 和进程内 dispatch 中行为一致。
  • 支持 evaluate 的元素定位,而无需把所有东西都从 Playwright 上切走。
  • 保持对现有调用方和 payload 的向后兼容。

不做的事

  • 将所有浏览器操作(click、type、wait 等)替换为 CDP 实现。
  • 移除 PR #13498 引入的现有安全网(它作为有用的回退保留)。
  • 在现有 browser.evaluateEnabled 门控之外引入新的不安全能力。
  • 为 evaluate 添加进程隔离(worker 进程/线程)。如果本次重构后仍然看到难以恢复的卡住状态,那是后续的想法。

当前架构(为什么会卡住)

高层来看:

  • 调用方向浏览器控制服务发送 act:evaluate
  • 路由处理器调用 Playwright 来执行 JavaScript。
  • Playwright 串行化页面命令,所以永不完成的 evaluate 会阻塞队列。
  • 被阻塞的队列意味着该标签页上后续的 click/type/wait 操作会看似挂起。

提案架构

1. 超时传播

引入单一预算概念,从中派生所有东西:

  • 调用方设置 timeoutMs(或未来的截止时间)。
  • 外层请求超时、路由处理器逻辑和页面内的执行预算都使用同一预算,在需要时为序列化开销留少量余量。
  • 中止通过 AbortSignal 到处传播,确保取消行为一致。

实现方向:

  • 添加一个小辅助(例如 createBudget({ timeoutMs, signal }))返回:
    • signal:链接的 AbortSignal
    • deadlineAtMs:绝对截止时间
    • remainingMs():子操作的剩余预算
  • 在以下位置使用此辅助:
    • src/browser/client-fetch.ts(HTTP 和进程内 dispatch)
    • src/node-host/runner.ts(代理路径)
    • 浏览器操作实现(Playwright 和 CDP)

2. 独立的 Evaluate 引擎(CDP 路径)

添加基于 CDP 的 evaluate 实现,不共享 Playwright 的每页面命令队列。关键在于 evaluate 传输使用独立的 WebSocket 连接和独立的 CDP 会话附加到目标。

实现方向:

  • 新模块,例如 src/browser/cdp-evaluate.ts,它:
    • 连接到配置的 CDP 端点(浏览器级 socket)。
    • 使用 Target.attachToTarget({ targetId, flatten: true }) 获取 sessionId
    • 运行:
      • Runtime.evaluate 用于页面级 evaluate,或
      • DOM.resolveNode + Runtime.callFunctionOn 用于元素级 evaluate。
    • 超时或中止时:
      • 尽力发送 Runtime.terminateExecution
      • 关闭 WebSocket 并返回清晰错误。

说明:

  • 这仍然在页面中执行 JavaScript,所以终止可能有副作用。好处是它不会阻塞 Playwright 队列,且在传输层可以通过关闭 CDP 会话来取消。

3. Ref 方案(不全面重写的元素定位)

难点是元素定位。CDP 需要 DOM handle 或 backendDOMNodeId,而目前大多数浏览器操作使用基于快照 ref 的 Playwright locator。

建议方案:保留现有 ref,但附加一个可选的 CDP 可解析 id。

3.1 扩展存储的 Ref 信息

扩展存储的角色 ref 元数据以可选包含 CDP id:

  • 当前:{ role, name, nth }
  • 提案:{ role, name, nth, backendDOMNodeId?: number }

这保持所有现有的 Playwright 操作正常工作,同时允许 CDP evaluate 在 backendDOMNodeId 可用时接受相同的 ref 值。

3.2 在快照时填充 backendDOMNodeId

生成角色快照时:

  1. 照常生成现有的角色 ref 映射(role, name, nth)。
  2. 通过 CDP(Accessibility.getFullAXTree)获取 AX 树,使用相同的去重规则计算 (role, name, nth) -> backendDOMNodeId 的并行映射。
  3. 将 id 合并回当前标签页的存储 ref 信息。

如果某个 ref 映射失败,保留 backendDOMNodeId 为 undefined。这让该功能成为尽力而为的,可以安全上线。

3.3 带 Ref 的 Evaluate 行为

act:evaluate 中:

  • 如果 ref 存在且有 backendDOMNodeId,通过 CDP 运行元素级 evaluate。
  • 如果 ref 存在但没有 backendDOMNodeId,回退到 Playwright 路径(仍有安全网)。

可选逃生通道:

  • 扩展请求结构以直接接受 backendDOMNodeId(为高级调用方和调试用),同时保留 ref 作为主要接口。

4. 保留最后手段的恢复路径

即使有 CDP evaluate,还有其他方式可以阻塞标签页或连接。保留现有恢复机制(终止执行 + 断开 Playwright)作为最后手段,用于:

  • 遗留调用方
  • CDP attach 被阻止的环境
  • 意外的 Playwright 边缘情况

实现计划(单次迭代)

交付物

  • 基于 CDP 的 evaluate 引擎,在 Playwright 每页面命令队列之外运行。
  • 调用方和处理器一致使用的单一端到端超时/中止预算。
  • 可以可选携带 backendDOMNodeId 的 Ref 元数据用于元素级 evaluate。
  • act:evaluate 尽可能使用 CDP 引擎,否则回退到 Playwright。
  • 证明卡住的 evaluate 不会阻塞后续操作的测试。
  • 让失败和回退可见的日志/指标。

实现清单

  1. 添加共享的”预算”辅助,将 timeoutMs + 上游 AbortSignal 链接为:
    • 单一 AbortSignal
    • 绝对截止时间
    • 下游操作的 remainingMs() 辅助
  2. 更新所有调用方路径使用该辅助,让 timeoutMs 到处含义一致:
    • src/browser/client-fetch.ts(HTTP 和进程内 dispatch)
    • src/node-host/runner.ts(node 代理路径)
    • 调用 /act 的 CLI 包装(给 browser evaluate 添加 --timeout-ms
  3. 实现 src/browser/cdp-evaluate.ts
    • 连接到浏览器级 CDP socket
    • Target.attachToTarget 获取 sessionId
    • 运行 Runtime.evaluate 做页面级 evaluate
    • 运行 DOM.resolveNode + Runtime.callFunctionOn 做元素级 evaluate
    • 超时/中止时:尽力 Runtime.terminateExecution 然后关闭 socket
  4. 扩展存储的角色 ref 元数据以可选包含 backendDOMNodeId
    • 保持现有 { role, name, nth } 行为用于 Playwright 操作
    • 添加 backendDOMNodeId?: number 用于 CDP 元素定位
  5. 在快照创建期间填充 backendDOMNodeId(尽力而为):
    • 通过 CDP(Accessibility.getFullAXTree)获取 AX 树
    • 计算 (role, name, nth) -> backendDOMNodeId 并合并到存储的 ref 映射
    • 如果映射模糊或缺失,保留 id 为 undefined
  6. 更新 act:evaluate 路由:
    • 如果没有 ref:总是使用 CDP evaluate
    • 如果 ref 解析到 backendDOMNodeId:使用 CDP 元素级 evaluate
    • 否则:回退到 Playwright evaluate(仍有界和可中止)
  7. 保留现有的”最后手段”恢复路径作为回退,而非默认路径。
  8. 添加测试:
    • 卡住的 evaluate 在预算内超时,下一个 click/type 成功
    • 中止取消 evaluate(客户端断开或超时)并解锁后续操作
    • 映射失败干净地回退到 Playwright
  9. 添加可观测性:
    • evaluate 持续时间和超时计数器
    • terminateExecution 使用率
    • 回退率(CDP -> Playwright)及原因

验收标准

  • 故意挂起的 act:evaluate 在调用方预算内返回且不阻塞该标签页的后续操作。
  • timeoutMs 在 CLI、agent 工具、node 代理和进程内调用中行为一致。
  • 如果 ref 可以映射到 backendDOMNodeId,元素级 evaluate 使用 CDP;否则回退路径仍然有界且可恢复。

测试计划

  • 单元测试:
    • 角色 ref 与 AX 树节点之间的 (role, name, nth) 匹配逻辑。
    • 预算辅助行为(余量、剩余时间计算)。
  • 集成测试:
    • CDP evaluate 超时在预算内返回且不阻塞下一个操作。
    • 中止取消 evaluate 并尽力触发终止。
  • 契约测试:
    • 确保 BrowserActRequestBrowserActResponse 保持兼容。

风险和缓解

  • 映射不完美:
    • 缓解:尽力映射,回退到 Playwright evaluate,添加调试工具。
  • Runtime.terminateExecution 有副作用:
    • 缓解:仅在超时/中止时使用,在错误消息中记录行为。
  • 额外开销:
    • 缓解:只在请求快照时获取 AX 树,按目标缓存,保持 CDP 会话短命。
  • 扩展中继限制:
    • 缓解:当每页面 socket 不可用时使用浏览器级 attach API,保留当前 Playwright 路径作为回退。

待定问题

  • 新引擎是否应该可配置为 playwrightcdpauto
  • 是否要为高级用户暴露新的”nodeRef”格式,还是只保留 ref
  • 帧快照和选择器作用域快照如何参与 AX 映射?