Files
Cyrene_v2/internal/capability/browser/launchoutput.go
T
AskaEth 93c5a4c46f
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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。
2026-10-07 10:37:33 +08:00

102 lines
3.4 KiB
Go

package browser
import (
"strconv"
"strings"
"sync"
"unicode/utf8"
)
// 接住子进程输出的两个上限(AGENTS §10:不出现裸魔法数字)。
const (
// chromeOutputMaxBytes 是缓存的**前缀**字节数。为什么留前 N 而不是后 N:Chrome 的
// 抱怨/崩溃信息总在开头,而尾部的常规日志量最大 —— 留头既省内存又留下原因。
chromeOutputMaxBytes = 4096
// chromeOutputMaxLines 是失败上报时最多打出的行数(前 N 行)。
chromeOutputMaxLines = 12
)
// 本文件只做一件事:**接住浏览器自己说的话**。design/28 §6 坑 5「失败要如实报」
// 的落地前提是「看得见失败」—— 启动失败时浏览器把原因写在自己的 stdout/stderr 上
// (参数被拒、profile 锁、崩溃),不接住它,日志里就只剩我们这一侧的猜测。
//
// 两条口径:
//
// - **有界**:只留前 N 字节 / 上报前 N 行。正常启动时 Chrome 也会往 stderr 写常规
// 信息,全打进日志就是刷屏;缓存也无上限地吃内存。
// - **不主动打**:本类型只缓存,**不输出**。成功时只报字节数(Debug),失败时由
// `localChrome.Dial` 一次性把前 N 行与本次 argv 一起报出来(见 launch.go)。
type chromeOutput struct {
mu sync.Mutex
head []byte
total int
truncated bool
}
// Write 实现 io.Writer。stdout 与 stderr 是两条独立的管道,`os/exec` 为它们各起一个
// 复制协程 ⇒ 必须加锁。它**永不返回错误**:缓存失败不能反过来弄坏子进程的写入。
func (o *chromeOutput) Write(p []byte) (int, error) {
o.mu.Lock()
defer o.mu.Unlock()
o.total += len(p)
room := chromeOutputMaxBytes - len(o.head)
if room <= 0 {
if len(p) > 0 {
o.truncated = true
}
return len(p), nil
}
if len(p) > room {
o.head = append(o.head, p[:room]...)
o.truncated = true
return len(p), nil
}
o.head = append(o.head, p...)
return len(p), nil
}
// totalBytes 报告子进程一共写了多少字节(截断时也照实报,不拿留下的那部分当全部)。
func (o *chromeOutput) totalBytes() int {
o.mu.Lock()
defer o.mu.Unlock()
return o.total
}
// preview 返回失败上报用的「前 N 行」:每行内的空白压成单空格、空行去掉;被截断时
// 在末尾点明**截断与总字节数**(绝不把半截输出说得像完整输出)。没有输出时返回空串,
// 调用方据此判断「浏览器一个字都没说」。
func (o *chromeOutput) preview() string {
o.mu.Lock()
raw := truncateRune(o.head)
truncated := o.truncated
total := o.total
o.mu.Unlock()
kept := make([]string, 0, chromeOutputMaxLines)
for _, line := range strings.Split(raw, "\n") {
line = strings.Join(strings.Fields(line), " ")
if line == "" {
continue
}
kept = append(kept, line)
if len(kept) == chromeOutputMaxLines {
truncated = true
break
}
}
text := strings.Join(kept, " | ")
if truncated && text != "" {
text += " | ...(已截断,共 " + strconv.Itoa(total) + " 字节)"
}
return text
}
// truncateRune 去掉尾部可能被切断的半个 UTF-8 字符(按字节截断的代价):最多回退
// utf8.UTFMax-1 个字节,不因个别非法字节丢掉整段可读输出。
func truncateRune(raw []byte) string {
for i := 0; i < utf8.UTFMax-1 && len(raw) > 0 && !utf8.Valid(raw); i++ {
raw = raw[:len(raw)-1]
}
return string(raw)
}