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
@@ -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 上已在
|
||||
|
||||
Reference in new issue
Block a user