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 }