Files
Cyrene_v2/internal/capability/browser/adopt.go
T
AskaEth 7f63c5bec4
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(browser): 同 profile 的浏览器还活着时不再必然启动超时
真机复现(我做的):同 profile 再起一次 chrome.exe ⇒ Chrome **把命令委托给已有
实例并 exit 0**,stderr 逐字「正在现有的浏览器会话中打开。」⇒ **不产生新的
DevToolsActivePort** ⇒ 我们的等待只能超时 ⇒ 浏览器降级档整体不可用。

修法:启动前加一跳**探测**,三档各自出路 ——
- 端口记录缺席 / 端口没人听 ⇒ 正常启动(后者先把**陈记录**清掉:那是启动时写下
  的记录、不是锁,删一条指向死端口的记录不会伤到任何活实例);
- 端口在听 + `/json/version` 有合法响应 ⇒ **复用它**(INFO `browser debug
  endpoint reused`),**不再起第二个**;
- 端口在听但握不上手 ⇒ **如实报错**(保留现场,既不起进程也不删记录);
- 真被占用且拿不到端点 ⇒ 报错带 **Chrome 原话**(`withChromeOutput`)+ 可操作处置。

🔴 红线(写进错误文案与注释):**绝不杀别人的浏览器**、不发信号抢 profile、
不新开监听端口;`ErrLaunch` / `errors.Is` 口径未破。

验收(主会话复跑 + 亲自变异):browser 包与 plugins/tools/io 两包 build/vet/test
全绿;我用 -overlay 摘掉「复用」那一支 ⇒ TestLocalChromeAdoptsLiveEndpoint 与
TestDial_ReusesLiveEndpointAndDoesNotSpawnSecondProcess 变红。子代理另跑了真机
端到端(临时 profile):会话 1 拿端口 51732 → 裸起第二次 chrome(exit 0)→
会话 2 **复用同一端口、未起第二个实例**。

⚠️ 已知未修(如实):Chrome 那句委托提示是**控制台代码页(GBK)字节**,我们的
UTF-8 日志里会显示成乱码(字节保真、人看不了);彻底解决要引编码转换,另定。
⚠️ 判据③的取舍:陈记录 +端口恰被别的程序复用时,会持续报错直到人工删
`DevToolsActivePort`(错误文案已写明处置);**未加"报错后自动清理重试"**。
2026-10-07 10:56:16 +08:00

131 lines
7.4 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**、不写新的
// DevToolsActivePort,stderr 只有一句「正在现有的浏览器会话中打开。」—— Chromium 把命令
// **委托给已在跑的那个实例**后自己退出。于是只按「等端口文件出现」实现的话,这一档永远等不到
// 端口:上一次会话只要没退干净,整个浏览器档就不可用。
//
// 三条判定与三条不同的出路(顺序即 Dial 里的调用顺序):
//
// ① 端点活着(端口在听 + `/json/version` 有合法响应)⇒ **复用它**,不再起第二个进程;
// ② 端口记录在、但端口没人听 ⇒ 那是**陈记录**,清掉后照常启动;
// ③ 端口在听却握不上手 ⇒ **如实报错**(带处置建议),不起进程 —— 绝不静默忽略。
//
// 红线:这一路只做**出站**连接,不杀任何进程、不发任何信号去抢 profile、不开任何监听端口
// (AGENTS §9);主人的浏览器可能正开着,我们只能连它或绕开它。
import (
"context"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"strconv"
)
// probeExistingEndpoint 探「profile 目录里记的调试端点还能不能用」——启动路径的第一步。
// 三条结论各自对应一条**不同于启动**的出路:
//
// ("", nil) 没有可复用的端点:端口记录缺席,或记的端口**已经没人听**(陈文件)
// ⇒ 调用方清掉陈记录后正常启动(见 clearStalePortFile);
// (url, nil) 端口在听、且 `/json/version` 有**合法响应** ⇒ 复用它,不再起第二个进程;
// ("", err) 端口在听、但握手失败 ⇒ **如实报错**(launchMarkerEndpointBroken),不起进程。
//
// 为什么先独立做一次 TCP 探活(而不是把 HTTP 探活的失败一分为二):两条出路必须分开 ——
// 「没人听」是**陈文件**(可以安全清掉后照常启动),「在听却不答 CDP」是**占用/异常**(只能
// 如实报)。混成一句「探测失败 ⇒ 直接启动」,就会在真被占用时把一个必然「委托出去就退出」的
// 空壳进程当成本次启动,把原因退化成一句查不明白的「启动失败」。
//
// 探测超时给得很短(adoptProbeTimeout):这是「要不要复用」的判断,不该拖住启动路径。
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)")
}
if ctx.Err() != nil {
// 探测期间调用方的预算先没了:这是「没时间了」,**不**说端点坏了。
return "", budgetLaunchErr(ctx)
}
return "", fmt.Errorf("%w: %s:%s 在听,但 /json/version 握手失败:%v。%s",
ErrLaunch, launchMarkerEndpointBroken, endpoint, handshakeErr, endpointBrokenHint(l.profileDir))
}
// portListening 报告 127.0.0.1:port 上有没有人在听(这一次探测的连接立刻关掉)。它只做
// **出站**连接,不开任何监听端口(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)))
if err != nil {
return false
}
_ = conn.Close()
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`)来自这条错误 —— 不上报原话,用户就只看到我们这一侧的猜测
// (真机踩过:窗口上跳出浏览器的抱怨,日志里像浏览器坏了,工具结果里什么都没有)。
// 没有输出时也如实点明,那本身是线索(参数被拒之类通常会带话)。
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 指到另一个目录。",
filepath.Join(profileDir, devToolsPortFile))
}
// profileBusyHint 是「新进程立刻退出」的处置建议:Chromium 把命令委托给了同一个 profile 上已在
// 跑的实例,然后自己退出 —— 给出路,但不碰任何不属于我们的进程。
func profileBusyHint(profileDir string) string {
return fmt.Sprintf("多半是该 profile(%s)已被另一个浏览器实例占用。"+
"处置:若那个实例是上一次会话留下的**我们的**浏览器、且调试端点还在,它在启动前就已被复用;"+
"走到这里说明它没有可用的调试端点(例如是被人手动用这个 profile 起的)。"+
"请先关掉那个浏览器窗口再重试,或把 profileDir 指到另一个目录 —— 本代理绝不杀别人的浏览器。",
profileDir)
}