Files
Cyrene_v2/internal/capability/browser/launch.go
T
AskaEth 6fe68ecfac
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(browser): 方案 A 落地 —— 显式端口 + 去掉 stealth flag(主人 2026-10-07 拍板)
依据 `DevDocs/reference/chromium-cdp/`(9 份实测+官方源码):旧姿势传 `--remote-debugging-port=0`
会被 Chromium 当成 ChromeDriver 式自动化启动 ⇒ 主动打开 `AutomationControlled`
⇒ `navigator.webdriver=true`;那个 bad-flag 是承重覆盖,但代价是**每次有头弹横幅**。
改用显式端口后 `webdriver` **本来就是 false** ⇒ flag 变纯冗余 ⇒ 去掉它,**两者兼得**。

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

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

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

⚠️ 未验(如实):无头档的横幅(无窗口判不了)、`chrome-headless-shell`、风控侧是否把
"端口写法"本身当信号、生产 profile 整链路(未碰)。
2026-10-07 11:18:25 +08:00

448 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package browser
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"os/exec"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
)
// 启动参数与就绪等待的具名常量(AGENTS §10:不出现裸魔法数字)。
const (
// portPollInterval 是「等调试端点就绪」的轮询间隔:冷启动通常 0.3~1.5s,
// 100ms 的粒度既不迟钝也不白烧 CPU。
portPollInterval = 100 * time.Millisecond
// defaultLang 是启动时声明的 UI 语言。口径:与「本机 / 出口 IP 所在地」对齐
// (design/28 §2 指纹一致性);写死 zh-CN 是本部署的实情,配置面(browser.json)
// 尚未开放语言项 —— 见本片报告「未做」。
defaultLang = "zh-CN"
)
// endpointProbeTimeout 是一次端点探活的超时:「要不要复用已在跑的实例」与「本次启动就绪了
// 没有」两处共用同一档。它先问「端口有没有在听」,再问 `/json/version` 答不答话,两问都给得
// 短 —— 前者是启动前的判定,后者是就绪轮询里的一次尝试,都不该拖住启动路径。
const endpointProbeTimeout = 2 * time.Second
// 启动失败的**分类标记**:几种情形必须在错误消息里一眼可分(design/28 §7 判据 4
// 「如实标注」/ §6 坑 5「失败要如实报」)——
//
// ① 浏览器进程自己退了(参数被拒 / 委托给已跑实例 / profile 锁被占);
// ② 我们自己的等待窗口用完(**我们选的那个**调试端口一直没答上 CDP);
// ③ **调用方的预算先没了**:浏览器没坏,只是没时间了;
// ④ 端口在听、但不是可用的 CDP 端点(握手失败)—— 它既不是「进程退出」也不是「端口没出现」,
// 必须单独说,因为它有**自己的**处置方式(见 endpointBrokenHint)。这一条有三个入口:
// 启动**前**探到旧记录指向坏端点、启动**中**发现选的端口被别人占了、以及会话就绪后的
// 复用判定(都在本包内共用同一个标记)。
//
// 哨兵仍然只有 ErrLaunch 一个(`errors.Is` 口径不变),这些标记是消息里可机读的那一段。
const (
// launchMarkerProcessExited 标记 ①。
launchMarkerProcessExited = "浏览器进程立刻退出"
// launchMarkerPortTimeout 标记 ②。
launchMarkerPortTimeout = "等调试端口就绪超时"
// launchMarkerBudget 标记 ③。
launchMarkerBudget = "调用方预算耗尽"
// launchMarkerEndpointBroken 标记 ④:端口在听却握不上手。它与「陈记录」(端口没人听)
// 是**两回事**:陈记录可以安全清掉后照常启动,这里只能如实报 —— 静默忽略它去另起一个
// 进程,换来的只是「委托给已跑实例后立刻退出」,把原因糊掉。
launchMarkerEndpointBroken = "调试端口在听但不是可用的 CDP 端点"
)
const (
// chromeOutputDrainWait 是「进程退出/被杀之后,把它已经写进管道的输出收干净」的
// 等待上限:诊断不该反过来拖住失败路径。
chromeOutputDrainWait = 500 * time.Millisecond
// launchDeadlineSlack 容忍「调用方的截止时间与我们这一次等待的截止时间几乎同时」
// 这种不可分情形:差值在它之内时按**我们自己等端口超时**报(此刻的事实正是这一次
// 等待用满了配置的 timeout,真机常态如此:外层 openSession 与本次等待同源同值)。
launchDeadlineSlack = 250 * time.Millisecond
)
// dialer 产出一个可用的 CDP HTTP 端点(`http://host:port`)与停止函数。它把
// 「启动本机浏览器」与「连接已跑端点」两条路统一成同一个会话装配入口。
type dialer interface {
// Dial 返回端点;stop 释放本次会话占用的资源(本机路径 = 杀掉进程,容器路径 = 空操作)。
Dial(ctx context.Context) (endpoint string, stop func(), err error)
// Describe 返回一句人类可读的来源描述(进日志与工具结果,不进提示词模板)。
Describe() string
// Local 报告这个端点是不是**我们自己的本机浏览器**:只有它为 true 时,会话收尾
// 才允许关闭浏览器。容器/手动实例(remoteCDP)是别人的,绝不代关。
Local() bool
}
// localChrome 是主路径:在**宿主真桌面**上起一个真 Chromium(默认有头)。
//
// 启动参数按 `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`、`--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 的默认参数,真浏览器不会那样)、
// 以及一切 `--headless*`(有头就是不能有它)。
//
// 未传 `--remote-debugging-pipe`(reference/10 §2.4 的首选)是**平台限制**而不是取舍:
// 它要求把 CDP 通道给到子进程的 fd 3/4,而 Go 的 `exec.Cmd.ExtraFiles` **在 Windows
// 上不支持**;何况官方源码里 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
headless bool
viewportW int
viewportH int
timeout time.Duration
log *slog.Logger
}
// Describe 实现 dialer。
func (l *localChrome) Describe() string {
mode := "headful"
if l.headless {
mode = "headless"
}
return fmt.Sprintf("本机浏览器 %s(%s)", l.exe, mode)
}
// Local 实现 dialer:本机浏览器归我们管,会话收尾可以(也应该)关掉它。
func (l *localChrome) Local() bool { return true }
// Dial 拉起浏览器并等调试端点就绪。任何一步失败都返回带分类原因的 ErrLaunch。
//
// 四条**先于启动**的判定(前三条都是为了让「上一次会话留下的浏览器」不至于把功能卡死):
//
// 1. **预算已耗尽就不起进程**:ctx 已经结束(这一轮的工具预算用完 / 会话被取消)时
// 立刻返回「预算耗尽」分类,**不 spawn** —— 起了也只是生一个会被马上杀掉的窗口,
// 并把「没时间了」伪装成「浏览器起不来」(真机踩过:窗口上跳出浏览器的抱怨,日志
// 里却像浏览器坏了)。判定做在**启动之前**,因为它要省掉的正是那次启动。
// 2. **能复用就复用**(`probeExistingEndpoint`):这个 profile 上记的调试端口还在
// (`cyrene-debug-port`,或老二进制留下的 `DevToolsActivePort`)、端口**确实在听**、
// `/json/version` 也**真有合法响应** ⇒ 直接出站连它,**不再起第二个**。
// 这不是优化,是**正确性**:同一个 user-data-dir 上起第二个实例时 Chromium 会把手上的
// 命令**委托给已在跑的那个**然后自己退出(真机实测:新进程 exit 0、不写新的端口记录,
// stderr 只有一句「正在现有的浏览器会话中打开。」),于是新进程永远不就绪 ⇒ 不复用的话,
// 这一档会一直报启动失败,直到人工杀掉那个进程。复用还正好贴合 design/28 §2「profile
// 长期保留、任务间复用」。
// 3. **端口在听、却握不上手 ⇒ 如实报错**(`launchMarkerEndpointBroken`):绝不「忽略掉它、
// 自己起一个」—— 占着这个端口的多半就是同一个 profile 的浏览器,起进程只会让新进程委托
// 出去后立刻退出,把一句本就查不明白的失败再糊一层。
// 4. **陈的记录先清掉**(`clearStalePortRecords`):只在真的要走启动路径时才清;不清的话,
// 上一轮的端口会被当成本次可复用的端点(理由见那个方法的注释)。
//
// 起进程与「等它就绪」由 launchWithRetry(含一次换端口重试)与 launchOnce 承担;子进程的
// stdout/stderr **接进缓冲**(`chromeOutput`):启动失败时把浏览器自己的话与本次 argv 一起
// 报进**日志**,并把浏览器原话附进**返回的错误**(`withChromeOutput`)—— 用户侧看到的工具
// 结果就是从这条错误来的。成功时只报字节数(Debug),不把 Chrome 的常规输出刷进日志。
//
// 停止函数只对**本次启动**的进程有效(复用来的实例不归我们杀):进程退出后 Chrome 的
// 渲染进程随之退出,因此进程级 Kill 足够。
func (l *localChrome) Dial(ctx context.Context) (string, func(), error) {
if strings.TrimSpace(l.exe) == "" {
return "", nil, fmt.Errorf("%w: 未指定可执行文件", ErrLaunch)
}
if err := ctx.Err(); err != nil {
return "", nil, budgetLaunchErr(ctx)
}
endpoint, err := l.probeExistingEndpoint(ctx)
if err != nil {
// 复用不了、又不许起第二个:如实报(错误里已带处置建议),一条 WARN 留现场。
l.log.Warn("browser launch blocked by unusable debug endpoint",
"profile", l.profileDir, "error", err)
return "", nil, err
}
if endpoint != "" {
l.log.Info("browser debug endpoint reused",
"endpoint", endpoint,
"profile", l.profileDir,
"detail", "复用已存在的调试端点(同 profile 的浏览器仍在跑,不再起第二个)")
return endpoint, func() {}, nil
}
if err := os.MkdirAll(l.profileDir, 0o700); err != nil {
return "", nil, fmt.Errorf("%w: 建 profile 目录 %s: %v", ErrLaunch, l.profileDir, err)
}
if err := l.clearStalePortRecords(); err != nil {
return "", nil, err
}
return l.launchWithRetry(ctx)
}
// 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
cmd.Stderr = out
if err := cmd.Start(); err != nil {
return "", nil, fmt.Errorf("%w: 启动 %s: %v", ErrLaunch, l.exe, err)
}
exited := make(chan struct{})
go func() {
defer close(exited)
// 用 cmd.Wait(而不是 cmd.Process.Wait):它会等两个输出复制协程收工,
// 于是 exited 关闭时缓冲里就是子进程说过的**全部**话(下面要在失败时上报它)。
_ = cmd.Wait()
}()
endpoint, err := l.waitDebugReady(ctx, port, exited)
if err != nil {
stopProcess(cmd)
l.waitExited(exited)
l.log.Warn("browser launch failed",
"exe", l.exe,
"argv", strings.Join(args, " "),
"chrome_output", out.preview(),
"chrome_output_bytes", out.totalBytes(),
"error", err)
return "", nil, withChromeOutput(err, out.preview())
}
l.log.Debug("browser launched",
"exe", l.exe, "port", port, "profile", l.profileDir, "chrome_output_bytes", out.totalBytes())
return endpoint, func() { stopProcess(cmd) }, nil
}
// waitExited 等子进程真正收尾(管道里的在途字节落进缓冲)再上报:Kill 之后 Wait 还要
// 一点点时间,不等就会漏掉最后几行 —— 而最后几行往往正是原因。等不到就照现有内容报,
// 不把失败路径拖住。
func (l *localChrome) waitExited(exited <-chan struct{}) {
timer := time.NewTimer(chromeOutputDrainWait)
defer timer.Stop()
select {
case <-exited:
case <-timer.C:
}
}
// budgetLaunchErr 是 ③「调用方预算耗尽」的**唯一文案**。它必须读起来像「没时间了」,
// 而不是像「浏览器坏了」:取消与截止时间到分别点明(两者都是调用方自己的事)。
func budgetLaunchErr(ctx context.Context) error {
detail := "上下文截止时间到(这一轮的工具预算已耗尽)"
if errors.Is(ctx.Err(), context.Canceled) {
detail = "上下文被取消(会话/这一轮已结束)"
}
return fmt.Errorf("%w: %s:%s,浏览器未启动完成", ErrLaunch, launchMarkerBudget, detail)
}
// 启动前的三条判定与它们的处置文案在 adopt.go:复用活端点(probeExistingEndpoint)、
// 清掉陈的端口记录(clearStalePortRecords)、端点不可用时的如实上报(withChromeOutput 与两条 hint)。
// launchArgs 组装启动参数(**顺序稳定、可断言**:单元测试钉住白/黑名单)。port 是我们
// **已经选好**的显式调试端口(取号与记录见 portrecord.go)。
func (l *localChrome) launchArgs(port int) []string {
args := []string{
// profile 必传:内含 cookie / 登录态,且是「并发上限 1」的锁载体(design/28 §3)。
"--user-data-dir=" + l.profileDir,
// **显式**端口(不是 `=0`):Chromium 把 `port==0` 当作 ChromeDriver 式自动化启动,
// 会主动打开 Blink 的 AutomationControlled ⇒ `navigator.webdriver === true`(官方
// runtime_features.cc:389-404;实测 reference/chromium-cdp/07 的 g1 vs g7);显式端口
// 不被这样处理,因此这一条同时替掉了原来的
// `--disable-blink-features=AutomationControlled`(那个 flag 在显式端口下是空操作,
// 却会命中官方 kBadFlags、每次有头启动弹一条「不受支持的命令行标记」信息栏)。
// 代价:Chromium 只在 port==0 时才写 <profile>/DevToolsActivePort,端口改由我们自己记。
fmt.Sprintf("--remote-debugging-port=%d", port),
// 非浏览器 WS 客户端在部分 Chrome 版本上会被 DevTools 的 Origin 校验拒绝;
// reference/07 §4 的命令行里同样有它。它只放宽「谁能连这个回环调试端点」,
// 不开任何对外服务面。
"--remote-allow-origins=*",
"--no-first-run",
"--no-default-browser-check",
fmt.Sprintf("--window-size=%d,%d", l.viewportW, l.viewportH),
"--lang=" + defaultLang,
"about:blank",
}
if l.headless {
// 只在面板显式降级时出现;默认档(有头)**绝不传**任何 `--headless*`。
args = append([]string{"--headless=new"}, args...)
}
return args
}
// waitDebugReady 等**我们自己选的那个端口**真的成为可用的 CDP 端点(就绪判据从「端口文件
// 出现」换成了「端点答得上 `/json/version`」—— 显式端口下 Chromium 不再写端口文件,而且
// 这样判别的是**事实**而不是一条可能陈旧的记录)。三条出口都要**说清原因**,而且三者互不
// 冒充(design/28 §7 判据 4「如实标注」):
//
// ① 浏览器进程自己退了(多半是 profile 锁被占 / 浏览器已在运行)——错误里带
// launchMarkerProcessExited **与可操作的处置建议**(profileBusyHint);子进程说的原话
// 由 launchOnce 追加进这条错误(withChromeOutput),用户侧因此看得见 Chromium 那句
// 「正在现有的浏览器会话中打开。」;
// ② 我们自己的等待窗口用完 —— 带 launchMarkerPortTimeout;若这期间那个端口**在听却答不上
// CDP**,说明它被别的进程抢走了(listen(:0) 取号与 Chromium bind 之间的 TOCTOU),
// 改报 launchMarkerEndpointBroken 并交给 launchWithRetry 换端口重试(portTakenError);
// ③ 调用方的预算先没了(ctx 结束)—— 带 launchMarkerBudget,**不**说浏览器坏。
//
// ② 与 ③ 的分辨靠两者的截止时间:ctx 的截止时间**明显早于**我们这一次等待的截止时间
// 时,结束它的是调用方;几乎同时到点时按 ② 报(那是这一次等待真的用满了配置的 timeout,
// 也是真机常态:外层 openSession 的预算与本次等待同源同值)。
//
// 还有一条**不走这里**的出口:启动前探到「端口在听却握不上手」时,Dial 会在起进程之前就
// 报 launchMarkerEndpointBroken(见 adopt.go)—— 那种情形根本不该起这个进程。
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 {
switch state, _ := probeDebugEndpoint(ctx, port); state {
case endpointReady:
return endpointURL(port), nil
case endpointForeign:
foreign = true
case endpointAbsent:
}
select {
case <-exited:
// 浏览器进程自己退了,端点不会再就绪 —— **立刻**报,不把超时白等完。
// 最常见的原因是同一 profile 已被另一个实例占用:Chromium 把命令**委托给已在跑的
// 那个**之后自己退出(真机实测 stderr 原话「正在现有的浏览器会话中打开。」)、新
// 进程不写端口记录。这里给可操作建议,但不碰别人的进程。
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 "", budgetLaunchErr(ctx)
}
return "", l.portTimeoutErr(port, foreign)
case <-deadline.C:
return "", l.portTimeoutErr(port, foreign)
case <-ticker.C:
}
}
}
// portTimeoutErr 是 ②「我们自己的等待窗口用完」的唯一文案(两条出口共用,口径只有一处)。
// 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))
}
// 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,
// 或主人手动起的实例)。它是纯出站客户端,不启动任何进程、不监听任何端口。
type remoteCDP struct{ endpoint string }
// Describe 实现 dialer。
func (r *remoteCDP) Describe() string { return "已跑的 CDP 端点 " + r.endpoint }
// Local 实现 dialer:容器/手动实例**不是我们的**,会话收尾绝不代它关闭。
func (r *remoteCDP) Local() bool { return false }
// Dial 先探活再交出端点;连不上就如实报,不偷偷回退本机(design/28 §6 坑 5)。
func (r *remoteCDP) Dial(ctx context.Context) (string, func(), error) {
endpoint := strings.TrimRight(strings.TrimSpace(r.endpoint), "/")
if endpoint == "" {
return "", nil, fmt.Errorf("%w: CDP 端点为空白", ErrUnavailable)
}
var version struct {
Browser string `json:"Browser"`
}
if err := httpGetJSON(ctx, endpoint+"/json/version", &version); err != nil {
return "", nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
return endpoint, func() {}, nil
}
// stopProcess 结束本进程拉起的浏览器(保留为具名函数:Kill 的语义在这一处写清楚,
// 调用点不必解释;Wait 由 Dial 里的等待协程负责,这里只 Kill,避免双重 Wait)。
// Windows 上渲染进程随浏览器进程一起退,因此进程级 Kill 足够。
func stopProcess(cmd *exec.Cmd) {
if cmd == nil || cmd.Process == nil {
return
}
_ = cmd.Process.Kill()
}
// newDialer 按「配置显式端点 → 配置显式可执行文件 → 本机探测结果」组装 dialer。
// 可用性判定由 browsercfg.Resolve 做(优先级单一出处),这里只把结论映射成具体 dialer。
func newDialer(avail browsercfg.Availability, cfg browsercfg.Config, log *slog.Logger) dialer {
if e := strings.TrimSpace(avail.Endpoint); e != "" {
return &remoteCDP{endpoint: e}
}
return &localChrome{
exe: strings.TrimSpace(avail.Executable),
profileDir: cfg.ProfileDir,
headless: cfg.Headless,
viewportW: cfg.ViewportWidth,
viewportH: cfg.ViewportHeight,
timeout: cfg.Timeout(),
log: log,
}
}