Files
acfunlive-remake/recordjsonl_test.go
T
tamakyi 9b9b3fa2cd 弹幕写盘出错不再被静默吞掉
审查发现:录制时每写一条弹幕都调用 os.File.Write / fmt.Fprintf,
但返回值一个都没接。磁盘写满、外接盘或网络盘掉线时,写入会失败而程序
毫无察觉 —— 照样打印「录制完成」、照样自动合并,最后拿到的成片缺了一大截
弹幕,界面上看不出是「缺了」还是「本来就没弹幕」。

1. 写入器记住写盘错误
   danmuWriter 增加一个 err 字段:每条弹幕的 ass / nick.ass / jsonl 写入,
   以及文件头的写入,都检查返回值。只保留第一个错误 —— 磁盘满了之后
   每写一条都会失败,全记下来只会把日志刷爆,而第一个就足以说明原因。

2. close() 负责把错误交出来
   三个文件的 Close 错误也要记:os.File 在部分系统上(NFS、某些网络盘)
   是到 Close 才报出「写不进去」,只盯每次 Write 的返回值会漏掉这种情况。
   getDanmu 里拿到错误就明确告警,并把这条记录标记成「弹幕文件写坏了」。

3. 写坏的本场跳过自动合并
   光打一条日志不够 —— 照样会合成一个缺弹幕的成片。liveInfo 增加
   danmuWriteFailed,finishPart 据此跳过合并,录播和弹幕文件都留着,
   用户检查磁盘后还能手动合并。

已验证:
- 新增 4 个用例:
  TestDanmuWriteErrorSurfacedOnClose(写入失败后 failed() 为真、
  close() 必须把错误交出来、错误里能看出是哪个文件)、
  TestDanmuWriteKeepsOnlyFirstError(只记第一个)、
  TestDanmuWriteNoFalseAlarm(正常录制不能误判,这是加检查最容易踩的反向坑)、
  TestFinishPartSkipsMergeWhenDanmuBroken(先跑一个「条件都满足应当合并」
  的对照组确认测试本身有效,再验证标了写坏之后不合并)。
- 反向验证:把 jsonl 的两处 Write 错误改回丢弃,
  TestDanmuWriteErrorSurfacedOnClose 立刻报「failed() 应为 true」。
- 测试踩到的两个坑(都记进注释):替换 w.jsonl 前要先关掉原句柄否则
  Windows 上临时目录删不掉;测试里没跑 initialize(),lInfoMap.info 是 nil,
  直接赋值会 panic。
- gofmt / go vet / go build / GOOS=linux go build / go test / go test -race 全过,
  全量测试连跑两次稳定。
2026-09-26 03:14:07 +08:00

244 lines
7.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/orzogc/acfundanmu"
)
// 用真正的录制写入器(danmuWriter)产生 jsonl,再按合并的流程重排一遍,
// 验证「本程序自己录下来的弹幕」合出来和以前一模一样(弹道、宽度都不变)。
// 这也是手动合并与自动合并的分水岭:录制侧写文件完全不经过 relayoutLanes,
// 只有合并渲染前会重排,所以磁盘上的 .ass/.nick.ass/.jsonl 一个字节都不变。
func TestLiveRecordingPathUnaffected(t *testing.T) {
dir := t.TempDir()
cfg := acfundanmu.SubConfig{Title: "测试直播", PlayResX: 1920, PlayResY: 1080,
FontSize: 48, StartTime: 1700000000000000000}
ass := filepath.Join(dir, "live.ass")
w := newDanmuWriter("liveid", "stream", cfg, ass)
texts := []string{"来啦", "这首歌好听", "主播加油啊", "打卡打卡", "哈哈哈哈哈哈哈哈哈",
"这条弹幕稍微长一点看看弹道到底怎么排"}
for i := 0; i < 500; i++ {
relMs := int64(i) * 220 // 平均 0.22 秒一条,够密集
c := &acfundanmu.Comment{Content: texts[i%len(texts)]}
c.SendTime = (cfg.StartTime + relMs*1e6) / 1e6
c.UserID = int64(2000 + i%9)
c.Nickname = "观众" + string(rune('A'+i%9))
c.Avatar = "http://example.invalid/a.png"
w.write(c)
}
w.close()
f, err := os.Open(filepath.Join(dir, "live.jsonl"))
if err != nil {
t.Fatal(err)
}
defer f.Close()
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 1<<20), 1<<20)
var meta danmuMeta
first := true
var recs []danmuRecord
for sc.Scan() {
if first {
json.Unmarshal(sc.Bytes(), &meta)
first = false
continue
}
var r danmuRecord
if json.Unmarshal(sc.Bytes(), &r) == nil {
recs = append(recs, r)
}
}
type snap struct{ lane, width int }
before := make([]snap, len(recs))
for i, r := range recs {
before[i] = snap{r.Lane, r.Width}
}
relayoutLanes(recs, meta)
laneDiff, widthDiff := 0, 0
for i, r := range recs {
if r.Lane != before[i].lane {
laneDiff++
if laneDiff <= 3 {
t.Logf("第 %d 条弹道变了:%d → %d", i, before[i].lane, r.Lane)
}
}
if r.Width != before[i].width {
widthDiff++
}
}
t.Logf("录制产生 %d 条弹幕(含空内容 %d 条),重排后弹道变化 %d 条、宽度变化 %d 条",
len(recs), countEmpty(recs), laneDiff, widthDiff)
if laneDiff != 0 || widthDiff != 0 {
t.Errorf("正常录制的弹幕重排后不应有任何变化")
}
}
func countEmpty(recs []danmuRecord) int {
n := 0
for _, r := range recs {
if r.Content == "" {
n++
}
}
return n
}
// ==================== 写盘出错必须能被发现 ====================
// 弹幕是边录边写的,写盘失败(磁盘满、外接盘/网络盘掉线)如果没人管,
// 程序会照常宣告「录制完成」并自动合并 —— 出来的成片缺一大截弹幕,
// 而日志里什么都看不出来。下面几条例外情况锁住「失败要浮出来」。
// 写入失败必须被记下来,并且在 close() 时交出来
func TestDanmuWriteErrorSurfacedOnClose(t *testing.T) {
dir := t.TempDir()
cfg := acfundanmu.SubConfig{Title: "测试直播", PlayResX: 1920, PlayResY: 1080,
FontSize: 48, StartTime: 1700000000000000000}
ass := filepath.Join(dir, "live.ass")
w := newDanmuWriter("liveid", "stream", cfg, ass)
if w.failed() {
t.Fatalf("刚建好的写入器不该有错误,实际:%v", w.err)
}
// 用「写到已关闭的句柄」来走同一条错误路径(和磁盘满、盘掉线一样,
// 都是 os.File.Write 返回 error)。换掉之前先把原来的句柄关掉,别泄漏
if err := w.jsonl.Close(); err != nil {
t.Fatal(err)
}
dead, err := os.Open(filepath.Join(dir, "live.jsonl"))
if err != nil {
t.Fatal(err)
}
if err := dead.Close(); err != nil {
t.Fatal(err)
}
w.jsonl = dead
c := &acfundanmu.Comment{Content: "这条应该写失败"}
c.SendTime = (cfg.StartTime + int64(500)*1e6) / 1e6
c.UserID = 7
c.Nickname = "观众"
w.write(c)
if !w.failed() {
t.Fatal("写入失败之后 failed() 应为 true(改之前错误是被丢掉的)")
}
err = w.close()
if err == nil {
t.Fatal("close() 必须把写盘错误交出来,实际返回 nil")
}
if !strings.Contains(err.Error(), "jsonl") {
t.Errorf("错误信息里要能看出是哪个文件出的问题,实际:%v", err)
}
}
// 只记第一个错:磁盘满了之后每写一条都会失败,全记下来会把日志刷爆
func TestDanmuWriteKeepsOnlyFirstError(t *testing.T) {
w := &danmuWriter{}
w.fail("第一次", errors.New("磁盘满了"))
first := w.err
w.fail("第二次", errors.New("还是写不进去"))
w.fail("第三次", errors.New("依旧写不进去"))
if w.err != first {
t.Fatalf("错误应保持在第一个,实际被后来的覆盖了:%v", w.err)
}
if !strings.Contains(w.err.Error(), "磁盘满了") {
t.Errorf("应保留第一个错误的原因,实际:%v", w.err)
}
}
// 正常录制不能被误判成失败(这是「加检查」最容易踩的反向坑)
func TestDanmuWriteNoFalseAlarm(t *testing.T) {
dir := t.TempDir()
cfg := acfundanmu.SubConfig{Title: "测试直播", PlayResX: 1920, PlayResY: 1080,
FontSize: 48, StartTime: 1700000000000000000}
w := newDanmuWriter("liveid", "stream", cfg, filepath.Join(dir, "ok.ass"))
for i := 0; i < 30; i++ {
c := &acfundanmu.Comment{Content: "正常弹幕"}
c.SendTime = (cfg.StartTime + int64(i)*220*1e6) / 1e6
c.UserID = int64(100 + i)
c.Nickname = "观众"
w.write(c)
}
if w.failed() {
t.Fatalf("正常写入不该判失败:%v", w.err)
}
if err := w.close(); err != nil {
t.Fatalf("正常写完后 close() 应返回 nil,实际:%v", err)
}
}
// 弹幕文件写坏之后必须跳过自动合并:只是打一条日志的话,
// 照样会合成一个「缺了一大截弹幕」的成片,而且是静默的
func TestFinishPartSkipsMergeWhenDanmuBroken(t *testing.T) {
oldAuto, oldCtx := config.AutoMerge, mainCtx
t.Cleanup(func() {
config.AutoMerge, mainCtx = oldAuto, oldCtx
// 等后台那两个(可能启动了的)合并跑完,别把任务状态留给后面的用例
takeMergeSlot()
releaseMergeSlot()
})
config.AutoMerge = true
mainCtx = context.Background()
dir := t.TempDir()
video := filepath.Join(dir, "v.mp4")
ass := filepath.Join(dir, "v.ass")
if err := os.WriteFile(video, []byte("fake"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(ass, []byte("[Script Info]"), 0644); err != nil {
t.Fatal(err)
}
// 测试里没跑 initialize(),这张表还没建起来
lInfoMap.Lock()
if lInfoMap.info == nil {
lInfoMap.info = make(map[string]liveInfo)
}
lInfoMap.Unlock()
// 对照组:一切都正常时应该触发合并
good := "good-live-id"
lInfoMap.Lock()
lInfoMap.info[good] = liveInfo{LiveID: good, uid: 1, assFile: ass, firstRecordFile: video,
recordStartNs: time.Now().UnixNano(), recordSegments: []recordSegment{{file: video, startNs: time.Now().UnixNano()}}}
lInfoMap.Unlock()
finishPart(good, true)
finishPart(good, false)
lInfoMap.RLock()
started := lInfoMap.info[good].mergeStarted
lInfoMap.RUnlock()
if !started {
t.Fatal("对照组:条件都满足时应当触发自动合并(否则这条测试本身没意义)")
}
// 实验组:同样条件,但弹幕写盘出过错
bad := "bad-live-id"
lInfoMap.Lock()
lInfoMap.info[bad] = liveInfo{LiveID: bad, uid: 2, assFile: ass, firstRecordFile: video,
danmuWriteFailed: true,
recordStartNs: time.Now().UnixNano(), recordSegments: []recordSegment{{file: video, startNs: time.Now().UnixNano()}}}
lInfoMap.Unlock()
finishPart(bad, true)
finishPart(bad, false)
lInfoMap.RLock()
got := lInfoMap.info[bad].mergeStarted
lInfoMap.RUnlock()
if got {
t.Error("弹幕文件写坏过,不该触发自动合并(否则会合成一个缺弹幕的成片)")
}
}