feat(browser): 方案 A 落地 —— 显式端口 + 去掉 stealth flag(主人 2026-10-07 拍板)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

依据 `DevDocs/reference/chromium-cdp/`(9 份实测+官方源码):旧姿势传 `--remote-debugging-port=0`
会被 Chromium 当成 ChromeDriver 式自动化启动 ⇒ 主动打开 `AutomationControlled`
⇒ `navigator.webdriver=true`;那个 bad-flag 是承重覆盖,但代价是**每次有头弹横幅**。
改用显式端口后 `webdriver` **本来就是 false** ⇒ flag 变纯冗余 ⇒ 去掉它,**两者兼得**。

- `launch.go`:`--remote-debugging-port=<我们选的端口>` 替掉 `=0`;**删掉**
  `--disable-blink-features=AutomationControlled`(注释写明理由)。
- 新增 `portrecord.go`:端口来源与落点的**唯一出处** —— 取空闲端口(`net.Listen`
  取号后立刻关闭,**本进程不常驻监听**)、写/读/清**我们自己的记录**
  `<profile>/cyrene-debug-port`(选它与 Chrome 的 `DevToolsActivePort` 同目录:
  天然按 profile 隔离、profile 换掉记录自动消失、内容只有端口号不含凭据);
  **兼容老记录**(换二进制时上一版起的浏览器可能还在跑 ⇒ 认不出会把"自己的浏览器"
  误报成"profile 被别人占了");就绪判据换成「我们选的那个端口答得上 `/json/version`」;
  TOCTOU 处理 = 端口被占**换号重试一次**,重试仍失败则如实报错且不留记录。
- 三条认领路径(复用/清陈记录/如实报错保留现场)与三条失败分类(进程立刻退出 /
  等端口超时 / 预算耗尽)**全部保留**;`ErrLaunch` + `errors.Is` 口径未变;
  **绝不杀进程**红线未变。

真机自证(子代理跑,默认 Skip 的 `realchrome_test.go`,`CYRENE_BROWSER_CDP_E2E=1` 开闸):
`webdriver=false` · 命令行**无** flag/`--enable-automation`/`--headless` · UA 无
`HeadlessChrome`、`plugins=5`、`languages=zh-CN,zh`、`window.chrome` 在 ·
**UI Automation 控件树 17 个命名节点里没有「信息栏容器/信息栏」⇒ 无横幅** ·
第二次 Dial **复用同端口、浏览器主进程仍是同一个 PID** · 收尾按 profile 路径筛残留 = 0。

验收(主会话复跑 + 亲自变异):整树 `go build ./...` + 两包 vet 绿;browser 包测试绿;
我用 -overlay 把该 flag 加回参数表 ⇒ `TestLaunchArgs_HeadfulCarriesNoAutomationTraces`
变红(逐字报"不许传的 flag")。

⚠️ 未验(如实):无头档的横幅(无窗口判不了)、`chrome-headless-shell`、风控侧是否把
"端口写法"本身当信号、生产 profile 整链路(未碰)。
This commit is contained in:
AskaEth committed 2026-10-07 11:18:25 +08:00
1 parent 74e171bc04
commit 6fe68ecfac
11 files changed
+1316 -320

No files matched your search

+6 -20
View File
@@ -15,11 +15,9 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
@@ -124,11 +122,15 @@ func TestAppClose_RealChromeStopsOnShutdown(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
// ① 起一个真浏览器(指纹自检走 CDP,无需外网),并从 profile 读回它的调试端点。
// ① 起一个真浏览器(指纹自检走 CDP,无需外网),并取回它的调试端点。
if _, err := tool.Execute(ctx, map[string]any{"action": "info"}); err != nil {
t.Fatalf("action=info(起真浏览器): %v", err)
}
endpoint := devToolsEndpoint(t, browsercfg.DefaultProfileDir(cfg.DataDir))
endpoint, ok := browsercap.DebugEndpoint(browsercfg.DefaultProfileDir(cfg.DataDir))
if !ok {
t.Fatalf("会话已建立,但 profile %s 下没有端口记录(显式端口由我们自己记,见 portrecord.go)",
browsercfg.DefaultProfileDir(cfg.DataDir))
}
if !endpointAlive(endpoint) {
t.Fatalf("会话已建立但调试端点 %s 不应答:本用例的前提不成立", endpoint)
}
@@ -151,22 +153,6 @@ func TestAppClose_RealChromeStopsOnShutdown(t *testing.T) {
endpoint, tailOfLog(out.String()))
}
// devToolsEndpoint 从 profile 目录的 DevToolsActivePort 读出这次会话的调试端点
// (格式与 launch.go 的 waitDevToolsPort 同源:首行是端口号)。
func devToolsEndpoint(t *testing.T, profileDir string) string {
t.Helper()
raw, err := os.ReadFile(filepath.Join(profileDir, "DevToolsActivePort"))
if err != nil {
t.Fatalf("读 DevToolsActivePort: %v(profile=%s)", err, profileDir)
}
line, _, _ := strings.Cut(strings.TrimSpace(string(raw)), "\n")
port, err := strconv.Atoi(strings.TrimSpace(line))
if err != nil || port <= 0 {
t.Fatalf("DevToolsActivePort 内容不合法: %q", raw)
}
return fmt.Sprintf("http://127.0.0.1:%d", port)
}
// endpointAlive 报告调试端点是否在应答(一次 /json/version)。
func endpointAlive(endpoint string) bool {
client := &http.Client{Timeout: 2 * time.Second}
+72 -57
View File
@@ -1,10 +1,10 @@
package browser
// 本文件是启动路径的**前置判定**:起进程之前先看「这个 profile 上是不是已经有一个浏览器」。
// 现场(主人实测复现):同一个 profile 上再起一次 Chrome 时,新进程 **exit 0**、不写新的
// DevToolsActivePort,stderr 只有一句「正在现有的浏览器会话中打开。」—— Chromium 把命令
// **委托给已在跑的那个实例**后自己退出。于是只按「等端口文件出现」实现的话,这一档永远等不到
// 端口:上一次会话只要没退干净,整个浏览器档就不可用。
// 现场(主人实测复现):同一个 profile 上再起一次 Chrome 时,新进程 **exit 0**、不写新的端口
// 记录,stderr 只有一句「正在现有的浏览器会话中打开。」—— Chromium 把命令**委托给已在跑的
// 那个实例**后自己退出。于是只按「等新进程诞生」实现的话,这一档永远等不到可用端点:上一次
// 会话只要没退干净,整个浏览器档就不可用。
//
// 三条判定与三条不同的出路(顺序即 Dial 里的调用顺序):
//
@@ -12,6 +12,10 @@ package browser
// ② 端口记录在、但端口没人听 ⇒ 那是**陈记录**,清掉后照常启动;
// ③ 端口在听却握不上手 ⇒ **如实报错**(带处置建议),不起进程 —— 绝不静默忽略。
//
// 判定用的端口来自 debugPortCandidates(portrecord.go):我们自己记的 `cyrene-debug-port`
// 优先,再兼容一眼老版二进制留下的 `DevToolsActivePort`(换二进制时,上一版起的浏览器可能
// 还在跑 —— 不认它就会把「profile 上已经有我们的浏览器」误报成「profile 被别人占了」)。
//
// 红线:这一路只做**出站**连接,不杀任何进程、不发任何信号去抢 profile、不开任何监听端口
// (AGENTS §9);主人的浏览器可能正开着,我们只能连它或绕开它。
@@ -20,59 +24,90 @@ import (
"errors"
"fmt"
"net"
"os"
"path/filepath"
"strconv"
)
// probeExistingEndpoint 探「profile 目录里记的调试端点还能不能用」——启动路径的第一步。
// probeExistingEndpoint 探「这个 profile 上记的调试端点还能不能用」——启动路径的第一步。
// 三条结论各自对应一条**不同于启动**的出路:
//
// ("", nil) 没有可复用的端点:端口记录缺席,或记的端口**已经没人听**(陈文件)
// ⇒ 调用方清掉陈记录后正常启动(见 clearStalePortFile);
// ("", nil) 没有可复用的端点:一个记录都没有,或记录的端口**已经没人听**(陈记录)
// ⇒ 调用方清掉陈记录后正常启动(见 clearStalePortRecords);
// (url, nil) 端口在听、且 `/json/version` 有**合法响应** ⇒ 复用它,不再起第二个进程;
// ("", err) 端口在听、但握手失败 ⇒ **如实报错**(launchMarkerEndpointBroken),不起进程。
//
// 为什么先独立做一次 TCP 探活(而不是把 HTTP 探活的失败一分为二):两条出路必须分开 ——
// 「没人听」是**陈文件**(可以安全清掉后照常启动),「在听却不答 CDP」是**占用/异常**(只能
// 「没人听」是**陈记录**(可以安全清掉后照常启动),「在听却不答 CDP」是**占用/异常**(只能
// 如实报)。混成一句「探测失败 ⇒ 直接启动」,就会在真被占用时把一个必然「委托出去就退出」的
// 空壳进程当成本次启动,把原因退化成一句查不明白的「启动失败」。
//
// 探测超时给得很短(adoptProbeTimeout):这是「要不要复用」的判断,不该拖住启动路径。
// 探测超时给得很短(endpointProbeTimeout):这是「要不要复用」的判断,不该拖住启动路径。
func (l *localChrome) probeExistingEndpoint(ctx context.Context) (string, error) {
port, ok := readDevToolsPort(filepath.Join(l.profileDir, devToolsPortFile))
if !ok {
return "", nil
}
if !portListening(ctx, port) {
return "", nil
}
endpoint := fmt.Sprintf("http://127.0.0.1:%d", port)
probeCtx, cancel := context.WithTimeout(ctx, adoptProbeTimeout)
defer cancel()
var version struct {
Browser string `json:"Browser"`
}
handshakeErr := httpGetJSON(probeCtx, endpoint+"/json/version", &version)
if handshakeErr == nil && version.Browser != "" {
return endpoint, nil
}
if handshakeErr == nil {
handshakeErr = errors.New("响应是合法 JSON,但没有 Browser 字段(不像 CDP 的 /json/version)")
var brokenPort int
var brokenCause error
for _, port := range debugPortCandidates(l.profileDir) {
state, cause := probeDebugEndpoint(ctx, port)
switch state {
case endpointReady:
return endpointURL(port), nil
case endpointForeign:
if brokenCause == nil {
brokenPort, brokenCause = port, cause
}
case endpointAbsent:
}
}
if ctx.Err() != nil {
// 探测期间调用方的预算先没了:这是「没时间了」,**不**说端点坏了。
return "", budgetLaunchErr(ctx)
}
return "", fmt.Errorf("%w: %s:%s 在听,但 /json/version 握手失败:%v。%s",
ErrLaunch, launchMarkerEndpointBroken, endpoint, handshakeErr, endpointBrokenHint(l.profileDir))
if brokenCause != nil {
return "", fmt.Errorf("%w: %s:%s 在听,但 /json/version 握手失败:%v。%s",
ErrLaunch, launchMarkerEndpointBroken, endpointURL(brokenPort), brokenCause, endpointBrokenHint(l.profileDir))
}
return "", nil
}
// portListening 报告 127.0.0.1:port 上有没有人在听(这一次探测的连接立刻关掉)。它只做
// debugEndpointState 是一次探活的三态结论。
type debugEndpointState int
const (
// endpointAbsent:这个端口上没人听(连接被拒)—— 记它下来的那条记录是**陈记录**。
endpointAbsent debugEndpointState = iota
// endpointReady:在听,且 `/json/version` 答得上一个带 Browser 字段的合法响应 ⇒ 就是 CDP。
endpointReady
// endpointForeign:在听,但答的不是可用的 CDP(拒答 / 非 JSON / 缺 Browser 字段)——
// 只能**如实报**,不能当成本次会话的端点。
endpointForeign
)
// probeDebugEndpoint 探一个端口的三态,并带回「不合法」那一态的原因(用于如实上报)。
// 它只做出站连接(AGENTS §9),不启动进程、不发信号。
//
// 三个判定为什么不能压成一个 bool:「没人听」与「在听但不是 CDP」的处置**完全不同**(前者
// 清记录后照常启动,后者只能如实报),压扁就必然丢掉其中一条。
func probeDebugEndpoint(ctx context.Context, port int) (debugEndpointState, error) {
if !portListening(ctx, port) {
return endpointAbsent, nil
}
probeCtx, cancel := context.WithTimeout(ctx, endpointProbeTimeout)
defer cancel()
var version struct {
Browser string `json:"Browser"`
}
err := httpGetJSON(probeCtx, endpointURL(port)+"/json/version", &version)
if err == nil && version.Browser != "" {
return endpointReady, nil
}
if err == nil {
err = errors.New("响应是合法 JSON,但没有 Browser 字段(不像 CDP 的 /json/version)")
}
return endpointForeign, err
}
// portListening 报告回环上有没有人在听这个端口(这一次探测的连接立刻关掉)。它只做
// **出站**连接,不开任何监听端口(AGENTS §9)。
func portListening(ctx context.Context, port int) bool {
d := net.Dialer{Timeout: adoptProbeTimeout}
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
d := net.Dialer{Timeout: endpointProbeTimeout}
conn, err := d.DialContext(ctx, "tcp", loopbackAddr(port))
if err != nil {
return false
}
@@ -80,26 +115,6 @@ func portListening(ctx context.Context, port int) bool {
return true
}
// clearStalePortFile 清掉**上一轮**留下的端口记录 —— 只有确认「端口已经没人听」才会走到这里。
//
// 为什么必须清:`waitDevToolsPort` 只认这一个文件,上一轮留下的端口号会被当成**本次**的就绪
// 结果 —— 于是「这一次没启动成功」会伪装成「端点已就绪」,调用方拿到一个死端点。
//
// 为什么可以清:这个文件是浏览器启动时写下的**一条记录**,不是锁。真正在跑、真正拥有这个
// profile 的实例一定有一个**活着的**端口,那种情况上一步已经把它复用走了。因此删掉一条指向死
// 端口的记录,不可能从任何活着的实例手里拿走什么 —— 它影响的只有我们自己的就绪判定。
//
// 文件本来就不在(ErrNotExist)不算失败;别的错误(被占用 / 权限)**如实报**并且**不继续
// 启动** —— 留着一个陈记录继续启动,只会把陈端口读成本次结果,那正是本方法要防的事。
func (l *localChrome) clearStalePortFile() error {
path := filepath.Join(l.profileDir, devToolsPortFile)
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("%w: 清掉上一轮的端口记录 %s 失败: %v(留着它会把陈端口当成本次就绪)",
ErrLaunch, path, err)
}
return nil
}
// withChromeOutput 把浏览器自己说的话附进**返回给调用方**的错误里。日志里本来就有它,但用户
// 侧看到的工具结果(`browserError`)来自这条错误 —— 不上报原话,用户就只看到我们这一侧的猜测
// (真机踩过:窗口上跳出浏览器的抱怨,日志里像浏览器坏了,工具结果里什么都没有)。
@@ -116,7 +131,7 @@ func withChromeOutput(err error, preview string) error {
func endpointBrokenHint(profileDir string) string {
return fmt.Sprintf("处置:先确认是谁占着这个端口(本代理不会、也不该杀任何进程);"+
"确认它不是本 profile 的浏览器调试端点后,删掉 %s 再重试,或把 profileDir 指到另一个目录。",
filepath.Join(profileDir, devToolsPortFile))
portRecordPath(profileDir))
}
// profileBusyHint 是「新进程立刻退出」的处置建议:Chromium 把命令委托给了同一个 profile 上已在
+101 -31
View File
@@ -7,19 +7,20 @@ package browser
// ③ 端口在听却握不上手 ⇒ 如实报错(带处置建议),不起进程;
// ④ 被占用且拿不到端点 ⇒ 情况②之外的「真被占用」:如实报 + 带上 Chrome 自己的原话。
//
// 判据①必须有**能证伪**的观察手段:怎么证明「没起第二个进程」?答案是**诱饵** ——
// 这些用例给 localChrome 配的 exe 是「一被拉起就大声喊、然后退出」的假浏览器
// (fakebrowser_test.go 的重跑自己范式)。真走了启动路径,喊声会进日志、Dial 也会失败;
// 用例因此是绿的 ⇒ 启动路径没被走到。(变异自证:把复用那一支去掉 ⇒ 判据①立刻变红。)
// 判据①必须有**能证伪**的观察手段:怎么证明「没起第二个进程」?答案是**两重观察** ——
// ① **诱饵**:这些用例给 localChrome 配的 exe 是「一被拉起就大声喊、然后退出」的假浏览器
// (fakebrowser_test.go 的重跑自己范式),真走了启动路径,喊声会进日志、Dial 也会失败;
// ② **启动计数**:假浏览器每次被拉起都会往 profile 目录追一行(`fakeLaunches`),可以直接
// 数出「起了几次」。用例因此是绿的 ⇒ 启动路径没被走到。(变异自证:把复用那一支去掉 ⇒
// 判据①立刻变红。)
//
// 现场依据(主人实测复现):同一个 profile 上再起一次 Chrome 时,新进程 exit 0、不写新的
// DevToolsActivePort,stderr 逐字只有一句「正在现有的浏览器会话中打开。」—— Chromium 把
// 命令委托给了已在跑的实例。不做复用,这一档就一直报启动失败,直到那个进程退出。
// 现场依据(主人实测复现):同一个 profile 上再起一次 Chrome 时,新进程 exit 0、不写新的端口
// 记录,stderr 逐字只有一句「正在现有的浏览器会话中打开。」—— Chromium 把命令委托给了已在跑
// 的实例。不做复用,这一档就一直报启动失败,直到那个进程退出。
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
@@ -62,19 +63,29 @@ func portOfEndpoint(t *testing.T, endpoint string) int {
return port
}
// writeDevToolsPortFile 写一条端口记录(真浏览器的格式:首行端口、次行 browser_guid)。
func writeDevToolsPortFile(t *testing.T, dir string, port int) {
// portFileExists 报告**我们自己的**端口记录还在不在(不清陈记录 / 保留现场两类断言都用它)。
func portFileExists(dir string) bool {
_, err := os.Stat(portRecordPath(dir))
return err == nil
}
// writePortRecordFile 写一条我们自己的端口记录(实现见 portrecord.go;格式:一行端口号)。
func writePortRecordFile(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 {
if err := writePortRecord(dir, port); err != nil {
t.Fatalf("写端口记录: %v", err)
}
}
// portFileExists 报告端口记录还在不在(不清陈记录 / 保留现场两类断言都用它)。
func portFileExists(dir string) bool {
_, err := os.Stat(filepath.Join(dir, devToolsPortFile))
return err == nil
// writeLegacyDevToolsPortFile 写一条**老格式**的端口记录(Chromium 在 `port==0` 时写的
// `DevToolsActivePort`:首行端口、次行 browser_guid)—— 用它钉住「换二进制后上一版起的浏览器
// 仍能被复用」这条兼容路径。
func writeLegacyDevToolsPortFile(t *testing.T, dir string, port int) {
t.Helper()
path := filepath.Join(dir, legacyDevToolsPortFile)
if err := os.WriteFile(path, []byte(strconv.Itoa(port)+"\nGUID\n"), 0o600); err != nil {
t.Fatalf("写老格式端口记录: %v", err)
}
}
// TestDial_ReusesLiveEndpointAndDoesNotSpawnSecondProcess 是判据①:profile 里记的调试端点
@@ -87,7 +98,7 @@ func TestDial_ReusesLiveEndpointAndDoesNotSpawnSecondProcess(t *testing.T) {
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))
writePortRecordFile(t, l.profileDir, portOfEndpoint(t, cdp.base))
endpoint, stop, err := l.Dial(context.Background())
if err != nil {
@@ -108,31 +119,90 @@ func TestDial_ReusesLiveEndpointAndDoesNotSpawnSecondProcess(t *testing.T) {
if !strings.Contains(logs, "source="+logSource) {
t.Errorf("这条 INFO 必须带来源(AGENTS §10 禁无来源裸日志):\n%s", logs)
}
if n := fakeLaunches(l.profileDir); n != 0 {
t.Errorf("复用这条路上不该起任何进程(假浏览器被拉起 %d 次)", n)
}
stop() // 复用来的实例不归我们杀:停止函数必须是空操作(能调用即通过)
}
// TestDial_ReusesLegacyDevToolsActivePort 钉住一条**兼容**路径:老版二进制(那时我们传的是
// `--remote-debugging-port=0`)在 profile 里留下的 `DevToolsActivePort` 若还活着,也必须被认领。
//
// 为什么值得单独钉:换二进制时上一版起的浏览器可能还在跑(本项目的后端常驻、浏览器按需回收)。
// 若不认那个文件,新二进制会把「profile 上已经有我们的浏览器」误报成「profile 被别人占了」,
// 于是功能一直不可用,直到那个浏览器自己空闲回收。
func TestDial_ReusesLegacyDevToolsActivePort(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)
writeLegacyDevToolsPortFile(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)
}
if n := fakeLaunches(l.profileDir); n != 0 {
t.Errorf("复用这条路上不该起任何进程(假浏览器被拉起 %d 次):\n%s", n, buf.String())
}
}
// TestDial_ClearsStaleLegacyRecord 补上「陈记录清理」的另一半:**老格式**记录指向的端口已经
// 没人听时,它也必须被清掉(否则下一次启动还会去探一个死端点),且不挡住正常启动。
func TestDial_ClearsStaleLegacyRecord(t *testing.T) {
t.Parallel()
log, _ := newCapturedLog(slog.LevelDebug)
l := newFakeChrome(t, fakeBrowserScript{Serve: serveCDP}, 5*time.Second, log)
writeLegacyDevToolsPortFile(t, l.profileDir, deadPort(t))
endpoint, stop, err := l.Dial(context.Background())
if err != nil {
t.Fatalf("陈的老格式记录不该挡住正常启动: %v", err)
}
defer stop()
port, ok := readPortRecord(l.profileDir)
if !ok || endpoint != endpointURL(port) {
t.Fatalf("成功之后应当记下本次用的端口:endpoint=%q port=%d ok=%v", endpoint, port, ok)
}
if _, err := os.Stat(filepath.Join(l.profileDir, legacyDevToolsPortFile)); err == nil {
t.Error("陈的老格式记录应当已被清掉(留着它下次还会去探那个死端口)")
}
}
// TestDial_ClearsStalePortFileThenLaunches 是判据②:端口记录在、但端口已经没人听 ⇒ 那是
// **陈记录**,清掉后照常启动。两个子用例各钉一半:
//
// (1) 清掉之后,本次就绪判定认**新进程写的**那个端口;
// (2) 清掉是**真清**:新进程不写端口时,Dial 必须报「等端口超时」—— 而不是把陈端口当成
// (1) 清掉之后,本次就绪判定认**新进程实际服务的**那个端口;
// (2) 清掉是**真清**:新进程从不就绪时,Dial 必须报「等端口就绪超时」—— 而不是把陈端口当成
// 本次结果稀里糊涂地成功(那正是「不清陈记录」会造成的伪装)。
func TestDial_ClearsStalePortFileThenLaunches(t *testing.T) {
t.Parallel()
t.Run("以新进程写的端口为准", func(t *testing.T) {
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))
l := newFakeChrome(t, fakeBrowserScript{Serve: serveCDP}, 5*time.Second, log)
stale := deadPort(t)
writePortRecordFile(t, l.profileDir, stale)
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)
port, ok := readPortRecord(l.profileDir)
if !ok {
t.Fatal("成功之后必须记下本次用的端口")
}
if endpoint != endpointURL(port) {
t.Errorf("endpoint = %q, want %q", endpoint, endpointURL(port))
}
if port == stale {
t.Errorf("陈端口(%d)被当成本次结果了", stale)
}
})
@@ -140,23 +210,23 @@ func TestDial_ClearsStalePortFileThenLaunches(t *testing.T) {
t.Parallel()
log, buf := newCapturedLog(slog.LevelDebug)
l := newFakeChrome(t, fakeBrowserScript{
Stderr: []string{"起来了,但从不写端口记录"}, SleepMS: 3000,
Stderr: []string{"起来了,但从不答 CDP"}, SleepMS: 3000,
}, 300*time.Millisecond, log)
stale := deadPort(t)
writeDevToolsPortFile(t, l.profileDir, stale)
writePortRecordFile(t, l.profileDir, stale)
endpoint, _, err := l.Dial(context.Background())
if err == nil {
t.Fatalf("陈记录被清掉、新进程又不写端口 ⇒ 必须报错;实得 endpoint=%q", endpoint)
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)
t.Errorf("应当读成「等调试端口就绪超时」: %v", err)
}
if endpoint == fmt.Sprintf("http://127.0.0.1:%d", stale) {
if endpoint == endpointURL(stale) {
t.Errorf("把陈端口当成本次结果了(endpoint=%q)", endpoint)
}
if portFileExists(l.profileDir) {
@@ -191,7 +261,7 @@ func TestDial_ReportsLiveButUnusableEndpoint(t *testing.T) {
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))
writePortRecordFile(t, l.profileDir, portOfEndpoint(t, srv.URL))
_, stop, err := l.Dial(context.Background())
if stop != nil {
@@ -207,7 +277,7 @@ func TestDial_ReportsLiveButUnusableEndpoint(t *testing.T) {
if !strings.Contains(err.Error(), launchMarkerEndpointBroken) {
t.Errorf("错误里应点明这一类(端口在听但不是 CDP 端点): %v", err)
}
for _, want := range []string{"处置", devToolsPortFile, l.profileDir} {
for _, want := range []string{"处置", portRecordFile, l.profileDir} {
if !containsInLog(err.Error(), want) {
t.Errorf("可操作的处置建议里缺少 %q: %v", want, err)
}
+1 -1
View File
@@ -162,7 +162,7 @@ func runCompareOnce(t *testing.T, rawURL string, g compareGroup, repeat int, run
rec := compareRecord{
TS: time.Now().Format(time.RFC3339), Group: g.Name, URL: rawURL,
Mode: modeName(g.Headless), LaunchFlags: drv.launchArgs(),
Mode: modeName(g.Headless), LaunchFlags: drv.launchArgs(portOfEndpoint(t, s.endpoint)),
CDPRuntimeEnable: false, // 本驱动从源头**不发** Runtime.enable(reference/06 的 P0)
Driver: "cyrene-go-cdp", ChromeVersion: chromeVersionOf(t, s.endpoint),
UserDataDir: profile, Headless: g.Headless,
+6 -3
View File
@@ -20,9 +20,12 @@
//
// 红线(design/28 §3/§4、AGENTS §9/§10):
//
// - **不新开对外监听端口**:调试端口由 Chromium 自己绑在 `127.0.0.1` 的**随机**
// 端口(`--remote-debugging-port=0`,端口从 profile 目录的 `DevToolsActivePort`
// 读回),我们是**出站连接方**;容器路径同理(我们连它)。
// - **不新开对外监听端口**:调试端口是**我们选号、Chromium 自己绑在 `127.0.0.1`** 的
// 显式端口(`--remote-debugging-port=<port>`,端口号由 `listen(:0)` 借内核挑一个空闲号
// 后立刻放掉,并记在 `<profile>/cyrene-debug-port` 里供下次复用,见 portrecord.go)——
// 本包**不持有任何监听端口**,始终是**出站连接方**;容器路径同理(我们连它)。
// 为什么不传 `--remote-debugging-port=0`:Chromium 会把「端口=0」当作 ChromeDriver 式
// 自动化启动、主动点亮 `navigator.webdriver`(reference/chromium-cdp/00 §2)。
// - **profile 含凭据**(cookie / 登录态):目录 0700、不得位于 git 工作树内
// (`browsercfg.EnsureProfileDir`),内容绝不进上下文与长期记忆。
// - **截图可能含凭据/二维码**:文件 0600、默认不进长期记忆(design/28 §4)。
+129 -12
View File
@@ -2,7 +2,9 @@ package browser
// 本文件是**假浏览器**:启动路径的测试必须真起一个子进程,才能证明
// - 子进程的 stdout/stderr 被接住了(失败时上报、成功时不刷屏);
// - 三种失败情形(进程立刻退出 / 端口没出来 / 预算没了)分辨得开。
// - 三种失败情形(进程立刻退出 / 端点没就绪 / 预算没了)分辨得开;
// - 「就绪」判的是**我们自己选的那个端口答不答 CDP**(显式端口下 Chromium 不再写
// DevToolsActivePort,见 portrecord.go),因此假浏览器要真的在 argv 给的端口上监听。
//
// 手法是 Go 里既有的「重跑自己」范式(stdlib `os/exec` 的 testhelper 同款,本仓
// `internal/capability/plugin/manager_test.go` 的 MCP stdio 假 server 也用它):
@@ -18,6 +20,8 @@ import (
"encoding/json"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
@@ -34,9 +38,29 @@ const (
// fakeBrowserUserDataDirFlag 是脚本的定位方式:profile 目录就是 argv 里
// `--user-data-dir=` 的值(真浏览器的同一个参数)。
fakeBrowserUserDataDirFlag = "--user-data-dir="
// fakeBrowserDebugPortFlag 是假浏览器的调试端口来源(与真浏览器的参数同名);
// 显式端口是我们自己选的,假浏览器照它监听才算「就绪」。
fakeBrowserDebugPortFlag = "--remote-debugging-port="
// fakeBrowserLaunchLogFile 记录**每次**假浏览器被拉起(一行一次)。它是「有没有起
// 第二个进程」这一判据的**直接观察**:复用与「预算耗尽不 spawn」两类用例都数它。
fakeBrowserLaunchLogFile = "fake-browser-launches"
// fakeBrowserNoScriptExit 是「argv 像浏览器、但没有脚本」时的退出码:宁可表现得像
// 一个立刻退出的浏览器,也不递归跑测试套件(那是测试基建事故,不是用例失败)。
// 一个立刻退出的浏览器,也不递归跑整个测试套件(那是测试基建事故,不是用例失败)。
fakeBrowserNoScriptExit = 7
// fakeBrowserServeIdle 是「要在端口上服务」时的默认存活时长:子进程必须活到用例
// 判定完(真浏览器是被 Kill 才退的),但不能无限活着 —— 无限活着会漏成孤儿进程。
fakeBrowserServeIdle = 30 * time.Second
)
// 假浏览器在调试端口上的三种做派(见 fakeBrowserScript.Serve)。
const (
// serveCDP:监听并答合法的 `/json/version` —— 这就是「真浏览器已就绪」。
serveCDP = "cdp"
// serveForeign:监听但答一个**不是 CDP** 的响应 —— 模拟「我们选的端口被别人占了」
// (TOCTOU 抢占),用于验证换端口重试。
serveForeign = "foreign"
// serveForeignThenCDP:第一次抢先占住端口、第二次才当 CDP —— 验证「重试之后真的成功」。
serveForeignThenCDP = "foreign-then-cdp"
)
// fakeBrowserScript 是一次「假启动」的行为脚本(字段零值 = 不做那件事)。
@@ -46,17 +70,18 @@ type fakeBrowserScript struct {
Stderr []string `json:"stderr"`
// ExitCode 是退出码。
ExitCode int `json:"exitCode"`
// WritePort > 0 ⇒ 往 profile 目录写 DevToolsActivePort(模拟真浏览器就绪);
// Dial 只读这个文件,不会去连端口,所以端口号不必真的有人在听。
WritePort int `json:"writePort"`
// Serve 决定它在 `--remote-debugging-port=` 那个端口上做什么(见上面三个常量);
// 空串 = **不监听**(模拟「起了但从不就绪」)。
Serve string `json:"serve"`
// SleepMS > 0 ⇒ 之后继续活着(模拟浏览器常驻):由 Dial 的停止函数或超时路径 Kill。
// Serve 非空时即使它为 0 也会活 fakeBrowserServeIdle(否则子进程可能在判定前就退了)。
SleepMS int `json:"sleepMs"`
}
// TestMain 让测试二进制可以**兼任**假浏览器。
func TestMain(m *testing.M) {
if dir, ok := fakeBrowserProfileDir(os.Args[1:]); ok {
os.Exit(runFakeBrowser(dir))
os.Exit(runFakeBrowser(dir, os.Args[1:]))
}
os.Exit(m.Run())
}
@@ -71,8 +96,24 @@ func fakeBrowserProfileDir(args []string) (string, bool) {
return "", false
}
// fakeBrowserDebugPort 从 argv 里取出调试端口(找不到 / 不合法 ⇒ 0,表示「不监听」)。
func fakeBrowserDebugPort(args []string) int {
for _, a := range args {
raw, ok := strings.CutPrefix(a, fakeBrowserDebugPortFlag)
if !ok {
continue
}
port, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || port <= 0 || port > 65535 {
return 0
}
return port
}
return 0
}
// runFakeBrowser 按脚本演一次假浏览器,返回退出码。
func runFakeBrowser(profileDir string) int {
func runFakeBrowser(profileDir string, args []string) int {
raw, err := os.ReadFile(filepath.Join(profileDir, fakeBrowserScriptFile))
if err != nil {
fmt.Fprintf(os.Stderr, "fake browser: %v\n", err)
@@ -83,25 +124,101 @@ func runFakeBrowser(profileDir string) int {
fmt.Fprintf(os.Stderr, "fake browser: %v\n", err)
return fakeBrowserNoScriptExit
}
launchNo := recordFakeLaunch(profileDir)
for _, line := range script.Stdout {
fmt.Fprintln(os.Stdout, line)
}
for _, line := range script.Stderr {
fmt.Fprintln(os.Stderr, line)
}
if script.WritePort > 0 {
content := strconv.Itoa(script.WritePort) + "\nfake-browser-guid\n"
if err := os.WriteFile(filepath.Join(profileDir, devToolsPortFile), []byte(content), 0o600); err != nil {
if script.Serve != "" {
if err := startFakeCDPServer(fakeBrowserDebugPort(args), serveKind(script.Serve, launchNo)); err != nil {
fmt.Fprintf(os.Stderr, "fake browser: %v\n", err)
return fakeBrowserNoScriptExit
}
}
if script.SleepMS > 0 {
time.Sleep(fakeBrowserServeIdle)
} else if script.SleepMS > 0 {
time.Sleep(time.Duration(script.SleepMS) * time.Millisecond)
}
return script.ExitCode
}
// serveKind 把脚本里的做派与**这是第几次被拉起**合起来算出本次的实际做派:
// `foreign-then-cdp` 只在第一次抢端口,第二次起老老实实当 CDP —— 于是「换端口重试之后
// 成功」这条路径可被断言,而不是只断言「重试过」。
func serveKind(mode string, launchNo int) string {
if mode == serveForeignThenCDP && launchNo > 1 {
return serveCDP
}
if mode == serveForeignThenCDP {
return serveForeign
}
return mode
}
// recordFakeLaunch 追加一行「我起来了」,返回这是第几次(1 起)。计数落在 profile 目录里,
// 因此每个用例各数各的。
func recordFakeLaunch(profileDir string) int {
path := filepath.Join(profileDir, fakeBrowserLaunchLogFile)
raw, _ := os.ReadFile(path)
n := fakeLaunchLines(string(raw))
n++
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return n
}
defer func() { _ = f.Close() }()
_, _ = fmt.Fprintf(f, "launch %d\n", n)
return n
}
// fakeLaunchLines 数启动记录的行数(一行一次;空行不算)。
func fakeLaunchLines(raw string) int {
n := 0
for _, line := range strings.Split(raw, "\n") {
if strings.TrimSpace(line) != "" {
n++
}
}
return n
}
// startFakeCDPServer 在 port 上起一个假 CDP HTTP 端点(在后台协程里服务到进程被杀)。
// port ≤ 0(argv 里没有可用端口)不服务:就绪判定因此不会成功,如实走失败路径。
func startFakeCDPServer(port int, mode string) error {
if port <= 0 {
return nil
}
mux := http.NewServeMux()
if mode == serveForeign {
// 「端口在听但不是 CDP」:任何路径都答一段不是 CDP 的正文。
mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("<html>我不是 CDP,只是占着这个端口</html>"))
})
} else {
mux.HandleFunc("/json/version", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"Browser":"Chrome/154.0.0.0","webSocketDebuggerUrl":"ws://127.0.0.1:` +
strconv.Itoa(port) + `/devtools/browser/fake"}`))
})
}
ln, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
if err != nil {
return fmt.Errorf("监听调试端口 %d: %w", port, err)
}
srv := &http.Server{Handler: mux}
go func() { _ = srv.Serve(ln) }()
return nil
}
// fakeLaunches 数这个 profile 上假浏览器被拉起过几次(0 = 没起过任何进程)。
func fakeLaunches(profileDir string) int {
raw, err := os.ReadFile(filepath.Join(profileDir, fakeBrowserLaunchLogFile))
if err != nil {
return 0
}
return fakeLaunchLines(string(raw))
}
// newFakeChrome 造一个「可执行文件 = 测试二进制自己」的 localChrome,并把脚本写进
// profile 目录(`Dial` 会把它当 `--user-data-dir` 传下来)。
func newFakeChrome(t *testing.T, script fakeBrowserScript, timeout time.Duration, log *slog.Logger) *localChrome {
+146 -77
View File
@@ -7,8 +7,6 @@ import (
"log/slog"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
@@ -17,12 +15,7 @@ import (
// 启动参数与就绪等待的具名常量(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,
// portPollInterval 是「等调试端点就绪」的轮询间隔:冷启动通常 0.3~1.5s,
// 100ms 的粒度既不迟钝也不白烧 CPU。
portPollInterval = 100 * time.Millisecond
@@ -32,30 +25,32 @@ const (
defaultLang = "zh-CN"
)
// adoptProbeTimeout 是「要不要复用已在跑的实例」这一次探测的超时(也是远端端点探活
// 用的那一档):它先问「端口有没有在听」,再问 `/json/version` 答不答话,两问都给得短 ——
// 这是启动前的判定,不该拖住启动路径。
const adoptProbeTimeout = 2 * time.Second
// endpointProbeTimeout 是一次端点探活的超时:「要不要复用已在跑的实例」与「本次启动就绪了
// 没有」两处共用同一档。它先问「端口有没有在听」,再问 `/json/version` 答不答话,两问都给得
// 短 —— 前者是启动前的判定,后者是就绪轮询里的一次尝试,都不该拖住启动路径。
const endpointProbeTimeout = 2 * time.Second
// 启动失败的**分类标记**:几种情形必须在错误消息里一眼可分(design/28 §7 判据 4
// 「如实标注」/ §6 坑 5「失败要如实报」)——
//
// ① 浏览器进程自己退了(参数被拒 / 委托给已跑实例 / profile 锁被占);
// ② 我们自己的等待窗口用完(DevToolsActivePort 没出现);
// ② 我们自己的等待窗口用完(**我们选的那个**调试端口一直没答上 CDP);
// ③ **调用方的预算先没了**:浏览器没坏,只是没时间了;
// ④ 上一轮记的调试端口**在听、但不是可用的 CDP 端点**(握手失败)—— 它既不是「进程退出」
// 也不是「端口没出现」,必须单独说,因为它有**自己的**处置方式(见 endpointBrokenHint)。
// ④ 端口在听、但不是可用的 CDP 端点(握手失败)—— 它既不是「进程退出」也不是「端口没出现」,
// 必须单独说,因为它有**自己的**处置方式(见 endpointBrokenHint)。这一条有三个入口:
// 启动**前**探到旧记录指向坏端点、启动**中**发现选的端口被别人占了、以及会话就绪后的
// 复用判定(都在本包内共用同一个标记)。
//
// 哨兵仍然只有 ErrLaunch 一个(`errors.Is` 口径不变),这些标记是消息里可机读的那一段。
const (
// launchMarkerProcessExited 标记 ①。
launchMarkerProcessExited = "浏览器进程立刻退出"
// launchMarkerPortTimeout 标记 ②。
launchMarkerPortTimeout = "等 DevToolsActivePort 超时"
launchMarkerPortTimeout = "等调试端口就绪超时"
// launchMarkerBudget 标记 ③。
launchMarkerBudget = "调用方预算耗尽"
// launchMarkerEndpointBroken 标记 ④:端口在听却握不上手。它与「陈文件」(端口没人听)
// 是**两回事**:陈文件可以安全清掉后照常启动,这里只能如实报 —— 静默忽略它去另起一个
// launchMarkerEndpointBroken 标记 ④:端口在听却握不上手。它与「陈记录」(端口没人听)
// 是**两回事**:陈记录可以安全清掉后照常启动,这里只能如实报 —— 静默忽略它去另起一个
// 进程,换来的只是「委托给已跑实例后立刻退出」,把原因糊掉。
launchMarkerEndpointBroken = "调试端口在听但不是可用的 CDP 端点"
)
@@ -84,11 +79,14 @@ type dialer interface {
// localChrome 是主路径:在**宿主真桌面**上起一个真 Chromium(默认有头)。
//
// 启动参数按 `DevDocs/reference/browser-stealth/10 §2.1` 的白/黑名单落地:
// 启动参数按 `DevDocs/reference/browser-stealth/10 §2.1` 的白/黑名单落地,并经
// `DevDocs/reference/chromium-cdp/` 的官方源码 + 本机实测修订了两处(见 portrecord.go 的文件头):
// - **要传**:`--user-data-dir`、`--window-size`、`--lang`、`--no-first-run`、
// `--no-default-browser-check`、`--disable-blink-features=AutomationControlled`、
// `--remote-debugging-port=0`;
// - **不传**:`--enable-automation`(会点亮「受自动化控制」,且 Puppeteer 默认就传它)、
// `--no-default-browser-check`、`--remote-debugging-port=<我们选的显式端口>`;
// - **不传**:`--disable-blink-features=AutomationControlled`(显式端口下它对
// `navigator.webdriver` 已是空操作,却仍是整条命令行里唯一命中官方 `kBadFlags` 的参数 ⇒
// 每次有头启动弹一条「不受支持的命令行标记」信息栏)、
// `--enable-automation`(会点亮「受自动化控制」,且 Puppeteer 默认就传它)、
// `--disable-extensions` / `--disable-default-apps` /
// `--disable-component-extensions-with-background-pages` / `--disable-component-update` /
// `--disable-popup-blocking`(Playwright/Puppeteer 的默认参数,真浏览器不会那样)、
@@ -96,9 +94,10 @@ type dialer interface {
//
// 未传 `--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)。
// 上不支持**;何况官方源码里 pipe 与 `--headless` 一样会打开 `AutomationControlled`
// (reference/chromium-cdp/02 §4.1),换它并不能解决问题。因此 Windows 主路径用
// `--remote-debugging-port=<显式端口>`:**仍是我们出站连它**,不开任何对外监听端口
// (AGENTS §9;端口由内核分配号、Chromium 自己 bind 在 127.0.0.1,见 portrecord.go)。
type localChrome struct {
exe string
profileDir string
@@ -121,7 +120,7 @@ func (l *localChrome) Describe() string {
// Local 实现 dialer:本机浏览器归我们管,会话收尾可以(也应该)关掉它。
func (l *localChrome) Local() bool { return true }
// Dial 拉起浏览器并等调试端口就绪。任何一步失败都返回带分类原因的 ErrLaunch。
// Dial 拉起浏览器并等调试端点就绪。任何一步失败都返回带分类原因的 ErrLaunch。
//
// 四条**先于启动**的判定(前三条都是为了让「上一次会话留下的浏览器」不至于把功能卡死):
//
@@ -129,22 +128,24 @@ func (l *localChrome) Local() bool { return true }
// 立刻返回「预算耗尽」分类,**不 spawn** —— 起了也只是生一个会被马上杀掉的窗口,
// 并把「没时间了」伪装成「浏览器起不来」(真机踩过:窗口上跳出浏览器的抱怨,日志
// 里却像浏览器坏了)。判定做在**启动之前**,因为它要省掉的正是那次启动。
// 2. **能复用就复用**(`probeExistingEndpoint`):profile 目录的 DevToolsActivePort 还在、
// 端口**确实在听**、`/json/version` 也**真有合法响应** ⇒ 直接出站连它,**不再起第二个**。
// 2. **能复用就复用**(`probeExistingEndpoint`):这个 profile 上记的调试端口还在
// (`cyrene-debug-port`,或老二进制留下的 `DevToolsActivePort`)、端口**确实在听**、
// `/json/version` 也**真有合法响应** ⇒ 直接出站连它,**不再起第二个**。
// 这不是优化,是**正确性**:同一个 user-data-dir 上起第二个实例时 Chromium 会把手上的
// 命令**委托给已在跑的那个**然后自己退出(真机实测:新进程 exit 0、不写新的
// DevToolsActivePort,stderr 只有一句「正在现有的浏览器会话中打开。」),于是新进程永远
// 不写端口 ⇒ 不复用的话,这一档会一直报启动失败,直到人工杀掉那个进程。复用还正好贴合
// design/28 §2「profile 长期保留、任务间复用」。
// 命令**委托给已在跑的那个**然后自己退出(真机实测:新进程 exit 0、不写新的端口记录,
// stderr 只有一句「正在现有的浏览器会话中打开。」),于是新进程永远不就绪 ⇒ 不复用的话,
// 这一档会一直报启动失败,直到人工杀掉那个进程。复用还正好贴合 design/28 §2「profile
// 长期保留、任务间复用」。
// 3. **端口在听、却握不上手 ⇒ 如实报错**(`launchMarkerEndpointBroken`):绝不「忽略掉它、
// 自己起一个」—— 占着这个端口的多半就是同一个 profile 的浏览器,起进程只会让新进程委托
// 出去后立刻退出,把一句本就查不明白的失败再糊一层。
// 4. **陈的端口记录先清掉**(`clearStalePortFile`):只在真的要走启动路径时才清;不清的话,
// 上一轮的端口会被当成本次结果(理由见那个方法的注释)。
// 4. **陈的记录先清掉**(`clearStalePortRecords`):只在真的要走启动路径时才清;不清的话,
// 上一轮的端口会被当成本次可复用的端点(理由见那个方法的注释)。
//
// 子进程的 stdout/stderr **接进缓冲**(`chromeOutput`):启动失败时把浏览器自己的话与本次
// argv 一起报进**日志**,并把浏览器原话附进**返回的错误**(`withChromeOutput`)—— 用户侧看到的
// 工具结果就是从这条错误来的。成功时只报字节数(Debug),不把 Chrome 的常规输出刷进日志。
// 起进程与「等它就绪」由 launchWithRetry(含一次换端口重试)与 launchOnce 承担;子进程的
// stdout/stderr **接进缓冲**(`chromeOutput`):启动失败时把浏览器自己的话与本次 argv 一起
// 报进**日志**,并把浏览器原话附进**返回的错误**(`withChromeOutput`)—— 用户侧看到的工具
// 结果就是从这条错误来的。成功时只报字节数(Debug),不把 Chrome 的常规输出刷进日志。
//
// 停止函数只对**本次启动**的进程有效(复用来的实例不归我们杀):进程退出后 Chrome 的
// 渲染进程随之退出,因此进程级 Kill 足够。
@@ -172,11 +173,57 @@ func (l *localChrome) Dial(ctx context.Context) (string, func(), error) {
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 {
if err := l.clearStalePortRecords(); err != nil {
return "", nil, err
}
return l.launchWithRetry(ctx)
}
args := l.launchArgs()
// launchWithRetry 起浏览器,并把**本次实际用的那个端口**记进 profile 目录的端口记录
// (`cyrene-debug-port`,见 portrecord.go:显式端口下 Chromium 不写 DevToolsActivePort,
// 端口只能我们自己记 —— 记不下就等于下一次启动无法复用,所以写失败按硬失败报,并把刚起的
// 进程收掉,不留孤儿)。
//
// 只在一种情形下**换端口**重试:证据表明我们选的端口被别人占了(`portTakenError`,即就绪
// 等待期间那个端口在听、却答不上 CDP)。那是「listen(:0) 取号」与「Chromium bind」之间的
// TOCTOU,换一个号就好,且**没有**碰任何不属于我们的进程。其余失败一律如实上报 —— 无差别
// 重试只会把原因糊掉,还把预算烧在重复等待上。
func (l *localChrome) launchWithRetry(ctx context.Context) (string, func(), error) {
var lastErr error
for attempt := 0; attempt < launchPortAttempts; attempt++ {
if err := ctx.Err(); err != nil {
// 重试之前预算先没了:这一轮的事实是「没时间了」,如实报(不是端口的问题)。
return "", nil, budgetLaunchErr(ctx)
}
port, err := pickDebugPort(ctx)
if err != nil {
return "", nil, fmt.Errorf("%w: %v", ErrLaunch, err)
}
endpoint, stop, err := l.launchOnce(ctx, port)
if err == nil {
if werr := writePortRecord(l.profileDir, port); werr != nil {
stop()
return "", nil, fmt.Errorf("%w: %v", ErrLaunch, werr)
}
return endpoint, stop, nil
}
lastErr = err
var taken *portTakenError
if !errors.As(err, &taken) {
return "", nil, err
}
l.log.Warn("browser launch retrying with another debug port",
"port", port, "profile", l.profileDir, "error", err)
}
// 换端口也还是被别人抢:**如实报同一类**(端口在听但不是 CDP),不冒充别的失败。
// 这时 lastErr 里已带标记与处置建议,直接交回它,不另编一句更好听的。
return "", nil, lastErr
}
// launchOnce 起一次浏览器(用给定的显式端口)并等它就绪。失败时**自己收掉刚起的进程**,
// 并把浏览器原话与本次 argv 报进日志、把原话附进返回的错误。
func (l *localChrome) launchOnce(ctx context.Context, port int) (string, func(), error) {
args := l.launchArgs(port)
cmd := exec.Command(l.exe, args...)
out := &chromeOutput{}
cmd.Stdout = out
@@ -191,7 +238,7 @@ func (l *localChrome) Dial(ctx context.Context) (string, func(), error) {
// 于是 exited 关闭时缓冲里就是子进程说过的**全部**话(下面要在失败时上报它)。
_ = cmd.Wait()
}()
port, err := l.waitDevToolsPort(ctx, exited)
endpoint, err := l.waitDebugReady(ctx, port, exited)
if err != nil {
stopProcess(cmd)
l.waitExited(exited)
@@ -205,7 +252,7 @@ func (l *localChrome) Dial(ctx context.Context) (string, func(), error) {
}
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
return endpoint, func() { stopProcess(cmd) }, nil
}
// waitExited 等子进程真正收尾(管道里的在途字节落进缓冲)再上报:Kill 之后 Wait 还要
@@ -231,15 +278,22 @@ func budgetLaunchErr(ctx context.Context) error {
}
// 启动前的三条判定与它们的处置文案在 adopt.go:复用活端点(probeExistingEndpoint)、
// 清掉陈的端口记录(clearStalePortFile)、端点不可用时的如实上报(withChromeOutput 与两条 hint)。
// 清掉陈的端口记录(clearStalePortRecords)、端点不可用时的如实上报(withChromeOutput 与两条 hint)。
// launchArgs 组装启动参数(**顺序稳定、可断言**:单元测试钉住白/黑名单)。
func (l *localChrome) launchArgs() []string {
// launchArgs 组装启动参数(**顺序稳定、可断言**:单元测试钉住白/黑名单)。port 是我们
// **已经选好**的显式调试端口(取号与记录见 portrecord.go)。
func (l *localChrome) launchArgs(port int) []string {
args := []string{
// profile 必传:内含 cookie / 登录态,且是「并发上限 1」的锁载体(design/28 §3)。
"--user-data-dir=" + l.profileDir,
// 内核分配随机回环端口;端口从 DevToolsActivePort 读回(见本类型文档)。
"--remote-debugging-port=0",
// **显式**端口(不是 `=0`):Chromium 把 `port==0` 当作 ChromeDriver 式自动化启动,
// 会主动打开 Blink 的 AutomationControlled ⇒ `navigator.webdriver === true`(官方
// runtime_features.cc:389-404;实测 reference/chromium-cdp/07 的 g1 vs g7);显式端口
// 不被这样处理,因此这一条同时替掉了原来的
// `--disable-blink-features=AutomationControlled`(那个 flag 在显式端口下是空操作,
// 却会命中官方 kBadFlags、每次有头启动弹一条「不受支持的命令行标记」信息栏)。
// 代价:Chromium 只在 port==0 时才写 <profile>/DevToolsActivePort,端口改由我们自己记。
fmt.Sprintf("--remote-debugging-port=%d", port),
// 非浏览器 WS 客户端在部分 Chrome 版本上会被 DevTools 的 Origin 校验拒绝;
// reference/07 §4 的命令行里同样有它。它只放宽「谁能连这个回环调试端点」,
// 不开任何对外服务面。
@@ -248,8 +302,6 @@ func (l *localChrome) launchArgs() []string {
"--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 {
@@ -259,14 +311,18 @@ func (l *localChrome) launchArgs() []string {
return args
}
// waitDevToolsPort 等 DevToolsActivePort 出现并解析出端口号。三条出口都要**说清原因**,
// 而且三者互不冒充(design/28 §7 判据 4「如实标注」):
// waitDebugReady 等**我们自己选的那个端口**真的成为可用的 CDP 端点(就绪判据从「端口文件
// 出现」换成了「端点答得上 `/json/version`」—— 显式端口下 Chromium 不再写端口文件,而且
// 这样判别的是**事实**而不是一条可能陈旧的记录)。三条出口都要**说清原因**,而且三者互不
// 冒充(design/28 §7 判据 4「如实标注」):
//
// ① 浏览器进程自己退了(多半是 profile 锁被占 / 浏览器已在运行)——错误里带
// launchMarkerProcessExited **与可操作的处置建议**(profileBusyHint);子进程说的原话
// 由 Dial 追加进这条错误(withChromeOutput),用户侧因此看得见 Chromium 那句
// 由 launchOnce 追加进这条错误(withChromeOutput),用户侧因此看得见 Chromium 那句
// 「正在现有的浏览器会话中打开。」;
// ② 我们自己的等待窗口用完 —— 带 launchMarkerPortTimeout;
// ② 我们自己的等待窗口用完 —— 带 launchMarkerPortTimeout;若这期间那个端口**在听却答不上
// CDP**,说明它被别的进程抢走了(listen(:0) 取号与 Chromium bind 之间的 TOCTOU),
// 改报 launchMarkerEndpointBroken 并交给 launchWithRetry 换端口重试(portTakenError);
// ③ 调用方的预算先没了(ctx 结束)—— 带 launchMarkerBudget,**不**说浏览器坏。
//
// ② 与 ③ 的分辨靠两者的截止时间:ctx 的截止时间**明显早于**我们这一次等待的截止时间
@@ -275,54 +331,67 @@ func (l *localChrome) launchArgs() []string {
//
// 还有一条**不走这里**的出口:启动前探到「端口在听却握不上手」时,Dial 会在起进程之前就
// 报 launchMarkerEndpointBroken(见 adopt.go)—— 那种情形根本不该起这个进程。
func (l *localChrome) waitDevToolsPort(ctx context.Context, exited <-chan struct{}) (int, error) {
path := filepath.Join(l.profileDir, devToolsPortFile)
func (l *localChrome) waitDebugReady(ctx context.Context, port int, exited <-chan struct{}) (string, error) {
launchDeadline := time.Now().Add(l.timeout)
deadline := time.NewTimer(l.timeout)
defer deadline.Stop()
ticker := time.NewTicker(portPollInterval)
defer ticker.Stop()
foreign := false
for {
if port, ok := readDevToolsPort(path); ok {
return port, nil
switch state, _ := probeDebugEndpoint(ctx, port); state {
case endpointReady:
return endpointURL(port), nil
case endpointForeign:
foreign = true
case endpointAbsent:
}
select {
case <-exited:
// 浏览器进程自己退了,端口不会再出现 —— **立刻**报,不把超时白等完。
// 浏览器进程自己退了,端点不会再就绪 —— **立刻**报,不把超时白等完。
// 最常见的原因是同一 profile 已被另一个实例占用:Chromium 把命令**委托给已在跑的
// 那个**之后自己退出(真机实测 stderr 原话「正在现有的浏览器会话中打开。」)、新
// 进程不写 DevToolsActivePort。这里给可操作建议,但不碰别人的进程。
return 0, fmt.Errorf("%w: %s。%s", ErrLaunch, launchMarkerProcessExited, profileBusyHint(l.profileDir))
// 进程不写端口记录。这里给可操作建议,但不碰别人的进程。
return "", 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 "", budgetLaunchErr(ctx)
}
return 0, l.portTimeoutErr()
return "", l.portTimeoutErr(port, foreign)
case <-deadline.C:
return 0, l.portTimeoutErr()
return "", l.portTimeoutErr(port, foreign)
case <-ticker.C:
}
}
}
// portTimeoutErr 是 ②「我们自己的等待窗口用完」的唯一文案(两条出口共用,口径只有一处)。
func (l *localChrome) portTimeoutErr() error {
return fmt.Errorf("%w: %s(%s)", ErrLaunch, launchMarkerPortTimeout, l.timeout)
// foreign=true 时改报「端口在听但不是可用的 CDP 端点」并带上换端口重试的标记:那是被别的
// 进程抢占的样子,与「浏览器起不来」是两回事。
func (l *localChrome) portTimeoutErr(port int, foreign bool) error {
if foreign {
return &portTakenError{err: fmt.Errorf(
"%w: %s(%s):%s 在听,但一直没有答出可用的 CDP 响应(可能被别的进程抢走了端口)。%s",
ErrLaunch, launchMarkerEndpointBroken, l.timeout, endpointURL(port), portTakenHint())}
}
return fmt.Errorf("%w: %s(%s):%s 一直没有应答", ErrLaunch, launchMarkerPortTimeout, l.timeout, endpointURL(port))
}
// 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
// portTakenError 标记「这一次失败是因为我们选的端口被别人占了」:它只在**进程内**用来决定
// 「换一个端口再试一次」(错误文案仍是 launchMarkerEndpointBroken —— 与启动前探到坏端点
// 共用同一类,因为对用户来说都是「端口在听但不是 CDP」)。
type portTakenError struct{ err error }
// Error 实现 error(文案由 portTimeoutErr 一次写清,这里不另编一句更好听的)。
func (e *portTakenError) Error() string { return e.err.Error() }
// Unwrap 让 errors.Is / errors.As 仍能穿过这一层(ErrLaunch 口径不变)。
func (e *portTakenError) Unwrap() error { return e.err }
// portTakenHint 是「我们选的端口被抢」的处置建议:重试即可(会自动换端口),但绝不代杀别人的进程。
func portTakenHint() string {
return "处置:会自动换一个端口重试一次;若反复出现,先查是谁占着这个端口 —— " +
"本代理不杀任何不属于自己的进程。"
}
// remoteCDP 是次选路径:连接一个**已在跑**的 CDP 端点(容器 + Xvfb + 真 Chromium,
+169 -119
View File
@@ -16,16 +16,22 @@ import (
// TestLaunchArgs_HeadfulCarriesNoAutomationTraces 是本片**最重要的一条测试**:它把
// `DevDocs/reference/browser-stealth/10 §2.1` 的黑名单钉成可执行判据 ——
// 有头路径**不许**出现任何 `--headless*`,也不许出现「自动化味道」的参数
// (`--enable-automation` 以及 Playwright/Puppeteer 默认传的那一串)。
// (`--enable-automation` 以及 Playwright/Puppeteer 默认传的那一串),
// 更不许出现 `--disable-blink-features` / `--enable-blink-features`:这两条在官方
// `kBadFlags` 名单里(reference/chromium-cdp/01),而改用**显式调试端口**后
// `navigator.webdriver` 本来就是 false(同册 07 的 g7/g8),那条 flag 已成空操作 ——
// 留着只剩「每次有头启动弹一条不受支持的命令行标记」这一个副作用。
// 有人往清单里加回一条,这里立刻红。
func TestLaunchArgs_HeadfulCarriesNoAutomationTraces(t *testing.T) {
t.Parallel()
l := &localChrome{exe: "chrome.exe", profileDir: `C:\tmp\p`, headless: false, viewportW: 1280, viewportH: 800}
args := l.launchArgs()
args := l.launchArgs(9411)
forbidden := []string{
"--headless",
"--enable-automation",
"--disable-blink-features",
"--enable-blink-features",
"--disable-extensions",
"--disable-default-apps",
"--disable-component-extensions-with-background-pages",
@@ -41,20 +47,23 @@ func TestLaunchArgs_HeadfulCarriesNoAutomationTraces(t *testing.T) {
}
}
// TestLaunchArgs_RequiredFlagsPresent 钉住「必须传」的那几条:profile、随机回环调试
// 端口、窗口尺寸、语言、免首次运行、以及 AutomationControlled 兜底防线。
// TestLaunchArgs_RequiredFlagsPresent 钉住「必须传」的那几条:profile、**显式**调试端口、
// 窗口尺寸、语言、免首次运行。显式端口是本片的核心改动:`--remote-debugging-port=0` 会被
// Chromium 当作 ChromeDriver 式自动化启动、主动点亮 `navigator.webdriver`
// (reference/chromium-cdp/02 §4.2、07 的 g1 vs g7),因此这条用例**同时**钉住
// 「端口必须是显式数字」与「不许出现 =0」。
func TestLaunchArgs_RequiredFlagsPresent(t *testing.T) {
t.Parallel()
const port = 9411
l := &localChrome{exe: "chrome.exe", profileDir: "/tmp/prof", headless: false, viewportW: 1024, viewportH: 768}
args := l.launchArgs()
args := l.launchArgs(port)
for _, want := range []string{
"--user-data-dir=/tmp/prof",
"--remote-debugging-port=0",
"--remote-debugging-port=9411",
"--window-size=1024,768",
"--lang=zh-CN",
"--no-first-run",
"--no-default-browser-check",
"--disable-blink-features=AutomationControlled",
"--remote-allow-origins=*",
"about:blank",
} {
@@ -62,47 +71,64 @@ func TestLaunchArgs_RequiredFlagsPresent(t *testing.T) {
t.Errorf("启动参数缺少 %q:%v", want, args)
}
}
for _, a := range args {
if strings.HasPrefix(a, "--remote-debugging-port=0") {
t.Errorf("调试端口必须是显式端口(=0 会被 Chromium 当作自动化启动):%v", args)
}
}
}
// TestLaunchArgs_HeadlessOnlyWhenConfigured 钉住「无头只在显式配置时出现」:
// 默认档是有头(design/28 §2),无头是面板显式降级的结果。
//
// ⚠️ 无头档**必然**点亮 `AutomationControlled`(`--headless` 本身就是四条打开路径之一,
// reference/chromium-cdp/02 §4.1),这是官方行为、不是我们的参数能去掉的:本片只保证
// **有头**档(默认档)不再靠那条 bad flag 去抵消它。无头档的 `webdriver` 读数**未实测**
// (见本片报告「没能证明的」)。
func TestLaunchArgs_HeadlessOnlyWhenConfigured(t *testing.T) {
t.Parallel()
l := &localChrome{exe: "chrome.exe", profileDir: "/tmp/p", headless: true, viewportW: 800, viewportH: 600}
args := l.launchArgs()
args := l.launchArgs(9412)
if len(args) == 0 || args[0] != "--headless=new" {
t.Errorf("无头档的第一个参数应为 --headless=new,实得:%v", args)
}
if !containsArg(args, "--disable-blink-features=AutomationControlled") {
t.Error("无头档也必须保留 AutomationControlled 防线")
if !containsArg(args, "--remote-debugging-port=9412") {
t.Errorf("无头档也必须用显式调试端口:%v", args)
}
}
// TestReadDevToolsPort 钉住端口文件解析:正常两行、只有一行、缺文件、垃圾内容。
func TestReadDevToolsPort(t *testing.T) {
// TestReadPortNumberFile 钉住「首行是端口号」的解析:我们自己的记录(一行端口)与
// Chromium 的 `DevToolsActivePort`(首行端口、次行 browser_guid)共用同一个解析。
func TestReadPortNumberFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, devToolsPortFile)
path := filepath.Join(dir, portRecordFile)
if _, ok := readDevToolsPort(path); ok {
if _, ok := readPortNumberFile(path); ok {
t.Error("文件不存在时不该报 ok")
}
if err := os.WriteFile(path, []byte("54321\nbrowser-guid-xyz\n"), 0o600); err != nil {
t.Fatal(err)
}
if port, ok := readDevToolsPort(path); !ok || port != 54321 {
if port, ok := readPortNumberFile(path); !ok || port != 54321 {
t.Errorf("读到 (%d,%v), want (54321,true)", port, ok)
}
if err := os.WriteFile(path, []byte("54322\n"), 0o600); err != nil {
t.Fatal(err)
}
if port, ok := readPortNumberFile(path); !ok || port != 54322 {
t.Errorf("我们自己的一行记录应可解析,实得 (%d,%v)", port, ok)
}
if err := os.WriteFile(path, []byte("not-a-port\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, ok := readDevToolsPort(path); ok {
if _, ok := readPortNumberFile(path); ok {
t.Error("垃圾内容不该报 ok")
}
if err := os.WriteFile(path, []byte("0\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, ok := readDevToolsPort(path); ok {
if _, ok := readPortNumberFile(path); ok {
t.Error("端口 0 不该报 ok")
}
}
@@ -120,10 +146,10 @@ func TestLocalChromeDescribe(t *testing.T) {
}
}
// TestLocalChromeAdoptsLiveEndpoint 钉住「认领已在跑的实例」:profile 目录里的
// DevToolsActivePort 还活着 ⇒ Dial 直接交出那个端点、**不启动任何进程**。
// TestLocalChromeAdoptsLiveEndpoint 钉住「认领已在跑的实例」:profile 目录里我们自己记的
// 调试端点(`cyrene-debug-port`)还活着 ⇒ Dial 直接交出那个端点、**不启动任何进程**。
// 这不是优化:同一 profile 起第二个实例时 Chromium 会委托给已在跑的那个然后自己退出,
// 新进程永远不写端口文件 ⇒ 不做认领,功能会被一个残留实例卡死。
// 新进程永远不会就绪 ⇒ 不做认领,功能会被一个残留实例卡死。
func TestLocalChromeAdoptsLiveEndpoint(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -137,9 +163,7 @@ func TestLocalChromeAdoptsLiveEndpoint(t *testing.T) {
port := strings.TrimPrefix(srv.URL, "http://127.0.0.1:")
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, devToolsPortFile), []byte(port+"\nGUID\n"), 0o600); err != nil {
t.Fatal(err)
}
writePortRecordFile(t, dir, portOfEndpoint(t, srv.URL))
l := &localChrome{exe: "definitely-not-a-browser.exe", profileDir: dir, timeout: time.Second, log: discardLog()}
endpoint, stop, err := l.Dial(context.Background())
if err != nil {
@@ -151,35 +175,33 @@ func TestLocalChromeAdoptsLiveEndpoint(t *testing.T) {
stop() // 认领来的实例不归我们杀:停止函数必须是空操作(调用不 panic 即通过)
}
// TestLocalChromeDoesNotAdoptDeadEndpoint 钉住反面:端口文件在、但没人听 ⇒ **不认领**
// TestLocalChromeDoesNotAdoptDeadEndpoint 钉住反面:端口记录在、但没人听 ⇒ **不认领**
// (否则会连到一个死端口,比启动失败更难排查)。它同时钉住「没人听」的**分类**:
// 探测要给出 ("", nil) 而不是错误 —— 这是「陈文件,可以清掉后照常启动」的那一条出路。
// 探测要给出 ("", nil) 而不是错误 —— 这是「陈记录,可以清掉后照常启动」的那一条出路。
func TestLocalChromeDoesNotAdoptDeadEndpoint(t *testing.T) {
t.Parallel()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, devToolsPortFile), []byte("1\nGUID\n"), 0o600); err != nil {
t.Fatal(err)
}
writePortRecordFile(t, dir, 1)
l := &localChrome{exe: "x", profileDir: dir, timeout: time.Second, log: discardLog()}
endpoint, err := l.probeExistingEndpoint(context.Background())
if endpoint != "" {
t.Fatalf("死端口不该被认领(endpoint=%q)", endpoint)
}
if err != nil {
t.Fatalf("死端口是「陈文件」而不是「坏端点」,探测不该报错: %v", err)
t.Fatalf("死端口是「陈记录」而不是「坏端点」,探测不该报错: %v", err)
}
}
// TestWaitDevToolsPort_ReportsEarlyExit 钉住「进程立刻退出」要**立刻**如实报,并把
// TestWaitDebugReady_ReportsEarlyExit 钉住「进程立刻退出」要**立刻**如实报,并把
// 最可能的原因写进错误(profile 被占用)—— 而不是把超时白等完、只留一句「超时」。
func TestWaitDevToolsPort_ReportsEarlyExit(t *testing.T) {
func TestWaitDebugReady_ReportsEarlyExit(t *testing.T) {
t.Parallel()
dir := t.TempDir()
l := &localChrome{profileDir: dir, timeout: 10 * time.Second, log: discardLog()}
exited := make(chan struct{})
close(exited)
start := time.Now()
_, err := l.waitDevToolsPort(context.Background(), exited)
_, err := l.waitDebugReady(context.Background(), deadPort(t), exited)
if err == nil {
t.Fatal("进程退出后应当报错")
}
@@ -233,7 +255,7 @@ func TestDial_ReportsChromeOutputAndArgvOnLaunchFailure(t *testing.T) {
complaint, // 浏览器自己说的话
l.exe, // 是哪个可执行文件
"--user-data-dir=" + l.profileDir, // 本次 argv(profile 路径不含凭据)
"--remote-debugging-port=0",
"--remote-debugging-port=", // 端口是**显式**的(具体号码每次不同,按前缀断言)
"chrome_output_bytes=",
} {
if !containsInLog(logs, want) {
@@ -244,21 +266,31 @@ func TestDial_ReportsChromeOutputAndArgvOnLaunchFailure(t *testing.T) {
// TestDial_SuccessDoesNotSpamChromeOutput 钉住反面:**成功时不刷屏**。正常启动时
// Chrome 也会往 stderr 写常规信息,那些不该进日志;只留一个字节数(Debug,供排查用)。
//
// 这里同时钉住新的就绪口径:假浏览器在**argv 给的那个显式端口**上监听并答 `/json/version`
// ⇒ Dial 成功,且把**这个端口**记进了 profile 目录的端口记录(下一次启动据此复用)。
func TestDial_SuccessDoesNotSpamChromeOutput(t *testing.T) {
t.Parallel()
const chatter = "DevTools listening on ws://127.0.0.1:55543"
log, buf := newCapturedLog(slog.LevelDebug)
l := newFakeChrome(t, fakeBrowserScript{
Stderr: []string{chatter}, WritePort: 55543, SleepMS: 5000,
Stderr: []string{chatter}, Serve: serveCDP, SleepMS: 5000,
}, 5*time.Second, log)
endpoint, stop, err := l.Dial(context.Background())
if err != nil {
t.Fatalf("脚本写了端口文件,Dial 应当成功: %v", err)
t.Fatalf("假浏览器在给定端口上服务了,Dial 应当成功: %v", err)
}
defer stop()
if endpoint != "http://127.0.0.1:55543" {
t.Errorf("endpoint = %q", endpoint)
port, ok := readPortRecord(l.profileDir)
if !ok {
t.Fatalf("成功启动后必须记下端口(否则下一次启动无法复用):endpoint=%q", endpoint)
}
if endpoint != endpointURL(port) {
t.Errorf("endpoint = %q, want %q(必须是我们自己选并记下的那个端口)", endpoint, endpointURL(port))
}
if got, dok := DebugEndpoint(l.profileDir); !dok || got != endpoint {
t.Errorf("DebugEndpoint() = (%q,%v), want (%q,true)", got, dok, endpoint)
}
logs := buf.String()
if containsInLog(logs, chatter) {
@@ -276,7 +308,7 @@ func TestDial_ExhaustedBudgetDoesNotSpawnBrowser(t *testing.T) {
t.Parallel()
log, buf := newCapturedLog(slog.LevelDebug)
l := newFakeChrome(t, fakeBrowserScript{
Stderr: []string{"这一行不该出现:进程根本没被拉起"}, WritePort: 55543, SleepMS: 5000,
Stderr: []string{"这一行不该出现:进程根本没被拉起"}, Serve: serveCDP, SleepMS: 5000,
}, 5*time.Second, log)
ctx, cancel := context.WithCancel(context.Background())
@@ -295,104 +327,122 @@ func TestDial_ExhaustedBudgetDoesNotSpawnBrowser(t *testing.T) {
if strings.Contains(err.Error(), launchMarkerPortTimeout) {
t.Errorf("预算耗尽不许被说成「端口等超时」(那是浏览器侧的问题): %v", err)
}
if _, ok := readDevToolsPort(filepath.Join(l.profileDir, devToolsPortFile)); ok {
t.Error("预算耗尽时不该起浏览器(脚本被执行了)")
if _, ok := readPortRecord(l.profileDir); ok {
t.Error("预算耗尽时不该起浏览器(端口记录被写下了)")
}
if n := fakeLaunches(l.profileDir); n != 0 {
t.Errorf("预算耗尽时子进程不该存在(假浏览器被拉起 %d 次)", n)
}
if strings.Contains(buf.String(), "这一行不该出现") {
t.Errorf("预算耗尽时子进程不该存在:\n%s", buf.String())
}
}
// TestDial_CallerDeadlineIsReportedAsBudget 钉住**真机那条路**:等待期间调用方的截止
// 时间到了(这一轮的工具预算烧完),错误必须读成「预算耗尽、浏览器没坏」,而不是
// 「等待调试端口时被取消」这种既像浏览器故障、又说不清谁取消的文案。
func TestDial_CallerDeadlineIsReportedAsBudget(t *testing.T) {
// TestDial_RecordsExplicitPortAndReusesItOnSecondLaunch 是本片「端口记录 + 复用」的主判据:
// 第一次启动必须记下一个**显式端口**;同一个 profile 上的第二次启动必须**复用**它、**不再起
// 第二个进程**(假浏览器的启动计数是直接观察,不是猜)。
//
// 为什么这条是硬要求:改用显式端口后 Chromium 不再写 `DevToolsActivePort`(reference/
// chromium-cdp/03 §3.3),若我们自己不记端口,第二次启动就会去起第二个进程 —— 而 Chromium
// 会把命令委托给已在跑的那个后自己退出(真机实测),于是「复用」这条正确性路径彻底失灵。
func TestDial_RecordsExplicitPortAndReusesItOnSecondLaunch(t *testing.T) {
t.Parallel()
log, buf := newCapturedLog(slog.LevelDebug)
l := newFakeChrome(t, fakeBrowserScript{Stderr: []string{"浏览器在等,但没人给它时间"}, SleepMS: 5000}, 10*time.Second, log)
l := newFakeChrome(t, fakeBrowserScript{Serve: serveCDP}, 5*time.Second, log)
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
_, _, err := l.Dial(ctx)
if err == nil {
t.Fatal("调用方截止时间到之后 Dial 必须报错")
first, stop, err := l.Dial(context.Background())
if err != nil {
t.Fatalf("第一次启动应当成功: %v", err)
}
if !errors.Is(err, ErrLaunch) {
t.Fatalf("错误必须仍然包住 ErrLaunch: %v", err)
defer stop()
if n := fakeLaunches(l.profileDir); n != 1 {
t.Fatalf("第一次启动应恰好起 1 个进程,实得 %d", n)
}
if !strings.Contains(err.Error(), launchMarkerBudget) {
t.Errorf("错误必须读成「预算耗尽」,实得: %v", err)
port, ok := readPortRecord(l.profileDir)
if !ok || port <= 0 {
t.Fatalf("必须记下显式端口,实得 port=%d ok=%v", port, ok)
}
if strings.Contains(err.Error(), launchMarkerPortTimeout) {
t.Errorf("调用方预算先到时不许报成我们自己的端口等待超时: %v", err)
if first != endpointURL(port) {
t.Fatalf("endpoint = %q, want %q", first, endpointURL(port))
}
if !containsInLog(buf.String(), l.profileDir) {
t.Errorf("这条失败也要带 argv(否则现场没法复现):\n%s", buf.String())
second, stop2, err := l.Dial(context.Background())
if err != nil {
t.Fatalf("第二次启动应当复用已存在的端点: %v", err)
}
defer stop2()
if second != first {
t.Errorf("第二次应复用它,实得 %q(第一次 %q)", second, first)
}
if n := fakeLaunches(l.profileDir); n != 1 {
t.Errorf("第二次不该再起进程(假浏览器被拉起 %d 次),日志:\n%s", n, buf.String())
}
if !strings.Contains(buf.String(), "browser debug endpoint reused") {
t.Errorf("复用必须留一条 INFO:\n%s", buf.String())
}
}
// TestDial_ThreeLaunchFailuresAreDistinguishable 钉住「一眼分辨」:三条出口 ——
// ① 进程立刻退出 / ② 我们自己的端口等待超时 / ③ 调用方预算耗尽 —— 各自的错误文案里
// 必须带**互不相同**的标记,谁也别冒充谁。
func TestDial_ThreeLaunchFailuresAreDistinguishable(t *testing.T) {
// TestDial_RetriesWithAnotherPortWhenPickedPortIsTaken 钉住 TOCTOU 的处置:`listen(:0)` 取号
// 与 Chromium bind 之间,端口可能被别的进程抢走 —— 这时**换一个端口重试一次**(而不是把
// 「端口被占」报成「浏览器起不来」)。假浏览器第一次抢先占住端口答非 CDP、第二次才当 CDP,
// 因此这条用例同时证明「重试发生了」与「重试真的能成功」。
func TestDial_RetriesWithAnotherPortWhenPickedPortIsTaken(t *testing.T) {
t.Parallel()
cases := []struct {
name string
script fakeBrowserScript
timeout time.Duration
ctx func() (context.Context, context.CancelFunc)
wantMarker string
forbid string
}{
{
name: "①进程立刻退出",
script: fakeBrowserScript{Stderr: []string{"参数被拒,进程退出"}, ExitCode: 1},
timeout: 5 * time.Second,
ctx: func() (context.Context, context.CancelFunc) { return context.WithCancel(context.Background()) },
wantMarker: launchMarkerProcessExited,
forbid: launchMarkerBudget,
},
{
name: "②等端口超时",
script: fakeBrowserScript{Stderr: []string{"起来了但从不写端口文件"}, SleepMS: 5000},
timeout: 300 * time.Millisecond,
ctx: func() (context.Context, context.CancelFunc) { return context.WithCancel(context.Background()) },
wantMarker: launchMarkerPortTimeout,
forbid: launchMarkerBudget,
},
{
name: "③调用方预算耗尽",
script: fakeBrowserScript{Stderr: []string{"还没就绪预算就没了"}, SleepMS: 5000},
timeout: 10 * time.Second,
ctx: func() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 200*time.Millisecond)
},
wantMarker: launchMarkerBudget,
forbid: launchMarkerPortTimeout,
},
log, buf := newCapturedLog(slog.LevelDebug)
l := newFakeChrome(t, fakeBrowserScript{Serve: serveForeignThenCDP}, 3*time.Second, log)
endpoint, stop, err := l.Dial(context.Background())
if err != nil {
t.Fatalf("换端口重试之后应当成功,实得: %v\n日志:\n%s", err, buf.String())
}
seen := map[string]string{}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
l := newFakeChrome(t, tc.script, tc.timeout, discardLog())
ctx, cancel := tc.ctx()
defer cancel()
_, _, err := l.Dial(ctx)
if err == nil {
t.Fatal("这一档必须报错")
}
msg := err.Error()
t.Logf("%s 实测错误:%v", tc.name, err)
if !strings.Contains(msg, tc.wantMarker) {
t.Errorf("错误里应带标记 %q:%v", tc.wantMarker, err)
}
if strings.Contains(msg, tc.forbid) {
t.Errorf("错误里不该出现 %q(冒充了另一类失败):%v", tc.forbid, err)
}
if prev, dup := seen[tc.wantMarker]; dup {
t.Errorf("标记 %q 与 %q 撞了 —— 三种情形必须可分", tc.wantMarker, prev)
}
seen[tc.wantMarker] = tc.name
})
defer stop()
if n := fakeLaunches(l.profileDir); n != 2 {
t.Errorf("应当恰好重试一次(起 2 个进程),实得 %d:\n%s", n, buf.String())
}
if !strings.Contains(buf.String(), "retrying with another debug port") {
t.Errorf("换端口重试必须留一条 WARN(现场可查):\n%s", buf.String())
}
port, ok := readPortRecord(l.profileDir)
if !ok {
t.Fatal("成功之后必须记下最终用的端口")
}
if endpoint != endpointURL(port) {
t.Errorf("endpoint = %q, want %q(必须是重试后最终成功的那个端口)", endpoint, endpointURL(port))
}
}
// TestDial_ReportsPortTakenAfterRetryExhausted 是上一条的反面:端口**反复**被别人抢走时,
// 如实报「端口在听但不是可用的 CDP 端点」(不冒充「等端口超时」,也不冒充「进程退出」),
// 并且**不**留下端口记录(本次没有可用会话,留下记录会让下一次启动去探一个假端点)。
func TestDial_ReportsPortTakenAfterRetryExhausted(t *testing.T) {
t.Parallel()
log, buf := newCapturedLog(slog.LevelDebug)
l := newFakeChrome(t, fakeBrowserScript{Serve: serveForeign}, 300*time.Millisecond, log)
_, 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)
}
if !strings.Contains(err.Error(), "处置") {
t.Errorf("应给可操作的处置建议: %v", err)
}
if n := fakeLaunches(l.profileDir); n != launchPortAttempts {
t.Errorf("应当试满 %d 次(每次换端口),实得 %d", launchPortAttempts, n)
}
if _, ok := readPortRecord(l.profileDir); ok {
t.Error("没有可用会话就不该留下端口记录(下一次启动会去探一个假端点)")
}
}
// TestDial_CallerDeadlineIsReportedAsBudget 与 TestDial_ThreeLaunchFailuresAreDistinguishable
// 这两条「失败分类」判据在 launchfailure_test.go(拆文件只为守住 AGENTS §10 的 500 行上限)。
@@ -0,0 +1,109 @@
package browser
// 本文件只放「失败怎么分类/怎么报」这一类判据(实现见 launch.go 的 budgetLaunchErr /
// portTimeoutErr 与 lift 里的三条出口)。它们与 launch_test.go 的其余 Dial 用例分开,
// 一是为了每个文件都不超 AGENTS §10 的 500 行上限,二是让「三类失败必须一眼可分」这一组
// 判据集中在一处可读。
import (
"context"
"errors"
"log/slog"
"strings"
"testing"
"time"
)
// TestDial_CallerDeadlineIsReportedAsBudget 钉住**真机那条路**:等待期间调用方的截止
// 时间到了(这一轮的工具预算烧完),错误必须读成「预算耗尽、浏览器没坏」,而不是
// 「等待调试端口时被取消」这种既像浏览器故障、又说不清谁取消的文案。
func TestDial_CallerDeadlineIsReportedAsBudget(t *testing.T) {
t.Parallel()
log, buf := newCapturedLog(slog.LevelDebug)
l := newFakeChrome(t, fakeBrowserScript{Stderr: []string{"浏览器在等,但没人给它时间"}, SleepMS: 5000}, 10*time.Second, log)
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
_, _, err := l.Dial(ctx)
if err == nil {
t.Fatal("调用方截止时间到之后 Dial 必须报错")
}
if !errors.Is(err, ErrLaunch) {
t.Fatalf("错误必须仍然包住 ErrLaunch: %v", err)
}
if !strings.Contains(err.Error(), launchMarkerBudget) {
t.Errorf("错误必须读成「预算耗尽」,实得: %v", err)
}
if strings.Contains(err.Error(), launchMarkerPortTimeout) {
t.Errorf("调用方预算先到时不许报成我们自己的端口等待超时: %v", err)
}
if !containsInLog(buf.String(), l.profileDir) {
t.Errorf("这条失败也要带 argv(否则现场没法复现):\n%s", buf.String())
}
}
// TestDial_ThreeLaunchFailuresAreDistinguishable 钉住「一眼分辨」:三条出口 ——
// ① 进程立刻退出 / ② 我们自己的端口等待超时 / ③ 调用方预算耗尽 —— 各自的错误文案里
// 必须带**互不相同**的标记,谁也别冒充谁。
func TestDial_ThreeLaunchFailuresAreDistinguishable(t *testing.T) {
t.Parallel()
cases := []struct {
name string
script fakeBrowserScript
timeout time.Duration
ctx func() (context.Context, context.CancelFunc)
wantMarker string
forbid string
}{
{
name: "①进程立刻退出",
script: fakeBrowserScript{Stderr: []string{"参数被拒,进程退出"}, ExitCode: 1},
timeout: 5 * time.Second,
ctx: func() (context.Context, context.CancelFunc) { return context.WithCancel(context.Background()) },
wantMarker: launchMarkerProcessExited,
forbid: launchMarkerBudget,
},
{
name: "②等端口就绪超时",
script: fakeBrowserScript{Stderr: []string{"起来了但从不答 CDP"}, SleepMS: 5000},
timeout: 300 * time.Millisecond,
ctx: func() (context.Context, context.CancelFunc) { return context.WithCancel(context.Background()) },
wantMarker: launchMarkerPortTimeout,
forbid: launchMarkerBudget,
},
{
name: "③调用方预算耗尽",
script: fakeBrowserScript{Stderr: []string{"还没就绪预算就没了"}, SleepMS: 5000},
timeout: 10 * time.Second,
ctx: func() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 200*time.Millisecond)
},
wantMarker: launchMarkerBudget,
forbid: launchMarkerPortTimeout,
},
}
seen := map[string]string{}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
l := newFakeChrome(t, tc.script, tc.timeout, discardLog())
ctx, cancel := tc.ctx()
defer cancel()
_, _, err := l.Dial(ctx)
if err == nil {
t.Fatal("这一档必须报错")
}
msg := err.Error()
t.Logf("%s 实测错误:%v", tc.name, err)
if !strings.Contains(msg, tc.wantMarker) {
t.Errorf("错误里应带标记 %q:%v", tc.wantMarker, err)
}
if strings.Contains(msg, tc.forbid) {
t.Errorf("错误里不该出现 %q(冒充了另一类失败):%v", tc.forbid, err)
}
if prev, dup := seen[tc.wantMarker]; dup {
t.Errorf("标记 %q 与 %q 撞了 —— 三种情形必须可分", tc.wantMarker, prev)
}
seen[tc.wantMarker] = tc.name
})
}
}
+176
View File
@@ -0,0 +1,176 @@
package browser
// 本文件是「调试端口的来源与落点」这一件事的**唯一出处**:取一个空闲端口、记在 profile
// 目录里、需要时读回来、陈记录清掉。
//
// 为什么改成自己记端口(DevDocs/reference/chromium-cdp/00 的「方案 A」,主人 2026-10-07 拍板):
//
// - Chromium 把 `--remote-debugging-port=0` 当作 **ChromeDriver 式自动化启动**,会主动打开
// Blink 的 `AutomationControlled` ⇒ `navigator.webdriver === true`(官方
// content/child/runtime_features.cc:389-404;本机实测见 07 的 g1 vs g7);**显式端口**
// 不会被这样处理(官方注释原文:调用方给具体端口时「更可能是挂调试器」,故不动该特性);
// - 我们因此**不再传** `--disable-blink-features=AutomationControlled`:它在显式端口下已是
// 空操作,却仍是整条命令行里唯一命中官方 `kBadFlags` 的参数 ⇒ 每次有头启动都弹一条
// 「不受支持的命令行标记」信息栏(01 的源码 + 07 的 g2/g7 实测)。去掉它,webdriver
// **本来就是 false**,横幅也随之消失;
// - 代价:Chromium **只在 `port==0` 时**才写 `<profile>/DevToolsActivePort`
// (官方 chrome/browser/devtools/remote_debugging_server.cc:363-395;实测 03 §3.3)——
// 改显式端口后那个文件不再出现,端口只能由**我们自己**记。
//
// 落点选 **profile 目录**里的 `cyrene-debug-port`(我们自有的记录:Chrome 既不读也不写它),
// 而不是 `$DATA_DIR/browser/` 下另开一份状态文件:
//
// - 它天然按 profile 隔离 —— 认领判定(adopt.go 的 probeExistingEndpoint)只需看一个目录,
// 不必再维护「profile → 记录」的映射与它们的失效时机;
// - 与 Chrome 自己的 `DevToolsActivePort` 同目录,两者可以并列读(debugPortCandidates);
// - profile 被删 / 被换 / 被搬走时记录随之消失,不会留下一条指向别人的陈端口。
//
// 记录内容只有一行端口号,**不含任何凭据**(profile 里的 cookie / 登录态在别的文件里)。
import (
"context"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"strings"
)
const (
// portRecordFile 是我们自己的调试端口记录(首行端口号)。
portRecordFile = "cyrene-debug-port"
// legacyDevToolsPortFile 是 Chromium 在 `--remote-debugging-port=0` 时写的那个文件
// (reference/chromium-cdp/03 §3.2:首行端口、次行 browser_guid)。改用显式端口后我们
// **不再依赖它**,但仍然读一眼 —— 换二进制时上一版(port=0)起的浏览器可能还在跑:
// 不认它就会把「profile 上已经有我们的浏览器」误报成「profile 被别人占了」。
legacyDevToolsPortFile = "DevToolsActivePort"
// debugPortPickAttempts 是「取一个空闲端口」的尝试次数。做法:`listen(:0)` 让内核分配一个
// 空闲回环端口、随即关闭,把号交给 Chromium 去 bind —— 这中间存在极小的 TOCTOU 抢占窗口
// (AGENTS §9:我们自己**不开**监听端口,这里只是「借内核挑个号」再立刻放掉),抢不到就再取。
debugPortPickAttempts = 5
// launchPortAttempts 是本机启动的尝试次数:1 次正常 + 1 次**换端口**重试。重试只发生在
// 有证据表明「我们选的端口被别人占了」时(见 portTakenError);其余失败一律如实上报 ——
// 无差别重试只会把失败原因糊掉,还把预算烧在重复等待上。
launchPortAttempts = 2
)
// endpointURL 把端口号变成 CDP HTTP 端点的地址(127.0.0.1:调试端点在回环上,我们出站连它)。
func endpointURL(port int) string {
return "http://" + loopbackAddr(port)
}
// loopbackAddr 拼一个回环地址(探查、记录、启停都用同一处拼法,避免两处写法漂移)。
func loopbackAddr(port int) string {
return net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
}
// pickDebugPort 取一个**当前空闲**的回环端口号交给 Chromium:listen(:0) 由内核分配、立刻
// 关闭(本进程不再持有它,AGENTS §9 的「不常驻监听端口」因此成立)。关掉之后再到手之间
// 有极小的被抢窗口,故取到一个「此刻确实没人听」的号才返回,最多试 debugPortPickAttempts 次。
func pickDebugPort(ctx context.Context) (int, error) {
for attempt := 0; attempt < debugPortPickAttempts; attempt++ {
ln, err := net.Listen("tcp", loopbackAddr(0))
if err != nil {
return 0, fmt.Errorf("listen(:0) 取空闲端口: %w", err)
}
port := ln.Addr().(*net.TCPAddr).Port
if err := ln.Close(); err != nil {
continue
}
if !portListening(ctx, port) {
return port, nil
}
}
return 0, fmt.Errorf("连续 %d 次取到的空闲端口都已被占用", debugPortPickAttempts)
}
// portRecordPath 返回这个 profile 的端口记录文件的路径。
func portRecordPath(profileDir string) string {
return filepath.Join(profileDir, portRecordFile)
}
// writePortRecord 记下**本次实际使用的**调试端口。写失败不算成功的一半:记不下端口就等于
// 下一次启动无法复用(会去起第二个进程、然后被 Chromium 委托出去),因此调用方必须把它当
// 硬失败(见 launchWithRetry)。
func writePortRecord(profileDir string, port int) error {
path := portRecordPath(profileDir)
if err := os.WriteFile(path, []byte(strconv.Itoa(port)+"\n"), 0o600); err != nil {
return fmt.Errorf("写调试端口记录 %s: %w", path, err)
}
return nil
}
// readPortRecord 读回我们自己记的端口;文件不在 / 内容不合法都返回 false(交给调用方当
// 「没有可复用的记录」处理,而不是把「正在写」误判成「配错」)。
func readPortRecord(profileDir string) (int, bool) {
return readPortNumberFile(portRecordPath(profileDir))
}
// DebugEndpoint 报告这个 profile 上**已知**的调试端点(我们自己记的端口记录,见本文件头)。
// 它只读文件、不探活、不启动任何进程,供排障与端到端用例取「这一次会话用的是哪个端点」。
// 返回 ("", false) 表示没有记录或记录不合法 —— **不猜**端口。
func DebugEndpoint(profileDir string) (string, bool) {
port, ok := readPortRecord(profileDir)
if !ok {
return "", false
}
return endpointURL(port), true
}
// debugPortCandidates 返回「这个 profile 上可能有 CDP 的端口」候选,按可信度排序:先我们
// 自己的记录,再那一眼兼容的老文件(legacyDevToolsPortFile),去重。
func debugPortCandidates(profileDir string) []int {
var out []int
if port, ok := readPortRecord(profileDir); ok {
out = append(out, port)
}
legacy := filepath.Join(profileDir, legacyDevToolsPortFile)
if port, ok := readPortNumberFile(legacy); ok && (len(out) == 0 || out[0] != port) {
out = append(out, port)
}
return out
}
// readPortNumberFile 读一个「首行是端口号」的文件;文件不在 / 还没写完 / 内容不合法都返回
// false。两种文件都套这个格式:我们自己的 portRecordFile(一行端口)与 Chrome 的
// legacyDevToolsPortFile(首行端口、次行 browser_guid)。
func readPortNumberFile(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
}
// clearStalePortRecords 清掉**上一轮**留下的端口记录(两种都清)—— 只有确认「候选端口都已经
// 没人听」才会走到这里(见 Dial 的调用顺序)。
//
// 为什么必须清:这两条记录是「这个 profile 上有一个 CDP 端点」的**唯一线索**,上一轮的端口
// 留着只会让下一次启动的认领判定去探一个死端口。
//
// 为什么可以清:它们只是浏览器启动时写下的**一条记录**,不是锁。真正在跑、真正拥有这个
// profile 的实例一定有一个**活着的**端口,那种情况上一步(probeExistingEndpoint)已经把它
// 复用走了。因此删掉指向死端口的记录,不可能从任何活着的实例手里拿走什么 —— 它影响的只有
// 我们自己的认领判定。
//
// 文件本来就不在(ErrNotExist)不算失败;别的错误(被占用 / 权限)**如实报**并且**不继续
// 启动** —— 留着一条陈记录继续启动,只会把陈端口读成本次可复用的端点。
func (l *localChrome) clearStalePortRecords() error {
for _, path := range []string{portRecordPath(l.profileDir), filepath.Join(l.profileDir, legacyDevToolsPortFile)} {
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("%w: 清掉上一轮的端口记录 %s 失败: %v(留着它会把陈端口当成本次可复用的端点)",
ErrLaunch, path, err)
}
}
return nil
}
@@ -0,0 +1,401 @@
package browser
// 本文件是**真机判据**(真 Chrome + 真显式端口 + 临时 profile):方案 A
// (reference/chromium-cdp/00)落地后的端到端自证,默认 Skip,只在显式开闸时跑:
//
// CYRENE_BROWSER_CDP_E2E=1 go test ./internal/capability/browser/ -run TestRealChromeCDP -v -timeout 5m
//
// 它证明五件事,每件都取自**真进程 / 真页面**而不是我们的以为:
//
// ① `navigator.webdriver === false`(改前用 `--remote-debugging-port=0` 实测是 true,见
// reference/chromium-cdp/07 的 g1/g7);
// ② 真 Chrome 的**命令行原话**里是显式端口,且没有 `--disable-blink-features` /
// `--enable-automation` / `--headless`(从 Win32_Process 读回);
// ③ 其余判据与 07 §2.2 的矩阵一致(UA 不含 HeadlessChrome、plugins.length=5、
// languages=zh-CN、window.chrome 在)—— 即这次改动**只动了端口写法**;
// ④ 同一个 profile 上的**第二次** Dial 复用同一个端点、**不起第二个浏览器进程**;
// ⑤ 窗口内**没有** bad-flags 横幅(UI Automation 读控件树原文;有横幅时长这样:
// 「您使用的是不受支持的命令行标记:--disable-blink-features=…」,见 07 §三)。
//
// 安全边界(硬要求):profile 一律落在 `t.TempDir()`(%TEMP% 下),**绝不触碰**
// `$DATA_DIR/browser/profile`;收尾只按**这个唯一临时 profile 路径**筛 PID,不按进程名杀。
// assertProfileIsTemporary 是防误杀主人浏览器的闸。
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
)
// realPluginsLength 是 reference/chromium-cdp/07 §2.2 实测的 `navigator.plugins.length`
// (九组一致)。它只用来钉「这次改动没动其余判据」,不是新的期望值来源。
const realPluginsLength = 5
// realUIAminNodes 是 UI Automation 控件树的最少命名节点数:低于它说明**没读到窗口**,
// 那种情况下的「没有横幅」是「读不到」而不是「没有」,必须判红(positive control)。
const realUIAminNodes = 5
// cdpE2EEnabled 报告这次运行是否开了真机闸(与 wire_browser_lifecycle_test.go 的
// CYRENE_BROWSER_E2E 同一套路:真机用例不混进普通 go test)。
func cdpE2EEnabled() bool { return os.Getenv("CYRENE_BROWSER_CDP_E2E") == "1" }
// TestRealChromeCDP_WebdriverFalseAndPortReuse 是真机主判据(默认 Skip,用法见文件头)。
func TestRealChromeCDP_WebdriverFalseAndPortReuse(t *testing.T) {
if !cdpE2EEnabled() {
t.Skip("未设 CYRENE_BROWSER_CDP_E2E=1:真机用例默认不跑(用法见本文件顶部)")
}
if os.PathSeparator != '\\' {
t.Skip("本用例依赖 Windows 的 Win32_Process / UI Automation")
}
exe, ok := browsercfg.NewProber().FindLocalBrowser()
if !ok {
t.Skip("本机没有 Chrome/Chromium,真机用例无法进行")
}
profile := filepath.Join(t.TempDir(), "profile-cdp-e2e")
assertProfileIsTemporary(t, profile)
t.Logf("真机前置:chrome=%s\n临时 profile=%s", exe, profile)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
log, logs := newCapturedLog(slog.LevelDebug)
l := &localChrome{
exe: exe, profileDir: profile, headless: false,
viewportW: browsercfg.DefaultViewportWidth, viewportH: browsercfg.DefaultViewportHeight,
timeout: 90 * time.Second, log: log,
}
cfg := configView{
profileDir: profile, shotsDir: t.TempDir(), headless: false,
viewportW: browsercfg.DefaultViewportWidth, viewportH: browsercfg.DefaultViewportHeight,
timeout: 90 * time.Second, idle: time.Minute, shotMaxBytes: browsercfg.DefaultScreenshotMaxBytes,
}
defer killChromeForProfile(t, profile)
s, err := openSession(ctx, l, cfg)
if err != nil {
t.Fatalf("起真 Chrome 会话: %v\n日志:\n%s", err, logs.String())
}
endpoint := s.endpoint
port, ok := readPortRecord(profile)
if !ok {
t.Fatalf("成功启动后必须记下端口(%s)", portRecordFile)
}
if endpoint != endpointURL(port) {
t.Fatalf("endpoint = %q, want %q", endpoint, endpointURL(port))
}
t.Logf("① 启动成功:endpoint=%s(我们自己记下的端口 = %d)", endpoint, port)
// ── 判据 ②:真进程的命令行(Win32_Process 读回的原话) ─────────────────────────
procs := chromeProcsForProfile(t, profile, false)
if len(procs) != 1 {
t.Fatalf("按临时 profile 路径筛出的浏览器主进程应恰好 1 个,实得 %d:%+v", len(procs), procs)
}
browserPID := procs[0].PID
t.Logf("② 真 Chrome 命令行(PID %d,原话):\n%s", browserPID, procs[0].CommandLine)
cmdline := procs[0].CommandLine
for _, want := range []string{
"--remote-debugging-port=" + strconv.Itoa(port),
"--user-data-dir=" + profile,
"--remote-allow-origins=*",
} {
if !strings.Contains(cmdline, want) {
t.Errorf("真 Chrome 命令行里应有 %q", want)
}
}
for _, bad := range []string{
"--disable-blink-features", "--enable-blink-features", "--enable-automation", "--headless",
} {
if strings.Contains(cmdline, bad) {
t.Errorf("真 Chrome 命令行里不该有 %q(reference/chromium-cdp/01/02)", bad)
}
}
// ── 判据 ①/③:页面自报的判据 ──────────────────────────────────────────────────
fp, err := readFingerprint(ctx, s)
if err != nil {
t.Fatalf("读真机指纹: %v", err)
}
extra := readRealPageSignals(t, ctx, s)
t.Logf("① 页面自报 navigator.webdriver=%v\n③ %s", fp.Webdriver, extra.summary())
if fp.Webdriver {
t.Errorf("navigator.webdriver 必须为 false(显式端口不该点亮 AutomationControlled)")
}
if strings.Contains(strings.ToLower(fp.UserAgent), "headless") {
t.Errorf("UA 里不该出现 HeadlessChrome:%q", fp.UserAgent)
}
if !strings.Contains(fp.UserAgent, "Chrome/") {
t.Errorf("UA 看起来不是真 Chrome:%q", fp.UserAgent)
}
if len(fp.Languages) == 0 || !strings.HasPrefix(fp.Languages[0], "zh-CN") {
t.Errorf("languages 应与启动的 --lang=zh-CN 一致,实得 %v", fp.Languages)
}
if extra.PluginsLength != realPluginsLength {
t.Errorf("plugins.length = %d, want %d(与 reference/chromium-cdp/07 §2.2 矩阵一致)",
extra.PluginsLength, realPluginsLength)
}
if !extra.HasWindowChrome {
t.Error("window.chrome 应当存在")
}
// ── 判据 ⑤:窗口内没有 bad-flags 横幅(UIA 控件树原文) ────────────────────────
dump := uiaDumpWhenReady(t, browserPID)
t.Logf("⑤ UI Automation 控件树(PID %d,命名节点 %d 个):\n%s",
browserPID, countNonEmptyLines(dump), dump)
for _, banner := range []string{
"不受支持的命令行标记", "unsupported command-line flag", "自动测试软件正在控制",
} {
if strings.Contains(dump, banner) {
t.Errorf("窗口内出现了横幅 %q:\n%s", banner, dump)
}
}
// ── 判据 ④:同 profile 第二次启动必须复用、不起第二个进程 ─────────────────────
second, stop2, err := l.Dial(ctx)
if err != nil {
t.Fatalf("第二次 Dial 应当复用已存在的端点: %v\n日志:\n%s", err, logs.String())
}
defer stop2()
if second != endpoint {
t.Errorf("第二次 Dial 交出 %q, want %q(复用必须交回同一个端点)", second, endpoint)
}
again := chromeProcsForProfile(t, profile, false)
if len(again) != 1 || again[0].PID != browserPID {
t.Errorf("第二次 Dial 不该换来新进程:before=%+v after=%+v", procs, again)
}
t.Logf("④ 第二次 Dial 复用成功:endpoint=%s,浏览器主进程仍是 PID %d(未起第二个)", second, browserPID)
// ── 收尾:关掉本次会话,并确认无残留(按唯一临时 profile 路径筛) ─────────────
s.Close()
left := killChromeForProfile(t, profile)
if left != 0 {
t.Errorf("收尾后仍按临时 profile 筛到 %d 个进程", left)
}
t.Logf("收尾:会话已关;按 profile %s 筛到的残留 Chrome 进程 = %d", profile, left)
t.Logf("本次真机运行的浏览器侧日志:\n%s", logs.String())
}
// realPageSignals 是页面自报的其余判据(07 §2.2 矩阵里的 plugins / window.chrome 等)。
type realPageSignals struct {
UserAgent string `json:"userAgent"`
PluginsLength int `json:"pluginsLength"`
Languages []string `json:"languages"`
HasWindowChrome bool `json:"hasWindowChrome"`
ChromeKeys []string `json:"chromeKeys"`
ScreenW int `json:"screenW"`
ScreenH int `json:"screenH"`
ColorDepth int `json:"colorDepth"`
Platform string `json:"platform"`
DevicePixelRatio float64 `json:"devicePixelRatio"`
}
// summary 把读数压成一行(进 t.Log,便于与 07 的矩阵逐项对照)。
func (r realPageSignals) summary() string {
return fmt.Sprintf("UA=%q plugins.length=%d languages=%v window.chrome=%v keys=%v "+
"platform=%s screen=%dx%dx%d dpr=%g",
r.UserAgent, r.PluginsLength, r.Languages, r.HasWindowChrome, r.ChromeKeys,
r.Platform, r.ScreenW, r.ScreenH, r.ColorDepth, r.DevicePixelRatio)
}
// realPageSignalsExpr 一次求值取回整组判据(口径与 07 的探针一致,多了 plugins / window.chrome)。
const realPageSignalsExpr = `(() => ({
userAgent: navigator.userAgent,
pluginsLength: navigator.plugins.length,
languages: navigator.languages,
hasWindowChrome: typeof window.chrome !== 'undefined',
chromeKeys: (typeof window.chrome === 'undefined') ? [] : Object.keys(window.chrome),
screenW: screen.width,
screenH: screen.height,
colorDepth: screen.colorDepth,
platform: navigator.platform,
devicePixelRatio: window.devicePixelRatio
}))()`
// readRealPageSignals 在**隔离世界**里读一次页面判据(与生产路径同一口径)。
func readRealPageSignals(t *testing.T, ctx context.Context, s *session) realPageSignals {
t.Helper()
var out realPageSignals
if err := s.evaluate(ctx, realPageSignalsExpr, &out); err != nil {
t.Fatalf("读页面判据: %v", err)
}
raw, _ := json.Marshal(out)
t.Logf("页面判据(原始 JSON):%s", raw)
return out
}
// ── Windows 侧探针:进程表 / UI Automation / 收尾清理(都只按临时 profile 路径或 PID 定位) ──
// chromeProcsPS 列出命令行含指定 profile 的 Chrome 进程(`PID|命令行` 每行一条)。
// includeChildren=1 时连渲染等子进程一起列(子进程 argv 里带 `--type=`)。
const chromeProcsPS = `param([string]$Profile, [string]$IncludeChildren)
$ErrorActionPreference = 'SilentlyContinue'
$wantChildren = ($IncludeChildren -eq '1')
Get-CimInstance Win32_Process | Where-Object {
$_.Name -like 'chrome*' -and $_.CommandLine -and $_.CommandLine.Contains($Profile) -and
($wantChildren -or (-not $_.CommandLine.Contains('--type=')))
} | ForEach-Object { '{0}|{1}' -f $_.ProcessId, $_.CommandLine }
`
// killChromePS 按 profile 路径筛 PID 并强制结束(`$PID` 是 PowerShell 自己的进程号,
// 必须排除 —— 它的命令行里也带着这个 profile 参数)。绝不按进程名杀。
const killChromePS = `param([string]$Profile)
$ErrorActionPreference = 'SilentlyContinue'
$me = $PID
Get-CimInstance Win32_Process | Where-Object {
$_.ProcessId -ne $me -and $_.Name -like 'chrome*' -and $_.CommandLine -and $_.CommandLine.Contains($Profile)
} | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }
`
// uiaDumpPS 用 UI Automation 把该 PID 主窗口的控件树**名字**写到 `-OutPath` 指的 UTF-8 文件
// (07 §三 的手法:纯 ASCII 脚本 + UTF-8 落盘,避开 PowerShell 5.1 按 GBK 解析脚本的问题)。
// ⚠️ 参数不能叫 `$Out`:它会被 PowerShell 当成通用参数 `-OutVariable` 的缩写,于是我自己的
// 参数收不到值(实测:脚本 exit 0 但文件根本没写)。
const uiaDumpPS = `param([int]$BrowserPid, [string]$OutPath)
$ErrorActionPreference = 'SilentlyContinue'
Add-Type -AssemblyName UIAutomationClient
$proc = Get-Process -Id $BrowserPid
$handle = if ($null -eq $proc) { 0 } else { $proc.MainWindowHandle }
$text = ''
if ($handle -eq 0) {
$text = 'NO_WINDOW'
} else {
$root = [System.Windows.Automation.AutomationElement]::FromHandle($handle)
if ($null -eq $root) { $text = 'NO_ROOT' } else {
$walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker
$out = New-Object System.Collections.Generic.List[string]
function Walk($el, $depth) {
if ($null -eq $el) { return }
if ($depth -gt 12) { return }
try {
$name = $el.Current.Name
if ($name -and $name.Trim().Length -gt 0) { $out.Add((' ' * $depth) + $name.Trim()) }
$child = $walker.GetFirstChild($el)
while ($null -ne $child) { Walk $child ($depth + 1); $child = $walker.GetNextSibling($child) }
} catch { }
}
Walk $root 0
$text = [string]::Join([char]10, $out)
}
}
[System.IO.File]::WriteAllText($OutPath, $text, (New-Object System.Text.UTF8Encoding($false)))
`
// runPowerShell 把一段脚本落到临时 .ps1 再执行(args 以 `-Name value` 形式传入)。
func runPowerShell(t *testing.T, name, script string, args ...string) string {
t.Helper()
path := filepath.Join(t.TempDir(), name+".ps1")
if err := os.WriteFile(path, []byte(script), 0o600); err != nil {
t.Fatalf("写 PowerShell 脚本: %v", err)
}
cmdArgs := append([]string{
"-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", path,
}, args...)
cmd := exec.Command("powershell", cmdArgs...)
raw, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("执行 PowerShell 脚本 %s: %v\n输出:%s", name, err, raw)
}
return string(raw)
}
// assertProfileIsTemporary 是本文件的**安全闸**:profile 必须在系统临时目录下,
// 免得任何一步(尤其是收尾的按路径杀进程)碰到主人的真实 profile。
func assertProfileIsTemporary(t *testing.T, profile string) {
t.Helper()
tmp := strings.ToLower(strings.TrimRight(os.TempDir(), `\`))
if !strings.HasPrefix(strings.ToLower(profile), tmp) {
t.Fatalf("拒绝在非临时目录下跑真机用例(防误杀主人的浏览器):%s 不在 %s 下", profile, tmp)
}
}
// chromeProcsForProfile 从 Win32_Process 里筛出「命令行含这个临时 profile」的 Chrome 进程。
// includeChildren=false 只要**浏览器主进程**(子进程 argv 里有 `--type=`)。
func chromeProcsForProfile(t *testing.T, profile string, includeChildren bool) []realChromeArgs {
t.Helper()
children := "0"
if includeChildren {
children = "1"
}
out := runPowerShell(t, "chrome-procs", chromeProcsPS, "-Profile", profile, "-IncludeChildren", children)
procs := make([]realChromeArgs, 0, 1)
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
pidStr, cmdline, ok := strings.Cut(line, "|")
if !ok {
continue
}
pid, err := strconv.Atoi(strings.TrimSpace(pidStr))
if err != nil {
continue
}
procs = append(procs, realChromeArgs{PID: pid, CommandLine: cmdline})
}
return procs
}
// realChromeArgs 是从**进程表里读回**的一行进程信息(原话,不做任何改写)。
type realChromeArgs struct {
PID int
CommandLine string
}
// uiaDumpForPID 用 UI Automation 把这个 PID 主窗口的控件树文本 dump 出来(07 §三 的手法)。
func uiaDumpForPID(t *testing.T, pid int) string {
t.Helper()
outPath := filepath.Join(t.TempDir(), "uia.txt")
runPowerShell(t, "uia-dump", uiaDumpPS, "-BrowserPid", strconv.Itoa(pid), "-OutPath", outPath)
raw, err := os.ReadFile(outPath)
if err != nil {
t.Fatalf("读 UIA 文本 %s: %v", outPath, err)
}
return string(raw)
}
// uiaDumpWhenReady 等窗口画出来再 dump(有界重试):窗口还没出现不是「没有横幅」的证据,
// 因此读不到节点就继续等,等不到就判红(positive control)。
func uiaDumpWhenReady(t *testing.T, pid int) string {
t.Helper()
var dump string
for attempt := 0; attempt < 20; attempt++ {
dump = uiaDumpForPID(t, pid)
if countNonEmptyLines(dump) >= realUIAminNodes {
return dump
}
time.Sleep(500 * time.Millisecond)
}
t.Fatalf("UI Automation 读不到足够的控件节点(最后一次读到 %d 个命名节点,内容见下),"+
"因此不能说「没有横幅」:\n%s", countNonEmptyLines(dump), dump)
return ""
}
// killChromeForProfile 按**唯一临时 profile 路径**筛 PID 并强制结束,返回剩下的个数。
// 绝不按进程名杀;也绝不碰命令行里不含这个 profile 的进程。
func killChromeForProfile(t *testing.T, profile string) int {
t.Helper()
runPowerShell(t, "chrome-kill", killChromePS, "-Profile", profile)
time.Sleep(500 * time.Millisecond)
return len(chromeProcsForProfile(t, profile, true))
}
// countNonEmptyLines 数文本里的非空行(UIA 控件的 positive control 用)。
func countNonEmptyLines(s string) int {
n := 0
for _, line := range strings.Split(s, "\n") {
if strings.TrimSpace(line) != "" {
n++
}
}
return n
}