fix: 修复大画廊下载时进程静默闪退(fatal error: concurrent map write)

现象:点「开始下载」后,单个画廊图片较多时整个程序无报错直接退出。
只在图片多的画廊上偶发。

根因:pageState.snapshotLocked 用浅拷贝把 Task 交给 UI:
    t := *s.task          // Pages map 仍是同一块内存
快照被 EventsEmit 做 JSON 序列化时,其他页 worker 正在写同一个 map,
撞上就抛 Go runtime 的致命错误:

    fatal error: concurrent map iteration and map write

这是 runtime 级 fatal error,不可 recover,直接杀进程 —— 所以没有任何
报错提示。图片越多、页并发越高,并发写窗口越长,越容易触发。

同一问题还有第二处:Store.UpsertTask 存的是原值(含活的 map),
而 Flush() 会在后台 json.Marshal 整个 state,同样会撞上。

修复:
- snapshotLocked 深拷贝 Pages map 与 FailedList slice
- UpsertTask 存前先 clone(),让 store 完全拥有自己的数据
- 另加一个兜底 recover,防止将来再有 panic 直接杀掉进程

测试:
- TestSnapshotDoesNotSharePagesMap:验证快照隔离(修复前失败)
- TestSnapshotRaceWindowClosed:持续写原 task + 持续序列化快照,
  精准复现崩溃窗口 —— 实测修复前 fatal error 崩进程,修复后通过
- 大画廊压测:200 页 × 页并发 20,以及 5 画廊 × 60 页 × 并发 16

测试从 62 增至 67。
This commit is contained in:
2026-10-09 08:39:27 +08:00
parent 0447875b6d
commit 27ca6e08a0
3 changed files with 309 additions and 19 deletions
+20 -19
View File
@@ -350,40 +350,41 @@ func truncate(s string, n int) string {
return string(r[:n])
}
// UpsertTask 更新/插入任务
// UpsertTask 更新/插入任务。
//
// ⚠️ 存进去前必须 clone() —— 否则外部(页级 worker)继续改同一个 map,
// 而 store 的 Flush() 会在后台 json.Marshal 整个 state —— 撞上就是
// fatal error: concurrent map write(不可 recover,进程闪退)。
func (s *Store) UpsertTask(t Task) Task {
s.mu.Lock()
defer s.mu.Unlock()
t.UpdatedAt = time.Now().UnixMilli()
for i := range s.state.Tasks {
if s.state.Tasks[i].GID == t.GID && s.state.Tasks[i].Token == t.Token {
if t.Pages == nil {
t.Pages = map[string]string{}
}
if t.FailedList == nil {
t.FailedList = []FailedItem{}
}
if t.CreatedAt == 0 {
t.CreatedAt = s.state.Tasks[i].CreatedAt
}
s.state.Tasks[i] = t
s.startFlushLocked()
return t.clone()
}
}
if t.Pages == nil {
t.Pages = map[string]string{}
}
if t.FailedList == nil {
t.FailedList = []FailedItem{}
}
for i := range s.state.Tasks {
if s.state.Tasks[i].GID == t.GID && s.state.Tasks[i].Token == t.Token {
if t.CreatedAt == 0 {
t.CreatedAt = s.state.Tasks[i].CreatedAt
}
s.state.Tasks[i] = t.clone()
s.startFlushLocked()
return t.clone()
}
}
if t.CreatedAt == 0 {
t.CreatedAt = t.UpdatedAt
}
s.state.Tasks = append([]Task{t}, s.state.Tasks...)
stored := t.clone()
s.state.Tasks = append([]Task{stored}, s.state.Tasks...)
s.startFlushLocked()
return t.clone()
return stored.clone()
}
// RemoveTask 删单个任务