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) } } }