修第三批 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
+208
View File
@@ -0,0 +1,208 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/orzogc/acfundanmu"
)
// 直接摆布全局任务状态:测试里只碰这几个标志位,用完恢复
func withJobFlags(t *testing.T, active, paused, stopped bool) {
t.Helper()
job.Lock()
prevActive, prevPaused, prevStopped, prevProc := job.active, job.paused, job.stopped, job.proc
job.active, job.paused, job.stopped = active, paused, stopped
job.Unlock()
t.Cleanup(func() {
job.Lock()
job.active, job.paused, job.stopped, job.proc = prevActive, prevPaused, prevStopped, prevProc
job.Unlock()
})
}
// resX + length 为 0(PlayResX 配置异常 + 空内容)时不能整数除零
func TestScrollMarksFallsBackWhenTotalIsZero(t *testing.T) {
emerge, left := scrollMarks(1000, 0, 0, danmuShowDur)
if emerge != 1000+danmuShowDur/2 {
t.Fatalf("行程为 0 时 emerge 应当退到半个停留时长,实际 %d", emerge-1000)
}
if left != 1000+danmuShowDur {
t.Fatalf("行程为 0 时 left 应当退到一个停留时长,实际 %d", left-1000)
}
}
// 端到端:配置里 PlayResX 为 0 又收到一条空弹幕时,写入器不能 panic
func TestDanmuWriterSurvivesZeroPlayRes(t *testing.T) {
dir := t.TempDir()
cfg := acfundanmu.SubConfig{Title: "测试", PlayResX: 0, PlayResY: 0,
FontSize: 40, StartTime: 1700000000000000000}
w := newDanmuWriter("liveid", "stream", cfg, filepath.Join(dir, "zero.ass"))
c := &acfundanmu.Comment{Content: ""} // 空内容 → 宽度 0,和 PlayResX=0 一起把分母变成 0
c.SendTime = (cfg.StartTime + int64(time.Second)) / 1e6
c.UserID = 1
c.Nickname = "甲"
w.write(c) // 原来这里整数除零 panic,会终止整场录制
if err := w.close(); err != nil {
t.Fatalf("关文件不该报错:%v", err)
}
if w.failed() {
t.Fatalf("写盘不该出错:%v", w.err)
}
// 这条弹幕仍要正常出现在 jsonl 里
b, err := os.ReadFile(filepath.Join(dir, "zero.jsonl"))
if err != nil {
t.Fatal(err)
}
if lines := strings.Split(strings.TrimSpace(string(b)), "\n"); len(lines) != 2 {
t.Fatalf("应当写出 1 条弹幕记录(加首行元数据共 2 行),实际 %d 行", len(lines))
}
}
// 任务已请求停止时,jobAttach 要就地结束刚启动的进程,
// 否则用户点了停止还得等整个块渲完
func TestJobAttachKillsProcessWhenStopped(t *testing.T) {
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
cmd = exec.Command("cmd", "/c", "ping", "-n", "30", "127.0.0.1")
} else {
cmd = exec.Command("sleep", "30")
}
if err := cmd.Start(); err != nil {
t.Skipf("起不了测试子进程,跳过:%v", err)
}
defer func() { _ = cmd.Process.Kill() }()
withJobFlags(t, true, false, true) // 任务已经处于「已请求停止」
jobAttach(cmd)
defer jobDetach(cmd)
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("任务已请求停止时,jobAttach 应当就地结束刚启动的进程,不能等它自己跑完")
}
}
// 挂起失败(进程已经不在)时不能留着「已暂停」的状态
func TestJobPauseRollsBackStateWhenSuspendFails(t *testing.T) {
withJobFlags(t, true, false, false)
fake := &exec.Cmd{Process: &os.Process{Pid: 999999}} // 不存在的进程号,挂起必然失败
jobAttach(fake)
defer jobDetach(fake)
if err := jobPause(); err == nil {
t.Fatal("挂起一个不存在的进程应当报错")
}
job.Lock()
paused := job.paused
job.Unlock()
if paused {
t.Fatal("挂起失败后不该留着「已暂停」:界面会显示已暂停,而 ffmpeg 其实还在跑")
}
}
// 只有标记成临时的 jsonl 才能删,正常同名 jsonl 一个字节都不能动
func TestDropTempDanmuOnlyRemovesTempFile(t *testing.T) {
dir := t.TempDir()
keep := filepath.Join(dir, "keep.jsonl")
if err := os.WriteFile(keep, []byte("{}\n"), 0644); err != nil {
t.Fatal(err)
}
dropTempDanmu(danmuSource{jsonlPath: keep})
if _, err := os.Stat(keep); err != nil {
t.Fatalf("正常的同名 jsonl 不能被删掉:%v", err)
}
tmp := filepath.Join(dir, "tmp.jsonl")
if err := os.WriteFile(tmp, []byte("{}\n"), 0644); err != nil {
t.Fatal(err)
}
dropTempDanmu(danmuSource{jsonlPath: tmp, tempJsonl: true})
if _, err := os.Stat(tmp); err == nil {
t.Fatal("标记为临时的还原结果应当在收尾时删掉")
}
}
// 还原结果写不进原目录时退回系统临时目录,并且要打上临时标记
func TestPrepareDanmuSourceMarksTempFile(t *testing.T) {
dir := t.TempDir()
assPath := filepath.Join(dir, "bom.ass")
if err := os.WriteFile(assPath, []byte(plainAssFixture), 0644); err != nil {
t.Fatal(err)
}
// 同名 jsonl 占成只读:写不进去,只能退到临时目录
jsonlPath := filepath.Join(dir, "bom.jsonl")
if err := os.WriteFile(jsonlPath, []byte("{}\n"), 0444); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(jsonlPath, 0644) })
src, err := prepareDanmuSource(assPath, true)
if err != nil {
t.Skipf("这台机器上没能造出「写不进去」的条件,跳过:%v", err)
}
if !src.tempJsonl {
t.Skip("写进原目录成功了(文件系统不认只读属性),跳过")
}
if src.jsonlPath == jsonlPath {
t.Fatal("退回临时文件时 jsonlPath 不该还指着原路径")
}
if _, err := os.Stat(src.jsonlPath); err != nil {
t.Fatalf("临时文件应当已经写好:%v", err)
}
dropTempDanmu(src)
if _, err := os.Stat(src.jsonlPath); err == nil {
t.Fatal("临时文件应当在收尾时被删掉")
}
}
// 带 UTF-8 BOM 的 ASS 也要能整份解析出来
func TestParseAssFileAcceptsBOM(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "bom.ass")
if err := os.WriteFile(p, []byte("\ufeff"+plainAssFixture), 0644); err != nil {
t.Fatal(err)
}
meta, records, err := parseAssFile(p)
if err != nil {
t.Fatalf("带 BOM 的 ASS 应当能正常解析,实际报错:%v", err)
}
if meta.PlayResX != 1920 || meta.PlayResY != 1080 {
t.Fatalf("分辨率应当解析出来,实际 %dx%d", meta.PlayResX, meta.PlayResY)
}
if meta.FontSize != 48 {
t.Fatalf("字号应当解析出来,实际 %d", meta.FontSize)
}
if len(records) != 2 {
t.Fatalf("应当读到 2 条弹幕(Comment 那行不算),实际 %d", len(records))
}
}
// 停止后下载头像不该把三次重试耗完(原来最坏要等 96 秒才响应停止)
func TestEnsureAvatarGivesUpAtOnceWhenStopped(t *testing.T) {
withJobFlags(t, true, false, true)
dir := t.TempDir()
t0 := time.Now()
got := ensureAvatar(424242, "http://127.0.0.1:1/none.png", dir)
elapsed := time.Since(t0)
if got != "" {
t.Fatalf("停止状态下不该返回头像路径,实际 %q", got)
}
if elapsed > 3*time.Second {
t.Fatalf("停止后应当立刻放弃这个头像,实际等了 %v", elapsed)
}
}