From 6fe68ecfac986166dc726a350a8fe102bfd0e23c Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 7 Oct 2026 11:18:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(browser):=20=E6=96=B9=E6=A1=88=20A=20?= =?UTF-8?q?=E8=90=BD=E5=9C=B0=20=E2=80=94=E2=80=94=20=E6=98=BE=E5=BC=8F?= =?UTF-8?q?=E7=AB=AF=E5=8F=A3=20+=20=E5=8E=BB=E6=8E=89=20stealth=20flag?= =?UTF-8?q?=EF=BC=88=E4=B8=BB=E4=BA=BA=202026-10-07=20=E6=8B=8D=E6=9D=BF?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 依据 `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` 取号后立刻关闭,**本进程不常驻监听**)、写/读/清**我们自己的记录** `/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 整链路(未碰)。 --- cmd/cyrene/wire_browser_lifecycle_test.go | 26 +- internal/capability/browser/adopt.go | 129 +++--- internal/capability/browser/adopt_test.go | 132 ++++-- internal/capability/browser/compare_test.go | 2 +- internal/capability/browser/doc.go | 9 +- .../capability/browser/fakebrowser_test.go | 141 +++++- internal/capability/browser/launch.go | 223 ++++++---- internal/capability/browser/launch_test.go | 288 +++++++------ .../capability/browser/launchfailure_test.go | 109 +++++ internal/capability/browser/portrecord.go | 176 ++++++++ .../capability/browser/realchrome_test.go | 401 ++++++++++++++++++ 11 files changed, 1316 insertions(+), 320 deletions(-) create mode 100644 internal/capability/browser/launchfailure_test.go create mode 100644 internal/capability/browser/portrecord.go create mode 100644 internal/capability/browser/realchrome_test.go diff --git a/cmd/cyrene/wire_browser_lifecycle_test.go b/cmd/cyrene/wire_browser_lifecycle_test.go index 7335238..fbfdfd9 100644 --- a/cmd/cyrene/wire_browser_lifecycle_test.go +++ b/cmd/cyrene/wire_browser_lifecycle_test.go @@ -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} diff --git a/internal/capability/browser/adopt.go b/internal/capability/browser/adopt.go index e73f90b..48f60fd 100644 --- a/internal/capability/browser/adopt.go +++ b/internal/capability/browser/adopt.go @@ -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 上已在 diff --git a/internal/capability/browser/adopt_test.go b/internal/capability/browser/adopt_test.go index 715ed6d..fcf75da 100644 --- a/internal/capability/browser/adopt_test.go +++ b/internal/capability/browser/adopt_test.go @@ -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) } diff --git a/internal/capability/browser/compare_test.go b/internal/capability/browser/compare_test.go index f8cdd29..1825f1e 100644 --- a/internal/capability/browser/compare_test.go +++ b/internal/capability/browser/compare_test.go @@ -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, diff --git a/internal/capability/browser/doc.go b/internal/capability/browser/doc.go index 7dfaf64..a68ce64 100644 --- a/internal/capability/browser/doc.go +++ b/internal/capability/browser/doc.go @@ -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=`,端口号由 `listen(:0)` 借内核挑一个空闲号 +// 后立刻放掉,并记在 `/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)。 diff --git a/internal/capability/browser/fakebrowser_test.go b/internal/capability/browser/fakebrowser_test.go index 728791f..61c5116 100644 --- a/internal/capability/browser/fakebrowser_test.go +++ b/internal/capability/browser/fakebrowser_test.go @@ -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("我不是 CDP,只是占着这个端口")) + }) + } 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 { diff --git a/internal/capability/browser/launch.go b/internal/capability/browser/launch.go index 9c9565d..a144cd2 100644 --- a/internal/capability/browser/launch.go +++ b/internal/capability/browser/launch.go @@ -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 时才写 /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, diff --git a/internal/capability/browser/launch_test.go b/internal/capability/browser/launch_test.go index 58144ae..64db913 100644 --- a/internal/capability/browser/launch_test.go +++ b/internal/capability/browser/launch_test.go @@ -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 行上限)。 diff --git a/internal/capability/browser/launchfailure_test.go b/internal/capability/browser/launchfailure_test.go new file mode 100644 index 0000000..ae56424 --- /dev/null +++ b/internal/capability/browser/launchfailure_test.go @@ -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 + }) + } +} diff --git a/internal/capability/browser/portrecord.go b/internal/capability/browser/portrecord.go new file mode 100644 index 0000000..68efd09 --- /dev/null +++ b/internal/capability/browser/portrecord.go @@ -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` 时**才写 `/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 +} diff --git a/internal/capability/browser/realchrome_test.go b/internal/capability/browser/realchrome_test.go new file mode 100644 index 0000000..9b79bbf --- /dev/null +++ b/internal/capability/browser/realchrome_test.go @@ -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 +}