Files
Cyrene_v2/internal/capability/browser/adopt_test.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

278 lines
12 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
// 本文件盯住「起进程之前的三条判定」(实现见 adopt.go):
//
// ① 端点活着 ⇒ **复用**,不起第二个进程;
// ② 端口记录在、端口没人听 ⇒ 清掉陈记录后照常启动;
// ③ 端口在听却握不上手 ⇒ 如实报错(带处置建议),不起进程;
// ④ 被占用且拿不到端点 ⇒ 情况②之外的「真被占用」:如实报 + 带上 Chrome 自己的原话。
//
// 判据①必须有**能证伪**的观察手段:怎么证明「没起第二个进程」?答案是**诱饵** ——
// 这些用例给 localChrome 配的 exe 是「一被拉起就大声喊、然后退出」的假浏览器
// (fakebrowser_test.go 的重跑自己范式)。真走了启动路径,喊声会进日志、Dial 也会失败;
// 用例因此是绿的 ⇒ 启动路径没被走到。(变异自证:把复用那一支去掉 ⇒ 判据①立刻变红。)
//
// 现场依据(主人实测复现):同一个 profile 上再起一次 Chrome 时,新进程 exit 0、不写新的
// DevToolsActivePort,stderr 逐字只有一句「正在现有的浏览器会话中打开。」—— Chromium 把
// 命令委托给了已在跑的实例。不做复用,这一档就一直报启动失败,直到那个进程退出。
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
)
// fakeBrowserLure 是诱饵:假浏览器一旦被拉起就喊这句,用例据此判定「起了第二个进程」。
const fakeBrowserLure = "假浏览器被拉起了 —— 这一档不该有第二个进程"
// chromeSaysSessionOpen 是真 Chrome 把命令委托给现存实例时写出的那句 stderr 原话。
const chromeSaysSessionOpen = "正在现有的浏览器会话中打开。"
// deadPort 返回一个「刚刚还活着、现在没人听」的回环端口号:用来造**陈**的端口记录。
func deadPort(t *testing.T) int {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("取空闲端口: %v", err)
}
port := ln.Addr().(*net.TCPAddr).Port
if err := ln.Close(); err != nil {
t.Fatalf("释放端口: %v", err)
}
return port
}
// portOfEndpoint 取 `http://127.0.0.1:PORT` 里的端口号(写端口记录用)。
func portOfEndpoint(t *testing.T, endpoint string) int {
t.Helper()
port, err := strconv.Atoi(strings.TrimPrefix(endpoint, "http://127.0.0.1:"))
if err != nil {
t.Fatalf("从 %q 取端口: %v", endpoint, err)
}
return port
}
// writeDevToolsPortFile 写一条端口记录(真浏览器的格式:首行端口、次行 browser_guid)。
func writeDevToolsPortFile(t *testing.T, dir string, port int) {
t.Helper()
path := filepath.Join(dir, devToolsPortFile)
if err := os.WriteFile(path, []byte(strconv.Itoa(port)+"\nGUID\n"), 0o600); err != nil {
t.Fatalf("写端口记录: %v", err)
}
}
// portFileExists 报告端口记录还在不在(不清陈记录 / 保留现场两类断言都用它)。
func portFileExists(dir string) bool {
_, err := os.Stat(filepath.Join(dir, devToolsPortFile))
return err == nil
}
// TestDial_ReusesLiveEndpointAndDoesNotSpawnSecondProcess 是判据①:profile 里记的调试端点
// 在听、`/json/version` 也答得上来 ⇒ **连它**,不出第二个进程。
//
// 复用的必要性不只是省一次启动:同 profile 上起第二个实例时 Chromium 会把命令委托出去后自己
// 退出,新进程永远不写端口记录 ⇒ 不复用就等于这一档不可用(详见 adopt.go 的文件头)。
func TestDial_ReusesLiveEndpointAndDoesNotSpawnSecondProcess(t *testing.T) {
t.Parallel()
cdp := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) { return map[string]any{}, nil })
log, buf := newCapturedLog(slog.LevelDebug)
l := newFakeChrome(t, fakeBrowserScript{Stderr: []string{fakeBrowserLure}, ExitCode: 9}, 5*time.Second, log)
writeDevToolsPortFile(t, l.profileDir, portOfEndpoint(t, cdp.base))
endpoint, stop, err := l.Dial(context.Background())
if err != nil {
t.Fatalf("活着且握手成功的端点应当被复用,实得错误: %v", err)
}
defer stop()
if endpoint != cdp.base {
t.Errorf("endpoint = %q, want %q(必须是复用来的那个端点)", endpoint, cdp.base)
}
logs := buf.String()
t.Logf("实测日志:\n%s", logs)
if containsInLog(logs, fakeBrowserLure) {
t.Errorf("起了第二个进程(诱饵被拉起)—— 复用这一支没生效:\n%s", logs)
}
if !strings.Contains(logs, "browser debug endpoint reused") {
t.Errorf("复用必须留一条 INFO(说明复用了已存在的调试端点):\n%s", logs)
}
if !strings.Contains(logs, "source="+logSource) {
t.Errorf("这条 INFO 必须带来源(AGENTS §10 禁无来源裸日志):\n%s", logs)
}
stop() // 复用来的实例不归我们杀:停止函数必须是空操作(能调用即通过)
}
// TestDial_ClearsStalePortFileThenLaunches 是判据②:端口记录在、但端口已经没人听 ⇒ 那是
// **陈记录**,清掉后照常启动。两个子用例各钉一半:
//
// (1) 清掉之后,本次就绪判定认**新进程写的**那个端口;
// (2) 清掉是**真清**:新进程不写端口时,Dial 必须报「等端口超时」—— 而不是把陈端口当成
// 本次结果稀里糊涂地成功(那正是「不清陈记录」会造成的伪装)。
func TestDial_ClearsStalePortFileThenLaunches(t *testing.T) {
t.Parallel()
t.Run("以新进程写的端口为准", func(t *testing.T) {
t.Parallel()
log, _ := newCapturedLog(slog.LevelDebug)
l := newFakeChrome(t, fakeBrowserScript{WritePort: 55543, SleepMS: 3000}, 5*time.Second, log)
writeDevToolsPortFile(t, l.profileDir, deadPort(t))
endpoint, stop, err := l.Dial(context.Background())
if err != nil {
t.Fatalf("陈记录不该挡住正常启动: %v", err)
}
defer stop()
if want := "http://127.0.0.1:55543"; endpoint != want {
t.Errorf("endpoint = %q, want %q(陈端口被当成本次结果了?)", endpoint, want)
}
})
t.Run("陈端口不会被当成本次结果", func(t *testing.T) {
t.Parallel()
log, buf := newCapturedLog(slog.LevelDebug)
l := newFakeChrome(t, fakeBrowserScript{
Stderr: []string{"起来了,但从不写端口记录"}, SleepMS: 3000,
}, 300*time.Millisecond, log)
stale := deadPort(t)
writeDevToolsPortFile(t, l.profileDir, stale)
endpoint, _, err := l.Dial(context.Background())
if err == nil {
t.Fatalf("陈记录被清掉、新进程又不写端口 ⇒ 必须报错;实得 endpoint=%q", endpoint)
}
t.Logf("实测 error=%v\n实测日志=%s", err, buf.String())
if !errors.Is(err, ErrLaunch) {
t.Fatalf("仍须包住 ErrLaunch: %v", err)
}
if !strings.Contains(err.Error(), launchMarkerPortTimeout) {
t.Errorf("应当读成「等 DevToolsActivePort 超时」: %v", err)
}
if endpoint == fmt.Sprintf("http://127.0.0.1:%d", stale) {
t.Errorf("把陈端口当成本次结果了(endpoint=%q)", endpoint)
}
if portFileExists(l.profileDir) {
t.Error("陈的端口记录应当已被清掉(否则下次还会读到它)")
}
})
}
// TestDial_ReportsLiveButUnusableEndpoint 是判据③:端口在听、但 `/json/version` 握不上手 ⇒
// **如实报错**,而且**不许静默忽略后另起一个进程**(起出来的那个只会委托出去再退出,把原因
// 糊成一句「启动失败」)。三种「在听但不是 CDP」的形态各钉一格。
func TestDial_ReportsLiveButUnusableEndpoint(t *testing.T) {
t.Parallel()
cases := []struct {
name string
handler http.HandlerFunc
}{
{"响应不是 JSON", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("<html>我不是 CDP</html>"))
}},
{"JSON 但没有 Browser 字段", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"hello":"world"}`))
}},
{"HTTP 503", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(tc.handler)
defer srv.Close()
log, buf := newCapturedLog(slog.LevelDebug)
l := newFakeChrome(t, fakeBrowserScript{Stderr: []string{fakeBrowserLure}, ExitCode: 9}, 3*time.Second, log)
writeDevToolsPortFile(t, l.profileDir, portOfEndpoint(t, srv.URL))
_, stop, err := l.Dial(context.Background())
if stop != nil {
t.Error("失败路径不该交出停止函数")
}
if err == nil {
t.Fatal("端口在听却握不上手时必须如实报错,不许静默忽略")
}
t.Logf("实测 error=%v\n实测日志=%s", err, buf.String())
if !errors.Is(err, ErrLaunch) {
t.Fatalf("仍须包住 ErrLaunch: %v", err)
}
if !strings.Contains(err.Error(), launchMarkerEndpointBroken) {
t.Errorf("错误里应点明这一类(端口在听但不是 CDP 端点): %v", err)
}
for _, want := range []string{"处置", devToolsPortFile, l.profileDir} {
if !containsInLog(err.Error(), want) {
t.Errorf("可操作的处置建议里缺少 %q: %v", want, err)
}
}
if containsInLog(buf.String(), fakeBrowserLure) {
t.Errorf("不许静默忽略后另起一个进程(诱饵被拉起):\n%s", buf.String())
}
if !portFileExists(l.profileDir) {
t.Error("握不上手时不该动端口记录:它是现场证据,也是复用判定的唯一依据")
}
})
}
}
// TestDial_ReportsOccupiedProfileWithChromeOutput 是判据④,也是本片要修的那个「必然等不到
// 端口」的场景在**我们没法复用**时的出口:新进程 exit 0(真机就是这样)、不写端口记录,
// Chrome 自己只在 stderr 说一句「正在现有的浏览器会话中打开。」。
//
// 要求:立刻报(不把预算干等完)、说清是哪一类(launchMarkerProcessExited)、给可操作建议、
// 并且把 **Chrome 自己的原话** 带进**返回给调用方**的错误里(用户侧的工具结果来自这条错误,
// 只留在日志里等于用户看不到)。
func TestDial_ReportsOccupiedProfileWithChromeOutput(t *testing.T) {
t.Parallel()
log, buf := newCapturedLog(slog.LevelDebug)
l := newFakeChrome(t, fakeBrowserScript{Stderr: []string{chromeSaysSessionOpen}, ExitCode: 0}, 5*time.Second, log)
start := time.Now()
_, stop, err := l.Dial(context.Background())
elapsed := time.Since(start)
if stop != nil {
t.Error("失败路径不该交出停止函数")
}
if err == nil {
t.Fatal("进程 exit 0 却不写端口记录 ⇒ 必须报错(真机就是这个形态)")
}
logs := buf.String()
t.Logf("实测 error=%v\n实测耗时=%v\n实测日志=%s", err, elapsed, logs)
if elapsed > 2*time.Second {
t.Errorf("应当立刻报,不把配置的 timeout 白等完:等了 %v", elapsed)
}
if !errors.Is(err, ErrLaunch) {
t.Fatalf("仍须包住 ErrLaunch: %v", err)
}
if !strings.Contains(err.Error(), launchMarkerProcessExited) {
t.Errorf("错误里应点明「进程立刻退出」这一类: %v", err)
}
if !strings.Contains(err.Error(), chromeSaysSessionOpen) {
t.Errorf("错误里必须带上 Chrome 自己的原话(用户侧只看得到这条错误): %v", err)
}
if !strings.Contains(err.Error(), "浏览器输出:") {
t.Errorf("原话应当以「浏览器输出:」引出(对应 withChromeOutput): %v", err)
}
for _, want := range []string{"处置", "关掉那个浏览器窗口", "绝不杀别人的浏览器", l.profileDir} {
if !containsInLog(err.Error(), want) {
t.Errorf("可操作的处置建议里缺少 %q: %v", want, err)
}
}
for _, want := range []string{
"--user-data-dir=" + l.profileDir, // 现场可复现:本次 argv
"chrome_output_bytes=", // 浏览器说了多少
chromeSaysSessionOpen, // 日志里也要有原话
} {
if !containsInLog(logs, want) {
t.Errorf("失败日志里缺少 %q:\n%s", want, logs)
}
}
}