diff --git a/internal/core/crash_test.go b/internal/core/crash_test.go index 3af99df..2fc7436 100644 --- a/internal/core/crash_test.go +++ b/internal/core/crash_test.go @@ -160,6 +160,63 @@ func TestConcurrentPageEventsNoCrash(t *testing.T) { } } +// 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。 // diff --git a/internal/core/engine.go b/internal/core/engine.go index 0c2d061..51117dc 100644 --- a/internal/core/engine.go +++ b/internal/core/engine.go @@ -216,7 +216,7 @@ func (e *Engine) Start(ctx context.Context) { if !ok { return } - e.runTask(ctx, t) + e.safeRunTask(ctx, t) } } }() @@ -246,6 +246,27 @@ func (e *Engine) Start(ctx context.Context) { wg.Wait() } +// worker 崩溃保护:Go 里普通 panic 可以 recover,但 runtime 的 +// fatal error(并发 map 读写这类)不行 —— 那会直接杀进程。 +// +// 这里 recover 的目的是「万一以后又有别处的 bug 导致 panic」时, +// 把这个画廊标记为失败、记一条日志,而不是让整个应用无声消失。 +// 用户至少能看到「哪个画廊出的问题」,已下载的文件也不会受影响。 +func (e *Engine) safeRunTask(ctx context.Context, t Task) { + defer func() { + if r := recover(); r != nil { + // 恢复代码本身也可能 panic(比如 panic 的根因就是 store 出问题), + // 所以这里再包一层,保证 recover 逻辑永远不会二次崩溃。 + func() { + defer func() { _ = recover() }() + e.log("error", fmt.Sprintf("画廊 %s 处理时发生内部错误:%v", t.GID, r)) + e.fail(t, fmt.Errorf("内部错误: %v", r)) + }() + } + }() + e.runTask(ctx, t) +} + func (e *Engine) runTask(ctx context.Context, task Task) { task.Status = "running" task.Error = "" @@ -336,7 +357,7 @@ func (e *Engine) runTask(ctx context.Context, task Task) { go func() { defer wg.Done() for p := range work { - st.downloadPage(ctx, p) + st.safeDownloadPage(ctx, p) } }() } @@ -367,7 +388,7 @@ func (e *Engine) runTask(ctx context.Context, task Task) { go func() { defer wg.Done() for p := range work { - st.downloadPage(ctx, p) + st.safeDownloadPage(ctx, p) } }() } @@ -445,6 +466,26 @@ func (s *pageState) scanExisting() { s.task.Done = len(s.done) } +// safeDownloadPage 包一层 recover:某一页的意外 panic 不该带走整个 worker。 +func (s *pageState) safeDownloadPage(ctx context.Context, p eh.GalleryPageInfo) { + defer func() { + if r := recover(); r != nil { + fname := targetName(p, s.gid, s.total) + s.mu.Lock() + s.failed[p.Page] = fmt.Sprintf("内部错误: %v", r) + s.failedL = append(s.failedL, FailedItem{ + Page: p.Page, File: fname, Error: fmt.Sprintf("内部错误: %v", r), + }) + s.task.Pages[strconv.Itoa(p.Page)] = "failed" + s.syncCountsLocked() + snap := s.snapshotLocked() + s.mu.Unlock() + s.engine.emitTask(snap) + } + }() + s.downloadPage(ctx, p) +} + // downloadPage 下单页(解析 URL → 下载),内部含 4 次退避重试 func (s *pageState) downloadPage(ctx context.Context, p eh.GalleryPageInfo) { if ctx.Err() != nil {