Files
AskaEth 6fe68ecfac
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(browser): 方案 A 落地 —— 显式端口 + 去掉 stealth flag(主人 2026-10-07 拍板)
依据 `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 整链路(未碰)。
2026-10-07 11:18:25 +08:00

348 lines
15 KiB
Go
Raw Permalink 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 目录追一行(`fakeLaunches`),可以直接
// 数出「起了几次」。用例因此是绿的 ⇒ 启动路径没被走到。(变异自证:把复用那一支去掉 ⇒
// 判据①立刻变红。)
//
// 现场依据(主人实测复现):同一个 profile 上再起一次 Chrome 时,新进程 exit 0、不写新的端口
// 记录,stderr 逐字只有一句「正在现有的浏览器会话中打开。」—— Chromium 把命令委托给了已在跑
// 的实例。不做复用,这一档就一直报启动失败,直到那个进程退出。
import (
"context"
"errors"
"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
}
// 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()
if err := writePortRecord(dir, port); err != nil {
t.Fatalf("写端口记录: %v", err)
}
}
// 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 里记的调试端点
// 在听、`/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)
writePortRecordFile(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)
}
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 必须报「等端口就绪超时」—— 而不是把陈端口当成
// 本次结果稀里糊涂地成功(那正是「不清陈记录」会造成的伪装)。
func TestDial_ClearsStalePortFileThenLaunches(t *testing.T) {
t.Parallel()
t.Run("以新进程实际服务的端口为准", func(t *testing.T) {
t.Parallel()
log, _ := newCapturedLog(slog.LevelDebug)
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()
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)
}
})
t.Run("陈端口不会被当成本次结果", func(t *testing.T) {
t.Parallel()
log, buf := newCapturedLog(slog.LevelDebug)
l := newFakeChrome(t, fakeBrowserScript{
Stderr: []string{"起来了,但从不答 CDP"}, SleepMS: 3000,
}, 300*time.Millisecond, log)
stale := deadPort(t)
writePortRecordFile(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("应当读成「等调试端口就绪超时」: %v", err)
}
if endpoint == endpointURL(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)
writePortRecordFile(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{"处置", portRecordFile, 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)
}
}
}