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。
//
+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 {