fix(browser): 启动失败要说出原因;预算耗尽 ≠ 浏览器坏了
真机现象:主人看到 Chrome 窗口内有横幅(他读作「不支持接受的参数」),日志只有 一句「等待调试端口时被取消: context deadline exceeded」,看不出是谁的错。 三件(本片): - 接住子进程 stdout/stderr(有界:前 4 KiB / 上报 12 行,只缓存不输出)⇒ 失败时 打一条带 exe / argv / chrome_output / chrome_output_bytes 的 WARN;成功只记字节数 (不刷屏)。 - 三种启动失败在错误里分开:① 进程立刻退出(profile 被占用)② 等端口超时 ③ 调用方预算耗尽(ctx 已结束 ⇒ **根本不 spawn**)。③ 与 ② 用两个截止时间比大小 区分 —— 顺带修掉一个老缺陷:原来 ctx 一结束就报「等待调试端口时被取消」,而那 几乎总是我们自己的 30s 用满,现被正确归到 ②。 - 降级档不再被**已耗尽**的轮预算连坐:如实报「预算耗尽 ⇒ 未尝试浏览器渲染, 这不是浏览器故障」,**不许把"没试"说成"试了失败"**。plugins/tools/io/bodyfetch.go 一字未动(fetchedBy / browserError 语义原样),只在**发起方**决定要不要发起。 对主人那条横幅的结论(有据,非推测):那是 Chrome 的 **bad-flags 警告栏**,触发者 是 `--disable-blink-features=AutomationControlled` —— 它明确在 Chromium 的 kBadFlags 名单里(bad_flags_prompt.cc 列了 kDisableBlinkFeatures/kEnableBlinkFeatures),本机 Chrome 154 zh-CN 的文案是「您使用的是不受支持的命令行标记:$1。稳定性和安全性会 有所下降。」;而「不支持接受的参数」在 Chrome 的 zh-CN/en-US 资源与二进制里**零命中** ⇒ 是意译。⚠️ 该参数是 design/28 §2 要求的(掩盖 navigator.webdriver)⇒ 横幅是 **设计取舍的视觉副作用**,去掉它才消失(但会暴露自动化特征)。登记待主人裁决。 验收(主会话复跑 + 亲自变异):go build ./... + 两包 vet 绿;browser 与 cmd/cyrene 两包测试绿;我用 -overlay 去掉 cmd.Stderr 接线 ⇒ TestDial_ReportsChromeOutputAndArgvOnLaunchFailure 变红(chrome_output="" chrome_output_bytes=0)。 ⚠️ 未部署:跑着的产物不含本片,要等下次 build + restart。
This commit is contained in:
1 parent
23dc9a3e5e
commit
93c5a4c46f
6 files changed
+688
-13
No files matched your search
@@ -2,6 +2,7 @@ package browser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -35,6 +36,33 @@ const (
|
||||
// 用的那一档):它只问「端口有没有在听」,给得短。
|
||||
const adoptProbeTimeout = 2 * time.Second
|
||||
|
||||
// 启动失败的**分类标记**:三种情形必须在错误消息里一眼可分(design/28 §7 判据 4
|
||||
// 「如实标注」/ §6 坑 5「失败要如实报」)——
|
||||
//
|
||||
// ① 浏览器进程自己退了(参数被拒 / 委托给已跑实例 / profile 锁被占);
|
||||
// ② 我们自己的等待窗口用完(DevToolsActivePort 没出现);
|
||||
// ③ **调用方的预算先没了**:浏览器没坏,只是没时间了。
|
||||
//
|
||||
// 哨兵仍然只有 ErrLaunch 一个(`errors.Is` 口径不变),这些标记是消息里可机读的那一段。
|
||||
const (
|
||||
// launchMarkerProcessExited 标记 ①。
|
||||
launchMarkerProcessExited = "浏览器进程立刻退出"
|
||||
// launchMarkerPortTimeout 标记 ②。
|
||||
launchMarkerPortTimeout = "等 DevToolsActivePort 超时"
|
||||
// launchMarkerBudget 标记 ③。
|
||||
launchMarkerBudget = "调用方预算耗尽"
|
||||
)
|
||||
|
||||
const (
|
||||
// chromeOutputDrainWait 是「进程退出/被杀之后,把它已经写进管道的输出收干净」的
|
||||
// 等待上限:诊断不该反过来拖住失败路径。
|
||||
chromeOutputDrainWait = 500 * time.Millisecond
|
||||
// launchDeadlineSlack 容忍「调用方的截止时间与我们这一次等待的截止时间几乎同时」
|
||||
// 这种不可分情形:差值在它之内时按**我们自己等端口超时**报(此刻的事实正是这一次
|
||||
// 等待用满了配置的 timeout,真机常态如此:外层 openSession 与本次等待同源同值)。
|
||||
launchDeadlineSlack = 250 * time.Millisecond
|
||||
)
|
||||
|
||||
// dialer 产出一个可用的 CDP HTTP 端点(`http://host:port`)与停止函数。它把
|
||||
// 「启动本机浏览器」与「连接已跑端点」两条路统一成同一个会话装配入口。
|
||||
type dialer interface {
|
||||
@@ -88,14 +116,22 @@ func (l *localChrome) Local() bool { return true }
|
||||
|
||||
// Dial 拉起浏览器并等调试端口就绪。任何一步失败都返回带分类原因的 ErrLaunch。
|
||||
//
|
||||
// 两条**先于启动**的判定(都是为了让「上一次会话留下的浏览器」不至于把功能卡死):
|
||||
// 三条**先于启动**的判定(前两条是为了让「上一次会话留下的浏览器」不至于把功能卡死):
|
||||
//
|
||||
// 1. **认领已在跑的实例**:profile 目录的 DevToolsActivePort 还活着(端口在听)⇒ 直接
|
||||
// 1. **预算已耗尽就不起进程**:ctx 已经结束(这一轮的工具预算用完 / 会话被取消)时
|
||||
// 立刻返回「预算耗尽」分类,**不 spawn** —— 起了也只是生一个会被马上杀掉的窗口,
|
||||
// 并把「没时间了」伪装成「浏览器起不来」(真机踩过:窗口上跳出浏览器的抱怨,日志
|
||||
// 里却像浏览器坏了)。判定做在**启动之前**,因为它要省掉的正是那次启动。
|
||||
// 2. **认领已在跑的实例**:profile 目录的 DevToolsActivePort 还活着(端口在听)⇒ 直接
|
||||
// 出站连接它,**不再起第二个**。这不是优化,是**正确性**:同一个 user-data-dir 上起
|
||||
// 第二个实例时 Chromium 会「委托给已在跑的那个」然后自己退出,于是新进程永远不写
|
||||
// DevToolsActivePort ⇒ 功能会一直报启动超时,直到人工杀掉那个进程。认领把这一步
|
||||
// 变成「复用」,也更贴合 design/28 §2「profile 长期保留、任务间复用」。
|
||||
// 2. **端口文件先清掉**:只在真的要走启动路径时才清,否则会把旧端口当成本次结果。
|
||||
// 3. **端口文件先清掉**:只在真的要走启动路径时才清,否则会把旧端口当成本次结果。
|
||||
//
|
||||
// 子进程的 stdout/stderr **接进缓冲**(`chromeOutput`):启动失败时把浏览器自己的话与
|
||||
// 本次 argv 一起报进日志 —— 这是「参数被拒 / 端口没出来 / 预算没了」三者可分的前提。
|
||||
// 成功时只报字节数(Debug),不把 Chrome 的常规输出刷进日志。
|
||||
//
|
||||
// 停止函数只对**本次启动**的进程有效(认领来的实例不归我们杀):进程退出后 Chrome 的
|
||||
// 渲染进程随之退出,因此进程级 Kill 足够。
|
||||
@@ -103,6 +139,9 @@ func (l *localChrome) Dial(ctx context.Context) (string, func(), error) {
|
||||
if strings.TrimSpace(l.exe) == "" {
|
||||
return "", nil, fmt.Errorf("%w: 未指定可执行文件", ErrLaunch)
|
||||
}
|
||||
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)
|
||||
return endpoint, func() {}, nil
|
||||
@@ -112,24 +151,60 @@ func (l *localChrome) Dial(ctx context.Context) (string, func(), error) {
|
||||
}
|
||||
_ = os.Remove(filepath.Join(l.profileDir, devToolsPortFile))
|
||||
|
||||
cmd := exec.Command(l.exe, l.launchArgs()...)
|
||||
args := l.launchArgs()
|
||||
cmd := exec.Command(l.exe, args...)
|
||||
out := &chromeOutput{}
|
||||
cmd.Stdout = out
|
||||
cmd.Stderr = out
|
||||
if err := cmd.Start(); err != nil {
|
||||
return "", nil, fmt.Errorf("%w: 启动 %s: %v", ErrLaunch, l.exe, err)
|
||||
}
|
||||
exited := make(chan struct{})
|
||||
go func() {
|
||||
defer close(exited)
|
||||
_, _ = cmd.Process.Wait()
|
||||
// 用 cmd.Wait(而不是 cmd.Process.Wait):它会等两个输出复制协程收工,
|
||||
// 于是 exited 关闭时缓冲里就是子进程说过的**全部**话(下面要在失败时上报它)。
|
||||
_ = cmd.Wait()
|
||||
}()
|
||||
port, err := l.waitDevToolsPort(ctx, exited)
|
||||
if err != nil {
|
||||
stopProcess(cmd)
|
||||
l.waitExited(exited)
|
||||
l.log.Warn("browser launch failed",
|
||||
"exe", l.exe,
|
||||
"argv", strings.Join(args, " "),
|
||||
"chrome_output", out.preview(),
|
||||
"chrome_output_bytes", out.totalBytes(),
|
||||
"error", err)
|
||||
return "", nil, err
|
||||
}
|
||||
l.log.Debug("browser launched", "exe", l.exe, "port", port, "profile", l.profileDir)
|
||||
l.log.Debug("browser launched",
|
||||
"exe", l.exe, "port", port, "profile", l.profileDir, "chrome_output_bytes", out.totalBytes())
|
||||
return fmt.Sprintf("http://127.0.0.1:%d", port), func() { stopProcess(cmd) }, nil
|
||||
}
|
||||
|
||||
// waitExited 等子进程真正收尾(管道里的在途字节落进缓冲)再上报:Kill 之后 Wait 还要
|
||||
// 一点点时间,不等就会漏掉最后几行 —— 而最后几行往往正是原因。等不到就照现有内容报,
|
||||
// 不把失败路径拖住。
|
||||
func (l *localChrome) waitExited(exited <-chan struct{}) {
|
||||
timer := time.NewTimer(chromeOutputDrainWait)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-exited:
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
|
||||
// budgetLaunchErr 是 ③「调用方预算耗尽」的**唯一文案**。它必须读起来像「没时间了」,
|
||||
// 而不是像「浏览器坏了」:取消与截止时间到分别点明(两者都是调用方自己的事)。
|
||||
func budgetLaunchErr(ctx context.Context) error {
|
||||
detail := "上下文截止时间到(这一轮的工具预算已耗尽)"
|
||||
if errors.Is(ctx.Err(), context.Canceled) {
|
||||
detail = "上下文被取消(会话/这一轮已结束)"
|
||||
}
|
||||
return fmt.Errorf("%w: %s:%s,浏览器未启动完成", ErrLaunch, launchMarkerBudget, detail)
|
||||
}
|
||||
|
||||
// adoptExisting 报告 profile 目录里记录的调试端点是否**仍然活着**;活着就返回它。
|
||||
// 探测超时给得很短:这是一个「要不要复用」的判断,不该拖住启动路径。
|
||||
func (l *localChrome) adoptExisting(ctx context.Context) (string, bool) {
|
||||
@@ -175,10 +250,20 @@ func (l *localChrome) launchArgs() []string {
|
||||
return args
|
||||
}
|
||||
|
||||
// waitDevToolsPort 等 DevToolsActivePort 出现并解析出端口号。三条出口都要**说清原因**:
|
||||
// 进程立刻退出(多半是 profile 锁被占 / 浏览器已在运行)、ctx 取消、超时。
|
||||
// waitDevToolsPort 等 DevToolsActivePort 出现并解析出端口号。三条出口都要**说清原因**,
|
||||
// 而且三者互不冒充(design/28 §7 判据 4「如实标注」):
|
||||
//
|
||||
// ① 浏览器进程自己退了(多半是 profile 锁被占 / 浏览器已在运行)——错误里带
|
||||
// launchMarkerProcessExited;
|
||||
// ② 我们自己的等待窗口用完 —— 带 launchMarkerPortTimeout;
|
||||
// ③ 调用方的预算先没了(ctx 结束)—— 带 launchMarkerBudget,**不**说浏览器坏。
|
||||
//
|
||||
// ② 与 ③ 的分辨靠两者的截止时间:ctx 的截止时间**明显早于**我们这一次等待的截止时间
|
||||
// 时,结束它的是调用方;几乎同时到点时按 ② 报(那是这一次等待真的用满了配置的 timeout,
|
||||
// 也是真机常态:外层 openSession 的预算与本次等待同源同值)。
|
||||
func (l *localChrome) waitDevToolsPort(ctx context.Context, exited <-chan struct{}) (int, error) {
|
||||
path := filepath.Join(l.profileDir, devToolsPortFile)
|
||||
launchDeadline := time.Now().Add(l.timeout)
|
||||
deadline := time.NewTimer(l.timeout)
|
||||
defer deadline.Stop()
|
||||
ticker := time.NewTicker(portPollInterval)
|
||||
@@ -192,16 +277,24 @@ func (l *localChrome) waitDevToolsPort(ctx context.Context, exited <-chan struct
|
||||
// 浏览器进程自己退了,端口不会再出现 —— **立刻**报,不把超时白等完。
|
||||
// 最常见的原因是同一 profile 已被另一个实例占用(Chromium 的 profile 锁)
|
||||
// 或浏览器已被别处启动:委托出去的新进程不写 DevToolsActivePort。
|
||||
return 0, fmt.Errorf("%w: 浏览器进程立刻退出(多半是该 profile 已被另一个实例占用)", ErrLaunch)
|
||||
return 0, fmt.Errorf("%w: %s(多半是该 profile 已被另一个实例占用)", ErrLaunch, launchMarkerProcessExited)
|
||||
case <-ctx.Done():
|
||||
return 0, fmt.Errorf("%w: 等待调试端口时被取消: %v", ErrLaunch, ctx.Err())
|
||||
if dl, ok := ctx.Deadline(); !ok || dl.Add(launchDeadlineSlack).Before(launchDeadline) {
|
||||
return 0, budgetLaunchErr(ctx)
|
||||
}
|
||||
return 0, l.portTimeoutErr()
|
||||
case <-deadline.C:
|
||||
return 0, fmt.Errorf("%w: 等 %s 超时(%s)", ErrLaunch, devToolsPortFile, l.timeout)
|
||||
return 0, l.portTimeoutErr()
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// portTimeoutErr 是 ②「我们自己的等待窗口用完」的唯一文案(两条出口共用,口径只有一处)。
|
||||
func (l *localChrome) portTimeoutErr() error {
|
||||
return fmt.Errorf("%w: %s(%s)", ErrLaunch, launchMarkerPortTimeout, l.timeout)
|
||||
}
|
||||
|
||||
// readDevToolsPort 读端口文件的首行;文件还没出现 / 还没写完 / 内容不合法都返回 false
|
||||
// (交给调用方继续轮询,而不是把「正在写」误判成「配错」)。
|
||||
func readDevToolsPort(path string) (int, bool) {
|
||||
|
||||
Reference in new issue
Block a user