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 整链路(未碰)。
This commit is contained in:
1 parent
74e171bc04
commit
6fe68ecfac
11 files changed
+1316
-320
No files matched your search
@@ -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 时才写 <profile>/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,
|
||||
|
||||
Reference in new issue
Block a user