Files
eh-downloader/internal/core/crash_test.go
T
toor 9241c5dcd1 fix: 补 panic 兜底,防止将来任何内部错误导致整进程消失
上一提交修了并发 map 写的问题(fatal error,不可 recover)。
这次给引擎加一层普通 panic 的兜底:

- safeRunTask:画廊级别,panic 时把该画廊标记失败 + 记日志
- safeDownloadPage:页级别,一页出错不带走整个 worker
- 恢复代码本身再包一层 recover —— 万一 panic 根因就是 store 出问题,
  恢复逻辑不该二次崩溃逃逸出去

这不能替代修 bug(runtime fatal error 依然拦不住),
但能保证「将来再出别的内部错误」时用户能看到日志,
而不是应用无声消失、已下载的文件也白搭。

TestSafeRunTaskRecoversPanic 验证 recover 生效
(该测试在写第一版时还真抓出了恢复代码的二次 panic 隐患)。
2026-10-09 08:41:42 +08:00

328 lines
8.1 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 core
// 崩溃复现测试
//
// 曾经的 bug:snapshotLocked 用浅拷贝把 Task 里的 Pages map 直接交出去,
// 而其他页 worker 还在写同一个 map —— 序列化时撞上写入,
// Go runtime 抛 fatal error: concurrent map read and map write,
// 不可 recover,整个进程静默退出。
//
// 症状:图片多的画廊 + 高页并发时才偶发,因为并发写窗口更长。
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"sync"
"testing"
"time"
"eh-downloader-wails/internal/eh"
)
// marshalSink 模拟前端事件通道:拿到 Task 就做 JSON 序列化(等价于 EventsEmit)
type marshalSink struct{}
func (marshalSink) TaskUpdate(t Task) {
// Wails 内部就是 json.Marshal(task),这里复刻同一件事
if _, err := json.Marshal(t); err != nil {
panic(err)
}
}
func (marshalSink) Log(string, string) {}
func (marshalSink) Done() {}
// 快照必须与原 task 完全隔离:改快照不能影响原始,反之亦然
func TestSnapshotDoesNotSharePagesMap(t *testing.T) {
st := &pageState{
task: &Task{GID: "1", Token: "t", Pages: map[string]string{}},
dir: t.TempDir(),
gid: "1",
token: "t",
total: 3,
done: map[int]bool{},
failed: map[int]string{},
}
st.pages = []eh.GalleryPageInfo{
{Page: 1, Filename: "001.png"},
{Page: 2, Filename: "002.png"},
{Page: 3, Filename: "003.png"},
}
st.scanExisting()
// 模拟下载完 1 页
st.mu.Lock()
st.done[1] = true
st.task.Pages["1"] = "done"
snap := st.snapshotLocked()
st.mu.Unlock()
// 立刻序列化快照(Wails 会做的事)
if _, err := json.Marshal(snap); err != nil {
t.Fatal(err)
}
// 原 task 继续被改(模拟其他 worker 同时在下载)
st.mu.Lock()
st.done[2] = true
st.task.Pages["2"] = "done"
st.mu.Unlock()
// 快照不该跟着长 —— 共享同一块 map 的话快照也会看到 "2"
if _, leaked := snap.Pages["2"]; leaked {
t.Error("快照的 Pages map 与原 task 共享内存(浅拷贝),并发下会崩")
}
}
// FailedList 也必须深拷贝
func TestSnapshotDoesNotShareFailedList(t *testing.T) {
st := &pageState{
task: &Task{GID: "1", Token: "t", Pages: map[string]string{}},
dir: t.TempDir(),
gid: "1",
total: 3,
done: map[int]bool{},
failed: map[int]string{},
}
st.scanExisting()
st.mu.Lock()
st.failed[1] = "boom"
st.failedL = append(st.failedL, FailedItem{Page: 1, File: "001.png", Error: "boom"})
snap := st.snapshotLocked()
st.mu.Unlock()
st.mu.Lock()
st.failed[2] = "boom2"
st.failedL = append(st.failedL, FailedItem{Page: 2, File: "002.png", Error: "boom2"})
st.mu.Unlock()
if len(snap.FailedList) != 1 {
t.Errorf("快照的 FailedList 跟着原切片增长了(应深拷贝): %+v", snap.FailedList)
}
}
// 真实并发压测:200 页 + 页并发 20 + 每页都触发事件序列化。
// 若仍有共享 map 问题,这个用例会崩进程或被 -race 抓到。
func TestConcurrentPageEventsNoCrash(t *testing.T) {
const total = 200 // 大画廊
m := newMockEH(total)
defer m.Close()
restore := m.installBase()
defer restore()
root := t.TempDir()
s, _ := newTestStore(t)
s.UpdateSettings(map[string]interface{}{
"downloadRoot": root,
"dirTemplate": "{gid}",
"galleryConcurrency": 1,
"pageConcurrency": 20,
"timeout": 10000,
})
e := NewEngine(s, &marshalSink{}, s.Settings())
s.AddTasks([]eh.GalleryRef{{GID: "777777", Token: "tok"}})
e.Enqueue(s.Tasks())
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
done := make(chan struct{})
go func() { e.Start(ctx); close(done) }()
select {
case <-done:
case <-time.After(110 * time.Second):
t.Fatal("大画廊下载超时")
}
task := s.FindTask("777777", "tok")
if task == nil {
t.Fatal("任务不见了")
}
if task.Status != "done" {
t.Errorf("应全部下完,实际 %s (done=%d failed=%d)", task.Status, task.Done, task.Failed)
}
if task.Done != total {
t.Errorf("应下 %d 张,实际 %d", total, task.Done)
}
for _, p := range []int{1, 50, 100, 200} {
name := filepath.Join(root, "777777", fmt.Sprintf("%03d.png", p))
if st, err := os.Stat(name); err != nil || st.Size() == 0 {
t.Errorf("第 %d 张图缺失", p)
}
}
}
// panic 兜底:将来若再有别处的 bug 导致 panic,
// 不该让整个应用无声消失,至少要留下错误记录。
func TestSafeRunTaskRecoversPanic(t *testing.T) {
s, _ := newTestStore(t)
sink := &panicSink{}
e := NewEngine(s, sink, s.Settings())
// 故意把 store 置 nil,让 runTask 内部第一处解引用就 panic。
// safeRunTask 必须吃掉它、记一条错误日志,而不是让 panic 逃逸出去。
e.store = nil
done := make(chan struct{})
go func() {
defer close(done)
defer func() {
if r := recover(); r != nil {
t.Errorf("safeRunTask 没有拦住 panic: %v", r)
}
}()
e.safeRunTask(context.Background(), Task{GID: "999", Token: "tok"})
}()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("safeRunTask 卡住了")
}
if !sink.sawError() {
t.Error("panic 应该被记录成错误日志,好让用户知道出了什么事")
}
}
// panicSink 记录错误日志
type panicSink struct {
mu sync.Mutex
errs []string
}
func (p *panicSink) TaskUpdate(Task) {}
func (p *panicSink) Log(level, msg string) {
if level == "error" {
p.mu.Lock()
p.errs = append(p.errs, msg)
p.mu.Unlock()
}
}
func (p *panicSink) Done() {}
func (p *panicSink) sawError() bool {
p.mu.Lock()
defer p.mu.Unlock()
return len(p.errs) > 0
}
// 精准复现崩溃窗口:一路持续写快照里的 map(原 bug 的形态),
// 另一路持续序列化快照。若快照与原 task 共享内存,这里会直接 fatal。
//
// 这是用户报告的崩溃场景:大画廊 + 高页并发时偶发闪退。
func TestSnapshotRaceWindowClosed(t *testing.T) {
st := &pageState{
task: &Task{GID: "1", Token: "t", Pages: map[string]string{}},
dir: t.TempDir(),
gid: "1",
total: 200,
done: map[int]bool{},
failed: map[int]string{},
}
for i := 1; i <= 200; i++ {
st.pages = append(st.pages, eh.GalleryPageInfo{
Page: i, Filename: fmt.Sprintf("%03d.png", i),
})
}
st.scanExisting()
// 快照(模拟发给 UI 的事件)
snap := st.snapshot()
// 一路持续写原 task(模拟页 worker)
stop := make(chan struct{})
var writerDone sync.WaitGroup
writerDone.Add(1)
go func() {
defer writerDone.Done()
for {
select {
case <-stop:
return
default:
}
st.mu.Lock()
for i := 1; i <= 200; i++ {
st.task.Pages[strconv.Itoa(i)] = "done"
}
st.mu.Unlock()
}
}()
// 一路持续序列化快照
var marshalerDone sync.WaitGroup
marshalerDone.Add(1)
go func() {
defer marshalerDone.Done()
for i := 0; i < 3000; i++ {
// 修复前:这里会撞上并发写 -> fatal error
if _, err := json.Marshal(snap); err != nil {
t.Errorf("序列化失败: %v", err)
return
}
}
}()
marshalerDone.Wait()
close(stop)
writerDone.Wait()
}
// 多画廊高并发压测(更容易撞出共享状态问题)
func TestConcurrentMultiGalleryNoCrash(t *testing.T) {
const (
galleries = 5
pages = 60
)
m := newMockEH(pages)
defer m.Close()
restore := m.installBase()
defer restore()
root := t.TempDir()
s, _ := newTestStore(t)
s.UpdateSettings(map[string]interface{}{
"downloadRoot": root,
"dirTemplate": "{gid}",
"galleryConcurrency": galleries,
"pageConcurrency": 16,
"timeout": 10000,
})
e := NewEngine(s, &marshalSink{}, s.Settings())
refs := make([]eh.GalleryRef, 0, galleries)
for i := 1; i <= galleries; i++ {
gid := strconv.Itoa(800000 + i)
refs = append(refs, eh.GalleryRef{GID: gid, Token: "tok"})
}
s.AddTasks(refs)
e.Enqueue(s.Tasks())
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel()
e.Start(ctx)
for i := 1; i <= galleries; i++ {
gid := strconv.Itoa(800000 + i)
task := s.FindTask(gid, "tok")
if task == nil || task.Status != "done" {
t.Errorf("画廊 %s 没下完: %v", gid, task)
continue
}
if task.Done != pages {
t.Errorf("画廊 %s 应下 %d 张,实际 %d", gid, pages, task.Done)
}
}
}