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
@@ -28,6 +28,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
@@ -66,9 +67,18 @@ func (a *app) bodyFallbackOption() iotools.Option {
|
||||
return iotools.WithBrowserFallback(nil)
|
||||
}
|
||||
log.Info("web-fetch browser fallback wired", "trigger", browserFallbackTrigger)
|
||||
return iotools.WithBrowserFallback(&browserBodyRenderer{caps: a.caps})
|
||||
return iotools.WithBrowserFallback(&browserBodyRenderer{caps: a.caps, log: log})
|
||||
}
|
||||
|
||||
// browserFallbackReasonBudget 是「这一档**没有发起**」的原因码:调用方这一轮的预算已经
|
||||
// 耗尽。它与 `no-usable-browser` 是两件事(一个是「没时间了」,一个是「没有浏览器」),
|
||||
// 混成一句会把人带偏(真机就出现过:窗口上是浏览器的抱怨,日志里像浏览器坏了)。
|
||||
const browserFallbackReasonBudget = "budget-exhausted"
|
||||
|
||||
// browserFallbackBudgetNote 写清这一刻的**实际**含义:浏览器没坏、这一档没被尝试。
|
||||
// 它进错误文本(顺 web-fetch 的 browserError 面出来),因此必须一眼可读、不含猜测。
|
||||
const browserFallbackBudgetNote = "本轮工具预算已耗尽(调用方上下文已结束)⇒ 未尝试浏览器渲染,这不是浏览器故障"
|
||||
|
||||
// browserBodyRenderer 把浏览器能力面(`*browsercap.Manager.RenderText`)适配成 io 包的
|
||||
// `iotools.BodyRenderer` —— 这是「进程内接口 + 依赖注入」的适配器,不引入第二个浏览器栈、
|
||||
// 不新开任何监听端口(AGENTS §9)。
|
||||
@@ -84,15 +94,43 @@ func (a *app) bodyFallbackOption() iotools.Option {
|
||||
//
|
||||
// 空正文**不在这里**判:`RenderText` 的「渲染成功但正文为空」是读数不是错误,而 io 的
|
||||
// 降级档有自己的口径(`ErrEmptyRenderedBody`)—— 本适配器原样交出去,不替上层下结论。
|
||||
type browserBodyRenderer struct{ caps *registry.Registry }
|
||||
type browserBodyRenderer struct {
|
||||
caps *registry.Registry
|
||||
// log 是挂载点的日志(已带来源)。可为 nil(单测直接构造):那就不记,不做替代语义。
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// RenderBody 实现 iotools.BodyRenderer。
|
||||
//
|
||||
// **预算口径**(design/28 §1 M5「降级档」+ §7 判据 4「如实标注」+ §6 坑 5「失败要如实报」,
|
||||
// 设计没有为这一档规定独立预算 ⇒ 取最小的诚实口径):ctx 已经结束(这一轮的工具预算耗尽 /
|
||||
// 会话被取消)时**不发起**,如实报「预算耗尽 ⇒ 未尝试」,而**不是**报一个看起来像
|
||||
// 「浏览器坏了」的错误。三条理由:
|
||||
//
|
||||
// - 这一档要冷启动真浏览器(秒级),启动所需的预算此刻已经不存在;
|
||||
// - 工具派发层在 ctx 结束时就要把结果丢掉(`internal/runtime/tool` 的 callTool 在
|
||||
// ctx.Done 与结果之间二选一,且 ctx 结束时返回 ctx.Err())⇒ 硬起一个浏览器只会留下
|
||||
// 一个被立刻杀掉的窗口,**结果没人读**(真机踩过:窗口上跳出浏览器的抱怨);
|
||||
// - 没试过就不能报成「试了失败」:`fetchedBy` / `browserError` 的语义(见
|
||||
// `plugins/tools/io/bodyfetch.go`)一字不动,本适配器只交「没发起」这个事实。
|
||||
//
|
||||
// ctx 还活着时**不动**它的预算(不另造一个第二预算):浏览器会话可能是热的(同一次
|
||||
// 会话里刚渲染过),渲染只要一两秒;真有取消,浏览器侧会把它如实分类成「预算耗尽」
|
||||
// (见 `internal/capability/browser` 的分类标记),不会再冒充「浏览器坏了」。
|
||||
func (r *browserBodyRenderer) RenderBody(ctx context.Context, rawURL string) (iotools.RenderedBody, error) {
|
||||
mgr := browserManagerOf(r.caps)
|
||||
if mgr == nil || !mgr.Available() {
|
||||
// 诚实降级:此时是「这一档没有来源」,**不是**「取到了空正文」。
|
||||
return iotools.RenderedBody{}, errors.New("web-fetch browser fallback: no usable browser available")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
if r.log != nil {
|
||||
r.log.Info("web-fetch browser fallback not attempted",
|
||||
"reason", browserFallbackReasonBudget, "error", err)
|
||||
}
|
||||
return iotools.RenderedBody{}, fmt.Errorf("web-fetch browser fallback: %s: %s: %w",
|
||||
browserFallbackReasonBudget, browserFallbackBudgetNote, err)
|
||||
}
|
||||
rendered, err := mgr.RenderText(ctx, rawURL)
|
||||
if err != nil {
|
||||
return iotools.RenderedBody{}, fmt.Errorf("browser render: %w", err)
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
|
||||
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
|
||||
@@ -257,3 +258,104 @@ func TestWireRuntime_BrowserWiredBeforeIoTools(t *testing.T) {
|
||||
"会静默关死(挂载条件读的是注册面)")
|
||||
}
|
||||
}
|
||||
|
||||
// ⑥ 预算口径:这一轮的预算已经耗尽(ctx 已结束)时,这一档**不发起**,如实报
|
||||
// 「预算耗尽 ⇒ 未尝试浏览器渲染」。判据是两句话都要在,而那句误导性的
|
||||
// 「启动本机浏览器失败」绝不能出现 —— 假 exe 一定启动失败,它出现就证明我们真去起了
|
||||
// 浏览器(那正是真机踩的坑:窗口上跳出浏览器的抱怨,日志里像浏览器坏了)。
|
||||
func TestBrowserBodyRenderer_ExhaustedBudgetIsNotABrowserFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := wsLoadConfig(t)
|
||||
var out bytes.Buffer
|
||||
a := browserTestApp(t, cfg, &out)
|
||||
if err := a.caps.RegisterTool(browsertools.New(
|
||||
newWiredBrowserManager(t, cfg, "example.com"))); err != nil {
|
||||
t.Fatalf("注册 browser 工具: %v", err)
|
||||
}
|
||||
r := &browserBodyRenderer{caps: a.caps, log: logger.WithSource(a.log, ioLogSource)}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
ctx func() (context.Context, context.CancelFunc)
|
||||
}{
|
||||
{"已取消", func() (context.Context, context.CancelFunc) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
return ctx, cancel
|
||||
}},
|
||||
{"已到期", func() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), 0)
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx, cancel := tc.ctx()
|
||||
defer cancel()
|
||||
got, err := r.RenderBody(ctx, "https://example.com/")
|
||||
if err == nil {
|
||||
t.Fatalf("预算耗尽时必须报错(不许拿空正文冒充成功): %+v", got)
|
||||
}
|
||||
t.Logf("实测 err=%v", err)
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, browserFallbackReasonBudget) {
|
||||
t.Errorf("err = %v,应带原因码 %q", err, browserFallbackReasonBudget)
|
||||
}
|
||||
if !strings.Contains(msg, "未尝试浏览器渲染") {
|
||||
t.Errorf("err = %v,应说清「没有试过」", err)
|
||||
}
|
||||
if strings.Contains(msg, "启动本机浏览器失败") {
|
||||
t.Errorf("预算耗尽不许被报成浏览器故障: %v", err)
|
||||
}
|
||||
if strings.Contains(msg, "no usable browser") {
|
||||
t.Errorf("「预算耗尽」与「没有可用来源」是两件事,不许混成一句: %v", err)
|
||||
}
|
||||
if got.Text != "" || got.Source != "" {
|
||||
t.Errorf("失败时不该交出半个结论: %+v", got)
|
||||
}
|
||||
if !strings.Contains(out.String(), "reason="+browserFallbackReasonBudget) {
|
||||
t.Errorf("日志里必须留下原因码(实得:%s)", out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "source="+ioLogSource) {
|
||||
t.Errorf("这条日志必须带来源标签(AGENTS §10):%s", out.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ⑦ 端到端(走生产接线的选项):预算耗尽的那一轮,web-fetch 的错误里**两条档的真实遭遇
|
||||
// 都要在** —— 直连为什么没成 + 浏览器这一档为什么**没试**(预算耗尽)。io 层的
|
||||
// `fetchedBy` / `browserError` 语义不许变(那是它的红线),所以这里断言的是它们的**输出**
|
||||
// 仍然照实:两条失败各自出现,谁也别替谁背锅。
|
||||
func TestBodyFallback_ExhaustedBudgetIsHonestEndToEnd(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := wsLoadConfig(t)
|
||||
var out bytes.Buffer
|
||||
a := browserTestApp(t, cfg, &out)
|
||||
srv := serveSkeletonPage(t)
|
||||
if err := a.caps.RegisterTool(browsertools.New(
|
||||
newWiredBrowserManager(t, cfg, endpointHost(srv)))); err != nil {
|
||||
t.Fatalf("注册 browser 工具: %v", err)
|
||||
}
|
||||
tool := fallbackFetchTool(t, a, srv)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
got, err := tool.Execute(ctx, map[string]any{"url": srv.URL})
|
||||
if err == nil {
|
||||
t.Fatalf("两条档都没成时必须报错(实得 %#v)", got)
|
||||
}
|
||||
t.Logf("实测 web-fetch 错误:%v", err)
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "browser fallback failed too") {
|
||||
t.Errorf("io 层「两条失败都照实报」的口径不许变: %v", err)
|
||||
}
|
||||
if !strings.Contains(msg, browserFallbackReasonBudget) {
|
||||
t.Errorf("浏览器那一档要如实说「预算耗尽 ⇒ 未尝试」: %v", err)
|
||||
}
|
||||
if strings.Contains(msg, "启动本机浏览器失败") {
|
||||
t.Errorf("预算耗尽不许被说成浏览器故障(真去起了浏览器): %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Errorf("失败时不该交出结果: %#v", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user