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:
@@ -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 的形态),
|
// 精准复现崩溃窗口:一路持续写快照里的 map(原 bug 的形态),
|
||||||
// 另一路持续序列化快照。若快照与原 task 共享内存,这里会直接 fatal。
|
// 另一路持续序列化快照。若快照与原 task 共享内存,这里会直接 fatal。
|
||||||
//
|
//
|
||||||
|
|||||||
+44
-3
@@ -216,7 +216,7 @@ func (e *Engine) Start(ctx context.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
e.runTask(ctx, t)
|
e.safeRunTask(ctx, t)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -246,6 +246,27 @@ func (e *Engine) Start(ctx context.Context) {
|
|||||||
wg.Wait()
|
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) {
|
func (e *Engine) runTask(ctx context.Context, task Task) {
|
||||||
task.Status = "running"
|
task.Status = "running"
|
||||||
task.Error = ""
|
task.Error = ""
|
||||||
@@ -336,7 +357,7 @@ func (e *Engine) runTask(ctx context.Context, task Task) {
|
|||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
for p := range work {
|
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() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
for p := range work {
|
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)
|
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 次退避重试
|
// downloadPage 下单页(解析 URL → 下载),内部含 4 次退避重试
|
||||||
func (s *pageState) downloadPage(ctx context.Context, p eh.GalleryPageInfo) {
|
func (s *pageState) downloadPage(ctx context.Context, p eh.GalleryPageInfo) {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user