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`(错误文案已写明处置);**未加"报错后自动清理重试"**。
This commit is contained in:
1 parent
93c5a4c46f
commit
7f63c5bec4
4 files changed
+470
-43
No files matched your search
@@ -0,0 +1,277 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user