依据 `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 整链路(未碰)。
177 lines
8.9 KiB
Go
177 lines
8.9 KiB
Go
package browser
|
||
|
||
// 本文件是「调试端口的来源与落点」这一件事的**唯一出处**:取一个空闲端口、记在 profile
|
||
// 目录里、需要时读回来、陈记录清掉。
|
||
//
|
||
// 为什么改成自己记端口(DevDocs/reference/chromium-cdp/00 的「方案 A」,主人 2026-10-07 拍板):
|
||
//
|
||
// - Chromium 把 `--remote-debugging-port=0` 当作 **ChromeDriver 式自动化启动**,会主动打开
|
||
// Blink 的 `AutomationControlled` ⇒ `navigator.webdriver === true`(官方
|
||
// content/child/runtime_features.cc:389-404;本机实测见 07 的 g1 vs g7);**显式端口**
|
||
// 不会被这样处理(官方注释原文:调用方给具体端口时「更可能是挂调试器」,故不动该特性);
|
||
// - 我们因此**不再传** `--disable-blink-features=AutomationControlled`:它在显式端口下已是
|
||
// 空操作,却仍是整条命令行里唯一命中官方 `kBadFlags` 的参数 ⇒ 每次有头启动都弹一条
|
||
// 「不受支持的命令行标记」信息栏(01 的源码 + 07 的 g2/g7 实测)。去掉它,webdriver
|
||
// **本来就是 false**,横幅也随之消失;
|
||
// - 代价:Chromium **只在 `port==0` 时**才写 `<profile>/DevToolsActivePort`
|
||
// (官方 chrome/browser/devtools/remote_debugging_server.cc:363-395;实测 03 §3.3)——
|
||
// 改显式端口后那个文件不再出现,端口只能由**我们自己**记。
|
||
//
|
||
// 落点选 **profile 目录**里的 `cyrene-debug-port`(我们自有的记录:Chrome 既不读也不写它),
|
||
// 而不是 `$DATA_DIR/browser/` 下另开一份状态文件:
|
||
//
|
||
// - 它天然按 profile 隔离 —— 认领判定(adopt.go 的 probeExistingEndpoint)只需看一个目录,
|
||
// 不必再维护「profile → 记录」的映射与它们的失效时机;
|
||
// - 与 Chrome 自己的 `DevToolsActivePort` 同目录,两者可以并列读(debugPortCandidates);
|
||
// - profile 被删 / 被换 / 被搬走时记录随之消失,不会留下一条指向别人的陈端口。
|
||
//
|
||
// 记录内容只有一行端口号,**不含任何凭据**(profile 里的 cookie / 登录态在别的文件里)。
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"net"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
)
|
||
|
||
const (
|
||
// portRecordFile 是我们自己的调试端口记录(首行端口号)。
|
||
portRecordFile = "cyrene-debug-port"
|
||
|
||
// legacyDevToolsPortFile 是 Chromium 在 `--remote-debugging-port=0` 时写的那个文件
|
||
// (reference/chromium-cdp/03 §3.2:首行端口、次行 browser_guid)。改用显式端口后我们
|
||
// **不再依赖它**,但仍然读一眼 —— 换二进制时上一版(port=0)起的浏览器可能还在跑:
|
||
// 不认它就会把「profile 上已经有我们的浏览器」误报成「profile 被别人占了」。
|
||
legacyDevToolsPortFile = "DevToolsActivePort"
|
||
|
||
// debugPortPickAttempts 是「取一个空闲端口」的尝试次数。做法:`listen(:0)` 让内核分配一个
|
||
// 空闲回环端口、随即关闭,把号交给 Chromium 去 bind —— 这中间存在极小的 TOCTOU 抢占窗口
|
||
// (AGENTS §9:我们自己**不开**监听端口,这里只是「借内核挑个号」再立刻放掉),抢不到就再取。
|
||
debugPortPickAttempts = 5
|
||
|
||
// launchPortAttempts 是本机启动的尝试次数:1 次正常 + 1 次**换端口**重试。重试只发生在
|
||
// 有证据表明「我们选的端口被别人占了」时(见 portTakenError);其余失败一律如实上报 ——
|
||
// 无差别重试只会把失败原因糊掉,还把预算烧在重复等待上。
|
||
launchPortAttempts = 2
|
||
)
|
||
|
||
// endpointURL 把端口号变成 CDP HTTP 端点的地址(127.0.0.1:调试端点在回环上,我们出站连它)。
|
||
func endpointURL(port int) string {
|
||
return "http://" + loopbackAddr(port)
|
||
}
|
||
|
||
// loopbackAddr 拼一个回环地址(探查、记录、启停都用同一处拼法,避免两处写法漂移)。
|
||
func loopbackAddr(port int) string {
|
||
return net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
|
||
}
|
||
|
||
// pickDebugPort 取一个**当前空闲**的回环端口号交给 Chromium:listen(:0) 由内核分配、立刻
|
||
// 关闭(本进程不再持有它,AGENTS §9 的「不常驻监听端口」因此成立)。关掉之后再到手之间
|
||
// 有极小的被抢窗口,故取到一个「此刻确实没人听」的号才返回,最多试 debugPortPickAttempts 次。
|
||
func pickDebugPort(ctx context.Context) (int, error) {
|
||
for attempt := 0; attempt < debugPortPickAttempts; attempt++ {
|
||
ln, err := net.Listen("tcp", loopbackAddr(0))
|
||
if err != nil {
|
||
return 0, fmt.Errorf("listen(:0) 取空闲端口: %w", err)
|
||
}
|
||
port := ln.Addr().(*net.TCPAddr).Port
|
||
if err := ln.Close(); err != nil {
|
||
continue
|
||
}
|
||
if !portListening(ctx, port) {
|
||
return port, nil
|
||
}
|
||
}
|
||
return 0, fmt.Errorf("连续 %d 次取到的空闲端口都已被占用", debugPortPickAttempts)
|
||
}
|
||
|
||
// portRecordPath 返回这个 profile 的端口记录文件的路径。
|
||
func portRecordPath(profileDir string) string {
|
||
return filepath.Join(profileDir, portRecordFile)
|
||
}
|
||
|
||
// writePortRecord 记下**本次实际使用的**调试端口。写失败不算成功的一半:记不下端口就等于
|
||
// 下一次启动无法复用(会去起第二个进程、然后被 Chromium 委托出去),因此调用方必须把它当
|
||
// 硬失败(见 launchWithRetry)。
|
||
func writePortRecord(profileDir string, port int) error {
|
||
path := portRecordPath(profileDir)
|
||
if err := os.WriteFile(path, []byte(strconv.Itoa(port)+"\n"), 0o600); err != nil {
|
||
return fmt.Errorf("写调试端口记录 %s: %w", path, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// readPortRecord 读回我们自己记的端口;文件不在 / 内容不合法都返回 false(交给调用方当
|
||
// 「没有可复用的记录」处理,而不是把「正在写」误判成「配错」)。
|
||
func readPortRecord(profileDir string) (int, bool) {
|
||
return readPortNumberFile(portRecordPath(profileDir))
|
||
}
|
||
|
||
// DebugEndpoint 报告这个 profile 上**已知**的调试端点(我们自己记的端口记录,见本文件头)。
|
||
// 它只读文件、不探活、不启动任何进程,供排障与端到端用例取「这一次会话用的是哪个端点」。
|
||
// 返回 ("", false) 表示没有记录或记录不合法 —— **不猜**端口。
|
||
func DebugEndpoint(profileDir string) (string, bool) {
|
||
port, ok := readPortRecord(profileDir)
|
||
if !ok {
|
||
return "", false
|
||
}
|
||
return endpointURL(port), true
|
||
}
|
||
|
||
// debugPortCandidates 返回「这个 profile 上可能有 CDP 的端口」候选,按可信度排序:先我们
|
||
// 自己的记录,再那一眼兼容的老文件(legacyDevToolsPortFile),去重。
|
||
func debugPortCandidates(profileDir string) []int {
|
||
var out []int
|
||
if port, ok := readPortRecord(profileDir); ok {
|
||
out = append(out, port)
|
||
}
|
||
legacy := filepath.Join(profileDir, legacyDevToolsPortFile)
|
||
if port, ok := readPortNumberFile(legacy); ok && (len(out) == 0 || out[0] != port) {
|
||
out = append(out, port)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// readPortNumberFile 读一个「首行是端口号」的文件;文件不在 / 还没写完 / 内容不合法都返回
|
||
// false。两种文件都套这个格式:我们自己的 portRecordFile(一行端口)与 Chrome 的
|
||
// legacyDevToolsPortFile(首行端口、次行 browser_guid)。
|
||
func readPortNumberFile(path string) (int, bool) {
|
||
raw, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return 0, false
|
||
}
|
||
line, _, _ := strings.Cut(strings.TrimSpace(string(raw)), "\n")
|
||
port, err := strconv.Atoi(strings.TrimSpace(line))
|
||
if err != nil || port <= 0 || port > 65535 {
|
||
return 0, false
|
||
}
|
||
return port, true
|
||
}
|
||
|
||
// clearStalePortRecords 清掉**上一轮**留下的端口记录(两种都清)—— 只有确认「候选端口都已经
|
||
// 没人听」才会走到这里(见 Dial 的调用顺序)。
|
||
//
|
||
// 为什么必须清:这两条记录是「这个 profile 上有一个 CDP 端点」的**唯一线索**,上一轮的端口
|
||
// 留着只会让下一次启动的认领判定去探一个死端口。
|
||
//
|
||
// 为什么可以清:它们只是浏览器启动时写下的**一条记录**,不是锁。真正在跑、真正拥有这个
|
||
// profile 的实例一定有一个**活着的**端口,那种情况上一步(probeExistingEndpoint)已经把它
|
||
// 复用走了。因此删掉指向死端口的记录,不可能从任何活着的实例手里拿走什么 —— 它影响的只有
|
||
// 我们自己的认领判定。
|
||
//
|
||
// 文件本来就不在(ErrNotExist)不算失败;别的错误(被占用 / 权限)**如实报**并且**不继续
|
||
// 启动** —— 留着一条陈记录继续启动,只会把陈端口读成本次可复用的端点。
|
||
func (l *localChrome) clearStalePortRecords() error {
|
||
for _, path := range []string{portRecordPath(l.profileDir), filepath.Join(l.profileDir, legacyDevToolsPortFile)} {
|
||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||
return fmt.Errorf("%w: 清掉上一轮的端口记录 %s 失败: %v(留着它会把陈端口当成本次可复用的端点)",
|
||
ErrLaunch, path, err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|