fix(browser): 同 profile 的浏览器还活着时不再必然启动超时
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

真机复现(我做的):同 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`(错误文案已写明处置);**未加"报错后自动清理重试"**。
This commit is contained in:
AskaEth committed 2026-10-07 10:56:16 +08:00
1 parent 93c5a4c46f
commit 7f63c5bec4
4 files changed
+470 -43

No files matched your search

+56 -41
View File
@@ -32,16 +32,19 @@ const (
defaultLang = "zh-CN"
)
// adoptProbeTimeout 是「要不要认领已在跑的实例」这一次探测的超时(也是远端端点探活
// 用的那一档):它只问「端口有没有在听」,给得短。
// adoptProbeTimeout 是「要不要复用已在跑的实例」这一次探测的超时(也是远端端点探活
// 用的那一档):它先问「端口有没有在听」,再问 `/json/version` 答不答话,两问都给得短 ——
// 这是启动前的判定,不该拖住启动路径。
const adoptProbeTimeout = 2 * time.Second
// 启动失败的**分类标记**:三种情形必须在错误消息里一眼可分(design/28 §7 判据 4
// 启动失败的**分类标记**:几种情形必须在错误消息里一眼可分(design/28 §7 判据 4
// 「如实标注」/ §6 坑 5「失败要如实报」)——
//
// ① 浏览器进程自己退了(参数被拒 / 委托给已跑实例 / profile 锁被占);
// ② 我们自己的等待窗口用完(DevToolsActivePort 没出现);
// ③ **调用方的预算先没了**:浏览器没坏,只是没时间了。
// ③ **调用方的预算先没了**:浏览器没坏,只是没时间了;
// ④ 上一轮记的调试端口**在听、但不是可用的 CDP 端点**(握手失败)—— 它既不是「进程退出」
// 也不是「端口没出现」,必须单独说,因为它有**自己的**处置方式(见 endpointBrokenHint)。
//
// 哨兵仍然只有 ErrLaunch 一个(`errors.Is` 口径不变),这些标记是消息里可机读的那一段。
const (
@@ -51,6 +54,10 @@ const (
launchMarkerPortTimeout = "等 DevToolsActivePort 超时"
// launchMarkerBudget 标记 ③。
launchMarkerBudget = "调用方预算耗尽"
// launchMarkerEndpointBroken 标记 ④:端口在听却握不上手。它与「陈文件」(端口没人听)
// 是**两回事**:陈文件可以安全清掉后照常启动,这里只能如实报 —— 静默忽略它去另起一个
// 进程,换来的只是「委托给已跑实例后立刻退出」,把原因糊掉。
launchMarkerEndpointBroken = "调试端口在听但不是可用的 CDP 端点"
)
const (
@@ -116,24 +123,30 @@ func (l *localChrome) Local() bool { return true }
// Dial 拉起浏览器并等调试端口就绪。任何一步失败都返回带分类原因的 ErrLaunch。
//
// 三条**先于启动**的判定(前两条是为了让「上一次会话留下的浏览器」不至于把功能卡死):
// 四条**先于启动**的判定(前三条都是为了让「上一次会话留下的浏览器」不至于把功能卡死):
//
// 1. **预算已耗尽就不起进程**:ctx 已经结束(这一轮的工具预算用完 / 会话被取消)时
// 立刻返回「预算耗尽」分类,**不 spawn** —— 起了也只是生一个会被马上杀掉的窗口,
// 并把「没时间了」伪装成「浏览器起不来」(真机踩过:窗口上跳出浏览器的抱怨,日志
// 里却像浏览器坏了)。判定做在**启动之前**,因为它要省掉的正是那次启动。
// 2. **认领已在跑的实例**:profile 目录的 DevToolsActivePort 还活着(端口在听)⇒ 直接
// 出站连接它,**不再起第二个**。这不是优化,是**正确性**:同一个 user-data-dir 上起
// 第二个实例时 Chromium 会「委托给已在跑的那个」然后自己退出,于是新进程永远不写
// DevToolsActivePort ⇒ 功能会一直报启动超时,直到人工杀掉那个进程。认领把这一步
// 变成「复用」,也更贴合 design/28 §2「profile 长期保留、任务间复用」。
// 3. **端口文件先清掉**:只在真的要走启动路径时才清,否则会把旧端口当成本次结果。
// 2. **能复用就复用**(`probeExistingEndpoint`):profile 目录的 DevToolsActivePort 还在、
// 端口**确实在听**、`/json/version` 也**真有合法响应** ⇒ 直接出站连它,**不再起第二个**。
// 这不是优化,是**正确性**:同一个 user-data-dir 上起第二个实例时 Chromium 会把手上的
// 命令**委托给已在跑的那个**然后自己退出(真机实测:新进程 exit 0、不写新的
// DevToolsActivePort,stderr 只有一句「正在现有的浏览器会话中打开。」),于是新进程永远
// 不写端口 ⇒ 不复用的话,这一档会一直报启动失败,直到人工杀掉那个进程。复用还正好贴合
// design/28 §2「profile 长期保留、任务间复用」。
// 3. **端口在听、却握不上手 ⇒ 如实报错**(`launchMarkerEndpointBroken`):绝不「忽略掉它、
// 自己起一个」—— 占着这个端口的多半就是同一个 profile 的浏览器,起进程只会让新进程委托
// 出去后立刻退出,把一句本就查不明白的失败再糊一层。
// 4. **陈的端口记录先清掉**(`clearStalePortFile`):只在真的要走启动路径时才清;不清的话,
// 上一轮的端口会被当成本次结果(理由见那个方法的注释)。
//
// 子进程的 stdout/stderr **接进缓冲**(`chromeOutput`):启动失败时把浏览器自己的话与
// 本次 argv 一起报进日志 —— 这是「参数被拒 / 端口没出来 / 预算没了」三者可分的前提。
// 成功时只报字节数(Debug),不把 Chrome 的常规输出刷进日志。
// 子进程的 stdout/stderr **接进缓冲**(`chromeOutput`):启动失败时把浏览器自己的话与本次
// argv 一起报进**日志**,并把浏览器原话附进**返回的错误**(`withChromeOutput`)—— 用户侧看到的
// 工具结果就是从这条错误来的。成功时只报字节数(Debug),不把 Chrome 的常规输出刷进日志。
//
// 停止函数只对**本次启动**的进程有效(认领来的实例不归我们杀):进程退出后 Chrome 的
// 停止函数只对**本次启动**的进程有效(复用来的实例不归我们杀):进程退出后 Chrome 的
// 渲染进程随之退出,因此进程级 Kill 足够。
func (l *localChrome) Dial(ctx context.Context) (string, func(), error) {
if strings.TrimSpace(l.exe) == "" {
@@ -142,14 +155,26 @@ func (l *localChrome) Dial(ctx context.Context) (string, func(), error) {
if err := ctx.Err(); err != nil {
return "", nil, budgetLaunchErr(ctx)
}
if endpoint, ok := l.adoptExisting(ctx); ok {
l.log.Info("browser session adopted", "endpoint", endpoint, "profile", l.profileDir)
endpoint, err := l.probeExistingEndpoint(ctx)
if err != nil {
// 复用不了、又不许起第二个:如实报(错误里已带处置建议),一条 WARN 留现场。
l.log.Warn("browser launch blocked by unusable debug endpoint",
"profile", l.profileDir, "error", err)
return "", nil, err
}
if endpoint != "" {
l.log.Info("browser debug endpoint reused",
"endpoint", endpoint,
"profile", l.profileDir,
"detail", "复用已存在的调试端点(同 profile 的浏览器仍在跑,不再起第二个)")
return endpoint, func() {}, nil
}
if err := os.MkdirAll(l.profileDir, 0o700); err != nil {
return "", nil, fmt.Errorf("%w: 建 profile 目录 %s: %v", ErrLaunch, l.profileDir, err)
}
_ = os.Remove(filepath.Join(l.profileDir, devToolsPortFile))
if err := l.clearStalePortFile(); err != nil {
return "", nil, err
}
args := l.launchArgs()
cmd := exec.Command(l.exe, args...)
@@ -176,7 +201,7 @@ func (l *localChrome) Dial(ctx context.Context) (string, func(), error) {
"chrome_output", out.preview(),
"chrome_output_bytes", out.totalBytes(),
"error", err)
return "", nil, err
return "", nil, withChromeOutput(err, out.preview())
}
l.log.Debug("browser launched",
"exe", l.exe, "port", port, "profile", l.profileDir, "chrome_output_bytes", out.totalBytes())
@@ -205,24 +230,8 @@ func budgetLaunchErr(ctx context.Context) error {
return fmt.Errorf("%w: %s:%s,浏览器未启动完成", ErrLaunch, launchMarkerBudget, detail)
}
// adoptExisting 报告 profile 目录里记录的调试端点是否**仍然活着**;活着就返回它。
// 探测超时给得很短:这是一个「要不要复用」的判断,不该拖住启动路径。
func (l *localChrome) adoptExisting(ctx context.Context) (string, bool) {
port, ok := readDevToolsPort(filepath.Join(l.profileDir, devToolsPortFile))
if !ok {
return "", false
}
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"`
}
if err := httpGetJSON(probeCtx, endpoint+"/json/version", &version); err != nil {
return "", false
}
return endpoint, true
}
// 启动前的三条判定与它们的处置文案在 adopt.go:复用活端点(probeExistingEndpoint)、
// 清掉陈的端口记录(clearStalePortFile)、端点不可用时的如实上报(withChromeOutput 与两条 hint)。
// launchArgs 组装启动参数(**顺序稳定、可断言**:单元测试钉住白/黑名单)。
func (l *localChrome) launchArgs() []string {
@@ -254,13 +263,18 @@ func (l *localChrome) launchArgs() []string {
// 而且三者互不冒充(design/28 §7 判据 4「如实标注」):
//
// ① 浏览器进程自己退了(多半是 profile 锁被占 / 浏览器已在运行)——错误里带
// launchMarkerProcessExited;
// launchMarkerProcessExited **与可操作的处置建议**(profileBusyHint);子进程说的原话
// 由 Dial 追加进这条错误(withChromeOutput),用户侧因此看得见 Chromium 那句
// 「正在现有的浏览器会话中打开。」;
// ② 我们自己的等待窗口用完 —— 带 launchMarkerPortTimeout;
// ③ 调用方的预算先没了(ctx 结束)—— 带 launchMarkerBudget,**不**说浏览器坏。
//
// ② 与 ③ 的分辨靠两者的截止时间:ctx 的截止时间**明显早于**我们这一次等待的截止时间
// 时,结束它的是调用方;几乎同时到点时按 ② 报(那是这一次等待真的用满了配置的 timeout,
// 也是真机常态:外层 openSession 的预算与本次等待同源同值)。
//
// 还有一条**不走这里**的出口:启动前探到「端口在听却握不上手」时,Dial 会在起进程之前就
// 报 launchMarkerEndpointBroken(见 adopt.go)—— 那种情形根本不该起这个进程。
func (l *localChrome) waitDevToolsPort(ctx context.Context, exited <-chan struct{}) (int, error) {
path := filepath.Join(l.profileDir, devToolsPortFile)
launchDeadline := time.Now().Add(l.timeout)
@@ -275,9 +289,10 @@ func (l *localChrome) waitDevToolsPort(ctx context.Context, exited <-chan struct
select {
case <-exited:
// 浏览器进程自己退了,端口不会再出现 —— **立刻**报,不把超时白等完。
// 最常见的原因是同一 profile 已被另一个实例占用(Chromium 的 profile 锁)
// 或浏览器已被别处启动:委托出去的新进程不写 DevToolsActivePort。
return 0, fmt.Errorf("%w: %s(多半是该 profile 已被另一个实例占用)", ErrLaunch, launchMarkerProcessExited)
// 最常见的原因是同一 profile 已被另一个实例占用:Chromium 把命令**委托给已在跑的
// 那个**之后自己退出(真机实测 stderr 原话「正在现有的浏览器会话中打开。」)、新
// 进程不写 DevToolsActivePort。这里给可操作建议,但不碰别人的进程。
return 0, fmt.Errorf("%w: %s。%s", ErrLaunch, launchMarkerProcessExited, profileBusyHint(l.profileDir))
case <-ctx.Done():
if dl, ok := ctx.Deadline(); !ok || dl.Add(launchDeadlineSlack).Before(launchDeadline) {
return 0, budgetLaunchErr(ctx)