Files
Cyrene_v2/internal/capability/browser/launch.go
T
AskaEth 7f63c5bec4
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(browser): 同 profile 的浏览器还活着时不再必然启动超时
真机复现(我做的):同 profile 再起一次 chrome.exe ⇒ Chrome **把命令委托给已有
实例并 exit 0**,stderr 逐字「正在现有的浏览器会话中打开。」⇒ **不产生新的
DevToolsActivePort** ⇒ 我们的等待只能超时 ⇒ 浏览器降级档整体不可用。

修法:启动前加一跳**探测**,三档各自出路 ——
- 端口记录缺席 / 端口没人听 ⇒ 正常启动(后者先把**陈记录**清掉:那是启动时写下
  的记录、不是锁,删一条指向死端口的记录不会伤到任何活实例);
- 端口在听 + `/json/version` 有合法响应 ⇒ **复用它**(INFO `browser debug
  endpoint reused`),**不再起第二个**;
- 端口在听但握不上手 ⇒ **如实报错**(保留现场,既不起进程也不删记录);
- 真被占用且拿不到端点 ⇒ 报错带 **Chrome 原话**(`withChromeOutput`)+ 可操作处置。

🔴 红线(写进错误文案与注释):**绝不杀别人的浏览器**、不发信号抢 profile、
不新开监听端口;`ErrLaunch` / `errors.Is` 口径未破。

验收(主会话复跑 + 亲自变异):browser 包与 plugins/tools/io 两包 build/vet/test
全绿;我用 -overlay 摘掉「复用」那一支 ⇒ TestLocalChromeAdoptsLiveEndpoint 与
TestDial_ReusesLiveEndpointAndDoesNotSpawnSecondProcess 变红。子代理另跑了真机
端到端(临时 profile):会话 1 拿端口 51732 → 裸起第二次 chrome(exit 0)→
会话 2 **复用同一端口、未起第二个实例**。

⚠️ 已知未修(如实):Chrome 那句委托提示是**控制台代码页(GBK)字节**,我们的
UTF-8 日志里会显示成乱码(字节保真、人看不了);彻底解决要引编码转换,另定。
⚠️ 判据③的取舍:陈记录 +端口恰被别的程序复用时,会持续报错直到人工删
`DevToolsActivePort`(错误文案已写明处置);**未加"报错后自动清理重试"**。
2026-10-07 10:56:16 +08:00

379 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package browser
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
)
// 启动参数与就绪等待的具名常量(AGENTS §10:不出现裸魔法数字)。
const (
// devToolsPortFile 是 Chromium 在 `--remote-debugging-port=0` 时写回端口号的
// 文件名(profile 目录下):首行端口、次行 browser_guid
// (reference/browser-stealth/10 §2.4,依据 content/browser/devtools/devtools_http_handler.cc)。
devToolsPortFile = "DevToolsActivePort"
// portPollInterval 是「等调试端口就绪」的轮询间隔:冷启动通常 0.3~1.5s,
// 100ms 的粒度既不迟钝也不白烧 CPU。
portPollInterval = 100 * time.Millisecond
// defaultLang 是启动时声明的 UI 语言。口径:与「本机 / 出口 IP 所在地」对齐
// (design/28 §2 指纹一致性);写死 zh-CN 是本部署的实情,配置面(browser.json)
// 尚未开放语言项 —— 见本片报告「未做」。
defaultLang = "zh-CN"
)
// adoptProbeTimeout 是「要不要复用已在跑的实例」这一次探测的超时(也是远端端点探活
// 用的那一档):它先问「端口有没有在听」,再问 `/json/version` 答不答话,两问都给得短 ——
// 这是启动前的判定,不该拖住启动路径。
const adoptProbeTimeout = 2 * time.Second
// 启动失败的**分类标记**:几种情形必须在错误消息里一眼可分(design/28 §7 判据 4
// 「如实标注」/ §6 坑 5「失败要如实报」)——
//
// ① 浏览器进程自己退了(参数被拒 / 委托给已跑实例 / profile 锁被占);
// ② 我们自己的等待窗口用完(DevToolsActivePort 没出现);
// ③ **调用方的预算先没了**:浏览器没坏,只是没时间了;
// ④ 上一轮记的调试端口**在听、但不是可用的 CDP 端点**(握手失败)—— 它既不是「进程退出」
// 也不是「端口没出现」,必须单独说,因为它有**自己的**处置方式(见 endpointBrokenHint)。
//
// 哨兵仍然只有 ErrLaunch 一个(`errors.Is` 口径不变),这些标记是消息里可机读的那一段。
const (
// launchMarkerProcessExited 标记 ①。
launchMarkerProcessExited = "浏览器进程立刻退出"
// launchMarkerPortTimeout 标记 ②。
launchMarkerPortTimeout = "等 DevToolsActivePort 超时"
// launchMarkerBudget 标记 ③。
launchMarkerBudget = "调用方预算耗尽"
// launchMarkerEndpointBroken 标记 ④:端口在听却握不上手。它与「陈文件」(端口没人听)
// 是**两回事**:陈文件可以安全清掉后照常启动,这里只能如实报 —— 静默忽略它去另起一个
// 进程,换来的只是「委托给已跑实例后立刻退出」,把原因糊掉。
launchMarkerEndpointBroken = "调试端口在听但不是可用的 CDP 端点"
)
const (
// chromeOutputDrainWait 是「进程退出/被杀之后,把它已经写进管道的输出收干净」的
// 等待上限:诊断不该反过来拖住失败路径。
chromeOutputDrainWait = 500 * time.Millisecond
// launchDeadlineSlack 容忍「调用方的截止时间与我们这一次等待的截止时间几乎同时」
// 这种不可分情形:差值在它之内时按**我们自己等端口超时**报(此刻的事实正是这一次
// 等待用满了配置的 timeout,真机常态如此:外层 openSession 与本次等待同源同值)。
launchDeadlineSlack = 250 * time.Millisecond
)
// dialer 产出一个可用的 CDP HTTP 端点(`http://host:port`)与停止函数。它把
// 「启动本机浏览器」与「连接已跑端点」两条路统一成同一个会话装配入口。
type dialer interface {
// Dial 返回端点;stop 释放本次会话占用的资源(本机路径 = 杀掉进程,容器路径 = 空操作)。
Dial(ctx context.Context) (endpoint string, stop func(), err error)
// Describe 返回一句人类可读的来源描述(进日志与工具结果,不进提示词模板)。
Describe() string
// Local 报告这个端点是不是**我们自己的本机浏览器**:只有它为 true 时,会话收尾
// 才允许关闭浏览器。容器/手动实例(remoteCDP)是别人的,绝不代关。
Local() bool
}
// localChrome 是主路径:在**宿主真桌面**上起一个真 Chromium(默认有头)。
//
// 启动参数按 `DevDocs/reference/browser-stealth/10 §2.1` 的白/黑名单落地:
// - **要传**:`--user-data-dir`、`--window-size`、`--lang`、`--no-first-run`、
// `--no-default-browser-check`、`--disable-blink-features=AutomationControlled`、
// `--remote-debugging-port=0`;
// - **不传**:`--enable-automation`(会点亮「受自动化控制」,且 Puppeteer 默认就传它)、
// `--disable-extensions` / `--disable-default-apps` /
// `--disable-component-extensions-with-background-pages` / `--disable-component-update` /
// `--disable-popup-blocking`(Playwright/Puppeteer 的默认参数,真浏览器不会那样)、
// 以及一切 `--headless*`(有头就是不能有它)。
//
// 未传 `--remote-debugging-pipe`(reference/10 §2.4 的首选)是**平台限制**而不是取舍:
// 它要求把 CDP 通道给到子进程的 fd 3/4,而 Go 的 `exec.Cmd.ExtraFiles` **在 Windows
// 上不支持**。因此 Windows 主路径用次选 `--remote-debugging-port=0`(内核分配随机
// 回环端口,从 profile 目录的 DevToolsActivePort 读回):**仍是我们出站连它**,
// 不开任何对外监听端口(AGENTS §9)。
type localChrome struct {
exe string
profileDir string
headless bool
viewportW int
viewportH int
timeout time.Duration
log *slog.Logger
}
// Describe 实现 dialer。
func (l *localChrome) Describe() string {
mode := "headful"
if l.headless {
mode = "headless"
}
return fmt.Sprintf("本机浏览器 %s(%s)", l.exe, mode)
}
// Local 实现 dialer:本机浏览器归我们管,会话收尾可以(也应该)关掉它。
func (l *localChrome) Local() bool { return true }
// Dial 拉起浏览器并等调试端口就绪。任何一步失败都返回带分类原因的 ErrLaunch。
//
// 四条**先于启动**的判定(前三条都是为了让「上一次会话留下的浏览器」不至于把功能卡死):
//
// 1. **预算已耗尽就不起进程**:ctx 已经结束(这一轮的工具预算用完 / 会话被取消)时
// 立刻返回「预算耗尽」分类,**不 spawn** —— 起了也只是生一个会被马上杀掉的窗口,
// 并把「没时间了」伪装成「浏览器起不来」(真机踩过:窗口上跳出浏览器的抱怨,日志
// 里却像浏览器坏了)。判定做在**启动之前**,因为它要省掉的正是那次启动。
// 2. **能复用就复用**(`probeExistingEndpoint`):profile 目录的 DevToolsActivePort 还在、
// 端口**确实在听**、`/json/version` 也**真有合法响应** ⇒ 直接出站连它,**不再起第二个**。
// 这不是优化,是**正确性**:同一个 user-data-dir 上起第二个实例时 Chromium 会把手上的
// 命令**委托给已在跑的那个**然后自己退出(真机实测:新进程 exit 0、不写新的
// DevToolsActivePort,stderr 只有一句「正在现有的浏览器会话中打开。」),于是新进程永远
// 不写端口 ⇒ 不复用的话,这一档会一直报启动失败,直到人工杀掉那个进程。复用还正好贴合
// design/28 §2「profile 长期保留、任务间复用」。
// 3. **端口在听、却握不上手 ⇒ 如实报错**(`launchMarkerEndpointBroken`):绝不「忽略掉它、
// 自己起一个」—— 占着这个端口的多半就是同一个 profile 的浏览器,起进程只会让新进程委托
// 出去后立刻退出,把一句本就查不明白的失败再糊一层。
// 4. **陈的端口记录先清掉**(`clearStalePortFile`):只在真的要走启动路径时才清;不清的话,
// 上一轮的端口会被当成本次结果(理由见那个方法的注释)。
//
// 子进程的 stdout/stderr **接进缓冲**(`chromeOutput`):启动失败时把浏览器自己的话与本次
// argv 一起报进**日志**,并把浏览器原话附进**返回的错误**(`withChromeOutput`)—— 用户侧看到的
// 工具结果就是从这条错误来的。成功时只报字节数(Debug),不把 Chrome 的常规输出刷进日志。
//
// 停止函数只对**本次启动**的进程有效(复用来的实例不归我们杀):进程退出后 Chrome 的
// 渲染进程随之退出,因此进程级 Kill 足够。
func (l *localChrome) Dial(ctx context.Context) (string, func(), error) {
if strings.TrimSpace(l.exe) == "" {
return "", nil, fmt.Errorf("%w: 未指定可执行文件", ErrLaunch)
}
if err := ctx.Err(); err != nil {
return "", nil, budgetLaunchErr(ctx)
}
endpoint, err := l.probeExistingEndpoint(ctx)
if err != nil {
// 复用不了、又不许起第二个:如实报(错误里已带处置建议),一条 WARN 留现场。
l.log.Warn("browser launch blocked by unusable debug endpoint",
"profile", l.profileDir, "error", err)
return "", nil, err
}
if endpoint != "" {
l.log.Info("browser debug endpoint reused",
"endpoint", endpoint,
"profile", l.profileDir,
"detail", "复用已存在的调试端点(同 profile 的浏览器仍在跑,不再起第二个)")
return endpoint, func() {}, nil
}
if err := os.MkdirAll(l.profileDir, 0o700); err != nil {
return "", nil, fmt.Errorf("%w: 建 profile 目录 %s: %v", ErrLaunch, l.profileDir, err)
}
if err := l.clearStalePortFile(); err != nil {
return "", nil, err
}
args := l.launchArgs()
cmd := exec.Command(l.exe, args...)
out := &chromeOutput{}
cmd.Stdout = out
cmd.Stderr = out
if err := cmd.Start(); err != nil {
return "", nil, fmt.Errorf("%w: 启动 %s: %v", ErrLaunch, l.exe, err)
}
exited := make(chan struct{})
go func() {
defer close(exited)
// 用 cmd.Wait(而不是 cmd.Process.Wait):它会等两个输出复制协程收工,
// 于是 exited 关闭时缓冲里就是子进程说过的**全部**话(下面要在失败时上报它)。
_ = cmd.Wait()
}()
port, err := l.waitDevToolsPort(ctx, exited)
if err != nil {
stopProcess(cmd)
l.waitExited(exited)
l.log.Warn("browser launch failed",
"exe", l.exe,
"argv", strings.Join(args, " "),
"chrome_output", out.preview(),
"chrome_output_bytes", out.totalBytes(),
"error", err)
return "", nil, withChromeOutput(err, out.preview())
}
l.log.Debug("browser launched",
"exe", l.exe, "port", port, "profile", l.profileDir, "chrome_output_bytes", out.totalBytes())
return fmt.Sprintf("http://127.0.0.1:%d", port), func() { stopProcess(cmd) }, nil
}
// waitExited 等子进程真正收尾(管道里的在途字节落进缓冲)再上报:Kill 之后 Wait 还要
// 一点点时间,不等就会漏掉最后几行 —— 而最后几行往往正是原因。等不到就照现有内容报,
// 不把失败路径拖住。
func (l *localChrome) waitExited(exited <-chan struct{}) {
timer := time.NewTimer(chromeOutputDrainWait)
defer timer.Stop()
select {
case <-exited:
case <-timer.C:
}
}
// budgetLaunchErr 是 ③「调用方预算耗尽」的**唯一文案**。它必须读起来像「没时间了」,
// 而不是像「浏览器坏了」:取消与截止时间到分别点明(两者都是调用方自己的事)。
func budgetLaunchErr(ctx context.Context) error {
detail := "上下文截止时间到(这一轮的工具预算已耗尽)"
if errors.Is(ctx.Err(), context.Canceled) {
detail = "上下文被取消(会话/这一轮已结束)"
}
return fmt.Errorf("%w: %s:%s,浏览器未启动完成", ErrLaunch, launchMarkerBudget, detail)
}
// 启动前的三条判定与它们的处置文案在 adopt.go:复用活端点(probeExistingEndpoint)、
// 清掉陈的端口记录(clearStalePortFile)、端点不可用时的如实上报(withChromeOutput 与两条 hint)。
// launchArgs 组装启动参数(**顺序稳定、可断言**:单元测试钉住白/黑名单)。
func (l *localChrome) launchArgs() []string {
args := []string{
// profile 必传:内含 cookie / 登录态,且是「并发上限 1」的锁载体(design/28 §3)。
"--user-data-dir=" + l.profileDir,
// 内核分配随机回环端口;端口从 DevToolsActivePort 读回(见本类型文档)。
"--remote-debugging-port=0",
// 非浏览器 WS 客户端在部分 Chrome 版本上会被 DevTools 的 Origin 校验拒绝;
// reference/07 §4 的命令行里同样有它。它只放宽「谁能连这个回环调试端点」,
// 不开任何对外服务面。
"--remote-allow-origins=*",
"--no-first-run",
"--no-default-browser-check",
fmt.Sprintf("--window-size=%d,%d", l.viewportW, l.viewportH),
"--lang=" + defaultLang,
// 兜底防线:真正的关键是**不传** `--enable-automation`(见类型文档)。
"--disable-blink-features=AutomationControlled",
"about:blank",
}
if l.headless {
// 只在面板显式降级时出现;默认档(有头)**绝不传**任何 `--headless*`。
args = append([]string{"--headless=new"}, args...)
}
return args
}
// waitDevToolsPort 等 DevToolsActivePort 出现并解析出端口号。三条出口都要**说清原因**,
// 而且三者互不冒充(design/28 §7 判据 4「如实标注」):
//
// ① 浏览器进程自己退了(多半是 profile 锁被占 / 浏览器已在运行)——错误里带
// launchMarkerProcessExited **与可操作的处置建议**(profileBusyHint);子进程说的原话
// 由 Dial 追加进这条错误(withChromeOutput),用户侧因此看得见 Chromium 那句
// 「正在现有的浏览器会话中打开。」;
// ② 我们自己的等待窗口用完 —— 带 launchMarkerPortTimeout;
// ③ 调用方的预算先没了(ctx 结束)—— 带 launchMarkerBudget,**不**说浏览器坏。
//
// ② 与 ③ 的分辨靠两者的截止时间:ctx 的截止时间**明显早于**我们这一次等待的截止时间
// 时,结束它的是调用方;几乎同时到点时按 ② 报(那是这一次等待真的用满了配置的 timeout,
// 也是真机常态:外层 openSession 的预算与本次等待同源同值)。
//
// 还有一条**不走这里**的出口:启动前探到「端口在听却握不上手」时,Dial 会在起进程之前就
// 报 launchMarkerEndpointBroken(见 adopt.go)—— 那种情形根本不该起这个进程。
func (l *localChrome) waitDevToolsPort(ctx context.Context, exited <-chan struct{}) (int, error) {
path := filepath.Join(l.profileDir, devToolsPortFile)
launchDeadline := time.Now().Add(l.timeout)
deadline := time.NewTimer(l.timeout)
defer deadline.Stop()
ticker := time.NewTicker(portPollInterval)
defer ticker.Stop()
for {
if port, ok := readDevToolsPort(path); ok {
return port, nil
}
select {
case <-exited:
// 浏览器进程自己退了,端口不会再出现 —— **立刻**报,不把超时白等完。
// 最常见的原因是同一 profile 已被另一个实例占用:Chromium 把命令**委托给已在跑的
// 那个**之后自己退出(真机实测 stderr 原话「正在现有的浏览器会话中打开。」)、新
// 进程不写 DevToolsActivePort。这里给可操作建议,但不碰别人的进程。
return 0, fmt.Errorf("%w: %s。%s", ErrLaunch, launchMarkerProcessExited, profileBusyHint(l.profileDir))
case <-ctx.Done():
if dl, ok := ctx.Deadline(); !ok || dl.Add(launchDeadlineSlack).Before(launchDeadline) {
return 0, budgetLaunchErr(ctx)
}
return 0, l.portTimeoutErr()
case <-deadline.C:
return 0, l.portTimeoutErr()
case <-ticker.C:
}
}
}
// portTimeoutErr 是 ②「我们自己的等待窗口用完」的唯一文案(两条出口共用,口径只有一处)。
func (l *localChrome) portTimeoutErr() error {
return fmt.Errorf("%w: %s(%s)", ErrLaunch, launchMarkerPortTimeout, l.timeout)
}
// readDevToolsPort 读端口文件的首行;文件还没出现 / 还没写完 / 内容不合法都返回 false
// (交给调用方继续轮询,而不是把「正在写」误判成「配错」)。
func readDevToolsPort(path string) (int, bool) {
raw, err := os.ReadFile(path)
if err != nil {
return 0, false
}
line, _, _ := strings.Cut(strings.TrimSpace(string(raw)), "\n")
port, err := strconv.Atoi(strings.TrimSpace(line))
if err != nil || port <= 0 || port > 65535 {
return 0, false
}
return port, true
}
// remoteCDP 是次选路径:连接一个**已在跑**的 CDP 端点(容器 + Xvfb + 真 Chromium,
// 或主人手动起的实例)。它是纯出站客户端,不启动任何进程、不监听任何端口。
type remoteCDP struct{ endpoint string }
// Describe 实现 dialer。
func (r *remoteCDP) Describe() string { return "已跑的 CDP 端点 " + r.endpoint }
// Local 实现 dialer:容器/手动实例**不是我们的**,会话收尾绝不代它关闭。
func (r *remoteCDP) Local() bool { return false }
// Dial 先探活再交出端点;连不上就如实报,不偷偷回退本机(design/28 §6 坑 5)。
func (r *remoteCDP) Dial(ctx context.Context) (string, func(), error) {
endpoint := strings.TrimRight(strings.TrimSpace(r.endpoint), "/")
if endpoint == "" {
return "", nil, fmt.Errorf("%w: CDP 端点为空白", ErrUnavailable)
}
var version struct {
Browser string `json:"Browser"`
}
if err := httpGetJSON(ctx, endpoint+"/json/version", &version); err != nil {
return "", nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
return endpoint, func() {}, nil
}
// stopProcess 结束本进程拉起的浏览器(保留为具名函数:Kill 的语义在这一处写清楚,
// 调用点不必解释;Wait 由 Dial 里的等待协程负责,这里只 Kill,避免双重 Wait)。
// Windows 上渲染进程随浏览器进程一起退,因此进程级 Kill 足够。
func stopProcess(cmd *exec.Cmd) {
if cmd == nil || cmd.Process == nil {
return
}
_ = cmd.Process.Kill()
}
// newDialer 按「配置显式端点 → 配置显式可执行文件 → 本机探测结果」组装 dialer。
// 可用性判定由 browsercfg.Resolve 做(优先级单一出处),这里只把结论映射成具体 dialer。
func newDialer(avail browsercfg.Availability, cfg browsercfg.Config, log *slog.Logger) dialer {
if e := strings.TrimSpace(avail.Endpoint); e != "" {
return &remoteCDP{endpoint: e}
}
return &localChrome{
exe: strings.TrimSpace(avail.Executable),
profileDir: cfg.ProfileDir,
headless: cfg.Headless,
viewportW: cfg.ViewportWidth,
viewportH: cfg.ViewportHeight,
timeout: cfg.Timeout(),
log: log,
}
}