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,8 @@ package browser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -193,3 +195,199 @@ func containsArg(args []string, want string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── 启动失败的可诊断性与分类(假浏览器见 fakebrowser_test.go) ──────────────────────
|
||||
|
||||
// TestDial_ReportsChromeOutputAndArgvOnLaunchFailure 是本片最重要的一条:启动失败时
|
||||
// **必须**把浏览器自己说的话与本次 argv 一起报进日志。真机踩过的坑正是这条的反面 ——
|
||||
// 窗口上跳出浏览器的抱怨,我们的日志里却只有自己这一侧的猜测("等待调试端口时被取消"),
|
||||
// 于是「浏览器为什么拒绝」永远查不到。
|
||||
func TestDial_ReportsChromeOutputAndArgvOnLaunchFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
const complaint = "不支持的标记: --remote-allow-origins=*"
|
||||
log, buf := newCapturedLog(slog.LevelDebug)
|
||||
l := newFakeChrome(t, fakeBrowserScript{Stderr: []string{complaint}, ExitCode: 2}, 5*time.Second, log)
|
||||
|
||||
_, stop, err := l.Dial(context.Background())
|
||||
if stop != nil {
|
||||
t.Error("失败路径不该交出停止函数")
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("假浏览器立刻退出时 Dial 必须报错")
|
||||
}
|
||||
if !errors.Is(err, ErrLaunch) {
|
||||
t.Fatalf("错误必须仍然包住 ErrLaunch(errors.Is 口径不变): %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), launchMarkerProcessExited) {
|
||||
t.Errorf("错误里应点明「进程立刻退出」这一类: %v", err)
|
||||
}
|
||||
|
||||
logs := buf.String()
|
||||
t.Logf("实测 error=%v\n实测日志=%s", err, logs)
|
||||
for _, want := range []string{
|
||||
complaint, // 浏览器自己说的话
|
||||
l.exe, // 是哪个可执行文件
|
||||
"--user-data-dir=" + l.profileDir, // 本次 argv(profile 路径不含凭据)
|
||||
"--remote-debugging-port=0",
|
||||
"chrome_output_bytes=",
|
||||
} {
|
||||
if !containsInLog(logs, want) {
|
||||
t.Errorf("失败日志里缺少 %q:\n%s", want, logs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDial_SuccessDoesNotSpamChromeOutput 钉住反面:**成功时不刷屏**。正常启动时
|
||||
// Chrome 也会往 stderr 写常规信息,那些不该进日志;只留一个字节数(Debug,供排查用)。
|
||||
func TestDial_SuccessDoesNotSpamChromeOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
const chatter = "DevTools listening on ws://127.0.0.1:55543"
|
||||
log, buf := newCapturedLog(slog.LevelDebug)
|
||||
l := newFakeChrome(t, fakeBrowserScript{
|
||||
Stderr: []string{chatter}, WritePort: 55543, SleepMS: 5000,
|
||||
}, 5*time.Second, log)
|
||||
|
||||
endpoint, stop, err := l.Dial(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("脚本写了端口文件,Dial 应当成功: %v", err)
|
||||
}
|
||||
defer stop()
|
||||
if endpoint != "http://127.0.0.1:55543" {
|
||||
t.Errorf("endpoint = %q", endpoint)
|
||||
}
|
||||
logs := buf.String()
|
||||
if containsInLog(logs, chatter) {
|
||||
t.Errorf("成功启动时不该把浏览器自己的输出打进日志(刷屏):\n%s", logs)
|
||||
}
|
||||
if !strings.Contains(logs, "browser launched") || !strings.Contains(logs, "chrome_output_bytes=") {
|
||||
t.Errorf("成功时只应留一条只报规模(字节数)的 Debug:\n%s", logs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDial_ExhaustedBudgetDoesNotSpawnBrowser 钉住预算口径的**起点**:ctx 已经结束时
|
||||
// **不起进程** —— 起了也只是生一个会被马上杀掉的窗口,并把「没时间了」伪装成
|
||||
// 「浏览器起不来」。
|
||||
func TestDial_ExhaustedBudgetDoesNotSpawnBrowser(t *testing.T) {
|
||||
t.Parallel()
|
||||
log, buf := newCapturedLog(slog.LevelDebug)
|
||||
l := newFakeChrome(t, fakeBrowserScript{
|
||||
Stderr: []string{"这一行不该出现:进程根本没被拉起"}, WritePort: 55543, SleepMS: 5000,
|
||||
}, 5*time.Second, log)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, _, err := l.Dial(ctx)
|
||||
if err == nil {
|
||||
t.Fatal("预算已耗尽时 Dial 必须报错")
|
||||
}
|
||||
t.Logf("实测错误(ctx 已取消):%v", err)
|
||||
if !errors.Is(err, ErrLaunch) {
|
||||
t.Fatalf("错误必须仍然包住 ErrLaunch: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), launchMarkerBudget) {
|
||||
t.Errorf("错误必须读成「预算耗尽」,实得: %v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), launchMarkerPortTimeout) {
|
||||
t.Errorf("预算耗尽不许被说成「端口等超时」(那是浏览器侧的问题): %v", err)
|
||||
}
|
||||
if _, ok := readDevToolsPort(filepath.Join(l.profileDir, devToolsPortFile)); ok {
|
||||
t.Error("预算耗尽时不该起浏览器(脚本被执行了)")
|
||||
}
|
||||
if strings.Contains(buf.String(), "这一行不该出现") {
|
||||
t.Errorf("预算耗尽时子进程不该存在:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDial_CallerDeadlineIsReportedAsBudget 钉住**真机那条路**:等待期间调用方的截止
|
||||
// 时间到了(这一轮的工具预算烧完),错误必须读成「预算耗尽、浏览器没坏」,而不是
|
||||
// 「等待调试端口时被取消」这种既像浏览器故障、又说不清谁取消的文案。
|
||||
func TestDial_CallerDeadlineIsReportedAsBudget(t *testing.T) {
|
||||
t.Parallel()
|
||||
log, buf := newCapturedLog(slog.LevelDebug)
|
||||
l := newFakeChrome(t, fakeBrowserScript{Stderr: []string{"浏览器在等,但没人给它时间"}, SleepMS: 5000}, 10*time.Second, log)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
_, _, err := l.Dial(ctx)
|
||||
if err == nil {
|
||||
t.Fatal("调用方截止时间到之后 Dial 必须报错")
|
||||
}
|
||||
if !errors.Is(err, ErrLaunch) {
|
||||
t.Fatalf("错误必须仍然包住 ErrLaunch: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), launchMarkerBudget) {
|
||||
t.Errorf("错误必须读成「预算耗尽」,实得: %v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), launchMarkerPortTimeout) {
|
||||
t.Errorf("调用方预算先到时不许报成我们自己的端口等待超时: %v", err)
|
||||
}
|
||||
if !containsInLog(buf.String(), l.profileDir) {
|
||||
t.Errorf("这条失败也要带 argv(否则现场没法复现):\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDial_ThreeLaunchFailuresAreDistinguishable 钉住「一眼分辨」:三条出口 ——
|
||||
// ① 进程立刻退出 / ② 我们自己的端口等待超时 / ③ 调用方预算耗尽 —— 各自的错误文案里
|
||||
// 必须带**互不相同**的标记,谁也别冒充谁。
|
||||
func TestDial_ThreeLaunchFailuresAreDistinguishable(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
script fakeBrowserScript
|
||||
timeout time.Duration
|
||||
ctx func() (context.Context, context.CancelFunc)
|
||||
wantMarker string
|
||||
forbid string
|
||||
}{
|
||||
{
|
||||
name: "①进程立刻退出",
|
||||
script: fakeBrowserScript{Stderr: []string{"参数被拒,进程退出"}, ExitCode: 1},
|
||||
timeout: 5 * time.Second,
|
||||
ctx: func() (context.Context, context.CancelFunc) { return context.WithCancel(context.Background()) },
|
||||
wantMarker: launchMarkerProcessExited,
|
||||
forbid: launchMarkerBudget,
|
||||
},
|
||||
{
|
||||
name: "②等端口超时",
|
||||
script: fakeBrowserScript{Stderr: []string{"起来了但从不写端口文件"}, SleepMS: 5000},
|
||||
timeout: 300 * time.Millisecond,
|
||||
ctx: func() (context.Context, context.CancelFunc) { return context.WithCancel(context.Background()) },
|
||||
wantMarker: launchMarkerPortTimeout,
|
||||
forbid: launchMarkerBudget,
|
||||
},
|
||||
{
|
||||
name: "③调用方预算耗尽",
|
||||
script: fakeBrowserScript{Stderr: []string{"还没就绪预算就没了"}, SleepMS: 5000},
|
||||
timeout: 10 * time.Second,
|
||||
ctx: func() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
},
|
||||
wantMarker: launchMarkerBudget,
|
||||
forbid: launchMarkerPortTimeout,
|
||||
},
|
||||
}
|
||||
seen := map[string]string{}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
l := newFakeChrome(t, tc.script, tc.timeout, discardLog())
|
||||
ctx, cancel := tc.ctx()
|
||||
defer cancel()
|
||||
_, _, err := l.Dial(ctx)
|
||||
if err == nil {
|
||||
t.Fatal("这一档必须报错")
|
||||
}
|
||||
msg := err.Error()
|
||||
t.Logf("%s 实测错误:%v", tc.name, err)
|
||||
if !strings.Contains(msg, tc.wantMarker) {
|
||||
t.Errorf("错误里应带标记 %q:%v", tc.wantMarker, err)
|
||||
}
|
||||
if strings.Contains(msg, tc.forbid) {
|
||||
t.Errorf("错误里不该出现 %q(冒充了另一类失败):%v", tc.forbid, err)
|
||||
}
|
||||
if prev, dup := seen[tc.wantMarker]; dup {
|
||||
t.Errorf("标记 %q 与 %q 撞了 —— 三种情形必须可分", tc.wantMarker, prev)
|
||||
}
|
||||
seen[tc.wantMarker] = tc.name
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user