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
+44 -3
View File
@@ -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 {