preview() 出镜前过 trace.RedactTextLimited(600 rune),覆盖失败日志的 chrome_output 与 withChromeOutput 追加给调用方的错误串;补凭据形状/原话可见/有界三条测试,删实现可打红。
158 lines
8.5 KiB
Go
158 lines
8.5 KiB
Go
package browser
|
||
|
||
// 本文件是启动路径的**前置判定**:起进程之前先看「这个 profile 上是不是已经有一个浏览器」。
|
||
// 现场(主人实测复现):同一个 profile 上再起一次 Chrome 时,新进程 **exit 0**、不写新的端口
|
||
// 记录,stderr 只有一句「正在现有的浏览器会话中打开。」—— Chromium 把命令**委托给已在跑的
|
||
// 那个实例**后自己退出。于是只按「等新进程诞生」实现的话,这一档永远等不到可用端点:上一次
|
||
// 会话只要没退干净,整个浏览器档就不可用。
|
||
//
|
||
// 三条判定与三条不同的出路(顺序即 Dial 里的调用顺序):
|
||
//
|
||
// ① 端点活着(端口在听 + `/json/version` 有合法响应)⇒ **复用它**,不再起第二个进程;
|
||
// ② 端口记录在、但端口没人听 ⇒ 那是**陈记录**,清掉后照常启动;
|
||
// ③ 端口在听却握不上手 ⇒ **如实报错**(带处置建议),不起进程 —— 绝不静默忽略。
|
||
//
|
||
// 判定用的端口来自 debugPortCandidates(portrecord.go):我们自己记的 `cyrene-debug-port`
|
||
// 优先,再兼容一眼老版二进制留下的 `DevToolsActivePort`(换二进制时,上一版起的浏览器可能
|
||
// 还在跑 —— 不认它就会把「profile 上已经有我们的浏览器」误报成「profile 被别人占了」)。
|
||
//
|
||
// 红线:这一路只做**出站**连接,不杀任何进程、不发任何信号去抢 profile、不开任何监听端口
|
||
// (AGENTS §9);主人的浏览器可能正开着,我们只能连它或绕开它。
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"net"
|
||
)
|
||
|
||
// probeExistingEndpoint 探「这个 profile 上记的调试端点还能不能用」——启动路径的第一步。
|
||
// 三条结论各自对应一条**不同于启动**的出路:
|
||
//
|
||
// ("", nil) 没有可复用的端点:一个记录都没有,或记录的端口**已经没人听**(陈记录)
|
||
// ⇒ 调用方清掉陈记录后正常启动(见 clearStalePortRecords);
|
||
// (url, nil) 端口在听、且 `/json/version` 有**合法响应** ⇒ 复用它,不再起第二个进程;
|
||
// ("", err) 端口在听、但握手失败 ⇒ **如实报错**(launchMarkerEndpointBroken),不起进程。
|
||
//
|
||
// 为什么先独立做一次 TCP 探活(而不是把 HTTP 探活的失败一分为二):两条出路必须分开 ——
|
||
// 「没人听」是**陈记录**(可以安全清掉后照常启动),「在听却不答 CDP」是**占用/异常**(只能
|
||
// 如实报)。混成一句「探测失败 ⇒ 直接启动」,就会在真被占用时把一个必然「委托出去就退出」的
|
||
// 空壳进程当成本次启动,把原因退化成一句查不明白的「启动失败」。
|
||
//
|
||
// 探测超时给得很短(endpointProbeTimeout):这是「要不要复用」的判断,不该拖住启动路径。
|
||
func (l *localChrome) probeExistingEndpoint(ctx context.Context) (string, error) {
|
||
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)
|
||
}
|
||
if brokenCause != nil {
|
||
return "", fmt.Errorf("%w: %s:%s 在听,但 /json/version 握手失败:%v。%s",
|
||
ErrLaunch, launchMarkerEndpointBroken, endpointURL(brokenPort), brokenCause, endpointBrokenHint(l.profileDir))
|
||
}
|
||
return "", nil
|
||
}
|
||
|
||
// 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: endpointProbeTimeout}
|
||
conn, err := d.DialContext(ctx, "tcp", loopbackAddr(port))
|
||
if err != nil {
|
||
return false
|
||
}
|
||
_ = conn.Close()
|
||
return true
|
||
}
|
||
|
||
// withChromeOutput 把浏览器自己说的话附进**返回给调用方**的错误里。日志里本来就有它,但用户
|
||
// 侧看到的工具结果(`browserError`)来自这条错误 —— 不上报原话,用户就只看到我们这一侧的猜测
|
||
// (真机踩过:窗口上跳出浏览器的抱怨,日志里像浏览器坏了,工具结果里什么都没有)。
|
||
// 没有输出时也如实点明,那本身是线索(参数被拒之类通常会带话)。
|
||
//
|
||
// 拼接后整条都是**调用方可见**的,因此两半各自的出镜口径必须交代清楚:
|
||
//
|
||
// - `preview` 是浏览器可控的自由文本 ⇒ 已在 preview() 里过了 chromeOutputPreview
|
||
// (凭据形状打码 + 600 rune 上限)。这里**不再**加一道带上限的守卫:同一份上限套在
|
||
// 整条上,会把下面这条错误自己的**可操作**建议截掉(`profileBusyHint` 里带 profileDir
|
||
// 路径,adopt_test 钉着它必须可见);
|
||
// - `err` 那一半是我们自己拼的(ErrLaunch 分类标记、endpointURL 回环地址、两条 hint 里
|
||
// 的文件系统路径),**不含对端文本** —— 对端说的话一律经 preview 走,没有第二条入口。
|
||
//
|
||
// 换言之:整条被覆盖,靠的是「对端文本只有一个入口(preview)」这个结构约束,而不是在
|
||
// 拼接处再洗一遍。
|
||
func withChromeOutput(err error, preview string) error {
|
||
if preview == "" {
|
||
return fmt.Errorf("%w;浏览器没有输出任何内容", err)
|
||
}
|
||
return fmt.Errorf("%w;浏览器输出:%s", err, preview)
|
||
}
|
||
|
||
// endpointBrokenHint 是「端口在听、却握不上手」的**可操作**处置建议。它只给**不动别人进程**的
|
||
// 出路(红线:占着这个 profile 的浏览器可能是主人在用的那个,我们不杀、也不发任何信号去抢)。
|
||
func endpointBrokenHint(profileDir string) string {
|
||
return fmt.Sprintf("处置:先确认是谁占着这个端口(本代理不会、也不该杀任何进程);"+
|
||
"确认它不是本 profile 的浏览器调试端点后,删掉 %s 再重试,或把 profileDir 指到另一个目录。",
|
||
portRecordPath(profileDir))
|
||
}
|
||
|
||
// profileBusyHint 是「新进程立刻退出」的处置建议:Chromium 把命令委托给了同一个 profile 上已在
|
||
// 跑的实例,然后自己退出 —— 给出路,但不碰任何不属于我们的进程。
|
||
func profileBusyHint(profileDir string) string {
|
||
return fmt.Sprintf("多半是该 profile(%s)已被另一个浏览器实例占用。"+
|
||
"处置:若那个实例是上一次会话留下的**我们的**浏览器、且调试端点还在,它在启动前就已被复用;"+
|
||
"走到这里说明它没有可用的调试端点(例如是被人手动用这个 profile 起的)。"+
|
||
"请先关掉那个浏览器窗口再重试,或把 profileDir 指到另一个目录 —— 本代理绝不杀别人的浏览器。",
|
||
profileDir)
|
||
}
|