Files
Cyrene_v2/internal/capability/browser/adopt.go
T
AskaEth b040f588b2 fix(browser): M53 追加 Chrome 输出预览打码
preview() 出镜前过 trace.RedactTextLimited(600 rune),覆盖失败日志的 chrome_output 与 withChromeOutput 追加给调用方的错误串;补凭据形状/原话可见/有界三条测试,删实现可打红。
2026-10-26 22:19:31 +08:00

158 lines
8.5 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
// 本文件是启动路径的**前置判定**:起进程之前先看「这个 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)
}