fix: 补 panic 兜底,防止将来任何内部错误导致整进程消失

上一提交修了并发 map 写的问题(fatal error,不可 recover)。
这次给引擎加一层普通 panic 的兜底:

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

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

TestSafeRunTaskRecoversPanic 验证 recover 生效
(该测试在写第一版时还真抓出了恢复代码的二次 panic 隐患)。
This commit is contained in:
2026-10-09 08:41:42 +08:00
parent 27ca6e08a0
commit 9241c5dcd1
2 changed files with 101 additions and 3 deletions
+57
View File
@@ -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。
//