修第三批 P2:除零、停止/暂停的响应与状态一致性、临时文件、带 BOM 的 ASS

一、弹幕滚动时间不再整数除零
   write() 里拿 (PlayResX+length) 做除数,PlayResX 被配成 0 又收到一条空内容弹幕时
   分母正好是 0,直接 panic 终止整场录制。抽成 scrollMarks 并给分母为 0 的兜底
   (退回半个停留时长,至少让它正常显示出来)。

二、点停止不再要等整个块渲完
   jobAttach 原来只看 paused:停止请求若落在 jobGate 之后、ffmpeg 启动过程中,
   job.proc 还没登记,jobStop 拿不到进程可杀,用户得等整个块渲完(大块要几分钟)。
   现在 attach 时发现任务已请求停止就地把它杀掉,上层拿到 errJobStopped,
   卡片显示的是「已停止」而不是「失败」。

三、暂停没挂住就回滚状态
   jobPause 先把 job.paused 置 true 再调 suspendProcess,挂起失败时状态已经改了:
   界面显示「已暂停」而 ffmpeg 还在跑。失败时回滚(只在 proc 仍是同一个进程时回滚)。
   jobResume 同理:恢复不成放回「已暂停」,免得显示在跑却停着。

四、.ass 还原的临时 jsonl 用完就删
   还原结果写不进录播目录时会退到系统临时目录的固定名文件,原来从不清理,
   每失败一次留一份。新增 danmuSource.tempJsonl 标记 + dropTempDanmu 收尾删除;
   只删标记过的临时文件 —— 正常情况下那个路径是用户录播目录里的同名 jsonl,
   删了就是丢数据。

五、带 BOM 的 ASS 能整份解析
   带 UTF-8 BOM 时首行是 BOM+[Script Info],一个节都进不去,最后只报
   「可能不是弹幕文件」,跟真实原因完全对不上。解析时剥掉 BOM。

六、头像下载期间暂停/停止不用再干等
   单次超时 30 秒 × 3 次重试,最坏 96 秒才响应暂停/停止。单次超时收到 10 秒
   (头像只有几百 KB),并且每次重试前过闸门:暂停在这里等、停止直接放弃,
   不再把剩下的重试跑完;停止也不再打「头像不可用」的告警。

验证:
- gofmt -l 空;go vet ./... 空;go build 与 GOOS=linux go build 通过
- go test ./... 全绿(10.9s);go test -race ./... 全绿(14.5s)
- 新增 p2_fixes_test.go 8 个用例;本轮 6 项加上一批 6 项共 12 项反向验证全部通过
  (每项临时退回源码 → 对应测试确实失败 → 恢复)
- 51897 隔离实例上完整复测:e2e.py 37 项全过、smoke_extra.py 33 项全过
This commit is contained in:
tamakyi
2026-09-26 10:07:30 +08:00
parent 9684346067
commit a24425e16d
6 changed files with 289 additions and 12 deletions
+17 -3
View File
@@ -169,7 +169,10 @@ func runDanmuburn(danmuFile, videoFile, outFile string, offset float64) {
lPrintErr(err)
os.Exit(1)
}
// 还原结果写不进原目录时会落在系统临时目录里,收工前删掉。
// 这里到处都是 os.Exit,defer 不会被执行,所以显式调用
if err := burnDanmu(src.jsonlPath, videoFile, outFile, offset); err != nil {
dropTempDanmu(src)
if err == errNoDanmu {
lPrintWarn(err.Error())
os.Exit(0)
@@ -177,6 +180,7 @@ func runDanmuburn(danmuFile, videoFile, outFile string, offset float64) {
lPrintErr(err)
os.Exit(1)
}
dropTempDanmu(src)
}
// 合成一个 jsonl(和可选的视频)为带头像的弹幕层
@@ -1104,10 +1108,18 @@ func ensureAvatar(uid int64, url, cacheDir string, legacyDirs ...string) string
}
err := runThrice(func() error {
// 每次重试前过闸门:暂停就在这里等着(不会把剩下的重试继续跑完),
// 停止就直接放弃 —— 否则点了暂停/停止还得等这个头像把三次重试耗完
if err := jobGate(); err != nil {
return noRetry(err)
}
return downloadAvatar(uid, url, path)
})
if err != nil {
lPrintWarnf("uid %d 的头像不可用,该用户的弹幕只烧录文字不带头像:%v", uid, err)
// 停止不算故障,不用报「头像不可用」
if !errors.Is(err, errJobStopped) {
lPrintWarnf("uid %d 的头像不可用,该用户的弹幕只烧录文字不带头像:%v", uid, err)
}
return ""
}
return path
@@ -1121,8 +1133,10 @@ const (
avatarMaxPixels = 2048 * 2048
)
// 复用连接:一场直播要下几百个头像,每个都新建 client 就是几百次握手
var avatarClient = &http.Client{Timeout: 30 * time.Second}
// 复用连接:一场直播要下几百个头像,每个都新建 client 就是几百次握手。
// 超时给 10 秒:头像只有几百 KB,超过就说明这个 CDN 节点不通,
// 早点超时能早点重试/过闸门,用户点暂停时也不用干等半分钟
var avatarClient = &http.Client{Timeout: 10 * time.Second}
func downloadAvatar(uid int64, url, path string) error {
req, err := http.NewRequest("GET", url, nil)