From 9241c5dcd104613375d50227599348b5315f6bb7 Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 9 Oct 2026 08:41:42 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=A1=A5=20panic=20=E5=85=9C=E5=BA=95?= =?UTF-8?q?=EF=BC=8C=E9=98=B2=E6=AD=A2=E5=B0=86=E6=9D=A5=E4=BB=BB=E4=BD=95?= =?UTF-8?q?=E5=86=85=E9=83=A8=E9=94=99=E8=AF=AF=E5=AF=BC=E8=87=B4=E6=95=B4?= =?UTF-8?q?=E8=BF=9B=E7=A8=8B=E6=B6=88=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一提交修了并发 map 写的问题(fatal error,不可 recover)。 这次给引擎加一层普通 panic 的兜底: - safeRunTask:画廊级别,panic 时把该画廊标记失败 + 记日志 - safeDownloadPage:页级别,一页出错不带走整个 worker - 恢复代码本身再包一层 recover —— 万一 panic 根因就是 store 出问题, 恢复逻辑不该二次崩溃逃逸出去 这不能替代修 bug(runtime fatal error 依然拦不住), 但能保证「将来再出别的内部错误」时用户能看到日志, 而不是应用无声消失、已下载的文件也白搭。 TestSafeRunTaskRecoversPanic 验证 recover 生效 (该测试在写第一版时还真抓出了恢复代码的二次 panic 隐患)。 --- internal/core/crash_test.go | 57 +++++++++++++++++++++++++++++++++++++ internal/core/engine.go | 47 ++++++++++++++++++++++++++++-- 2 files changed, 101 insertions(+), 3 deletions(-) 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 {