真机复现(我做的):同 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`(错误文案已写明处置);**未加"报错后自动清理重试"**。
131 lines
7.4 KiB
Go
131 lines
7.4 KiB
Go
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)
|
||
}
|