Skip to content

Commit 0a5edd4

Browse files
hughdbrownclaude
andauthored
Bug panic rethrow crashes program (#62)
Fix panic that crashes program --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 4472abd commit 0a5edd4

File tree

3 files changed

+81
-10
lines changed

3 files changed

+81
-10
lines changed

internal/sync/incremental.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"errors"
66
"fmt"
7+
"runtime/debug"
78
"strconv"
89
"time"
910

@@ -13,9 +14,9 @@ import (
1314

1415
// Incremental performs an incremental sync using the Gmail History API.
1516
// Falls back to full sync if history is too old (404 error).
16-
func (s *Syncer) Incremental(ctx context.Context, email string) (*gmail.SyncSummary, error) {
17+
func (s *Syncer) Incremental(ctx context.Context, email string) (summary *gmail.SyncSummary, err error) {
1718
startTime := time.Now()
18-
summary := &gmail.SyncSummary{StartTime: startTime}
19+
summary = &gmail.SyncSummary{StartTime: startTime}
1920

2021
// Get source - must already exist for incremental sync
2122
source, err := s.store.GetSourceByIdentifier(email)
@@ -42,11 +43,16 @@ func (s *Syncer) Incremental(ctx context.Context, email string) (*gmail.SyncSumm
4243
return nil, fmt.Errorf("start sync: %w", err)
4344
}
4445

45-
// Defer failure handling
46+
// Defer failure handling — recover from panics and return as error
4647
defer func() {
4748
if r := recover(); r != nil {
48-
_ = s.store.FailSync(syncID, fmt.Sprintf("panic: %v", r))
49-
panic(r)
49+
stack := debug.Stack()
50+
s.logger.Error("sync panic recovered", "panic", r, "stack", string(stack))
51+
if failErr := s.store.FailSync(syncID, fmt.Sprintf("panic: %v", r)); failErr != nil {
52+
s.logger.Error("failed to record sync failure", "error", failErr)
53+
}
54+
summary = nil
55+
err = fmt.Errorf("sync panicked: %v", r)
5056
}
5157
}()
5258

internal/sync/sync.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"log/slog"
1010
"os"
1111
"path/filepath"
12+
"runtime/debug"
1213
"strconv"
1314
"time"
1415

@@ -214,9 +215,9 @@ func (s *Syncer) processBatch(ctx context.Context, sourceID int64, listResp *gma
214215
}
215216

216217
// Full performs a full synchronization.
217-
func (s *Syncer) Full(ctx context.Context, email string) (*gmail.SyncSummary, error) {
218+
func (s *Syncer) Full(ctx context.Context, email string) (summary *gmail.SyncSummary, err error) {
218219
startTime := time.Now()
219-
summary := &gmail.SyncSummary{StartTime: startTime}
220+
summary = &gmail.SyncSummary{StartTime: startTime}
220221

221222
// Get or create source
222223
source, err := s.store.GetOrCreateSource("gmail", email)
@@ -232,11 +233,16 @@ func (s *Syncer) Full(ctx context.Context, email string) (*gmail.SyncSummary, er
232233
summary.WasResumed = state.wasResumed
233234
summary.ResumedFromToken = state.pageToken
234235

235-
// Defer failure handling
236+
// Defer failure handling — recover from panics and return as error
236237
defer func() {
237238
if r := recover(); r != nil {
238-
_ = s.store.FailSync(state.syncID, fmt.Sprintf("panic: %v", r))
239-
panic(r)
239+
stack := debug.Stack()
240+
s.logger.Error("sync panic recovered", "panic", r, "stack", string(stack))
241+
if failErr := s.store.FailSync(state.syncID, fmt.Sprintf("panic: %v", r)); failErr != nil {
242+
s.logger.Error("failed to record sync failure", "error", failErr)
243+
}
244+
summary = nil
245+
err = fmt.Errorf("sync panicked: %v", r)
240246
}
241247
}()
242248

internal/sync/sync_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,76 @@
11
package sync
22

33
import (
4+
"context"
45
"fmt"
56
"os"
67
"path/filepath"
78
"runtime"
9+
"strings"
810
"testing"
911

1012
"github.com/wesm/msgvault/internal/gmail"
1113
"github.com/wesm/msgvault/internal/store"
1214
testemail "github.com/wesm/msgvault/internal/testutil/email"
1315
)
1416

17+
// panicOnBatchAPI wraps a MockAPI and panics when GetMessagesRawBatch is called.
18+
// Used to test that Full() recovers from panics gracefully.
19+
type panicOnBatchAPI struct {
20+
*gmail.MockAPI
21+
}
22+
23+
func (p *panicOnBatchAPI) GetMessagesRawBatch(_ context.Context, _ []string) ([]*gmail.RawMessage, error) {
24+
panic("unexpected nil pointer in batch processing")
25+
}
26+
27+
func TestFullSync_PanicReturnsError(t *testing.T) {
28+
env := newTestEnv(t)
29+
seedMessages(env, 1, 12345, "msg1")
30+
31+
// Replace the client with one that panics during batch fetch
32+
env.Syncer = New(&panicOnBatchAPI{MockAPI: env.Mock}, env.Store, nil)
33+
34+
// Should return an error, NOT panic and crash the program
35+
_, err := env.Syncer.Full(env.Context, testEmail)
36+
if err == nil {
37+
t.Fatal("expected error from panic recovery, got nil")
38+
}
39+
if !strings.Contains(err.Error(), "panic") {
40+
t.Errorf("expected error to mention panic, got: %v", err)
41+
}
42+
}
43+
44+
// panicOnHistoryAPI wraps a MockAPI and panics when ListHistory is called.
45+
// Used to test that Incremental() recovers from panics gracefully.
46+
type panicOnHistoryAPI struct {
47+
*gmail.MockAPI
48+
}
49+
50+
func (p *panicOnHistoryAPI) ListHistory(_ context.Context, _ uint64, _ string) (*gmail.HistoryResponse, error) {
51+
panic("unexpected nil pointer in history processing")
52+
}
53+
54+
func TestIncrementalSync_PanicReturnsError(t *testing.T) {
55+
env := newTestEnv(t)
56+
env.CreateSourceWithHistory(t, "12340")
57+
58+
env.Mock.Profile.MessagesTotal = 10
59+
env.Mock.Profile.HistoryID = 12350
60+
61+
// Replace the client with one that panics during history fetch
62+
env.Syncer = New(&panicOnHistoryAPI{MockAPI: env.Mock}, env.Store, nil)
63+
64+
// Should return an error, NOT panic and crash the program
65+
_, err := env.Syncer.Incremental(env.Context, testEmail)
66+
if err == nil {
67+
t.Fatal("expected error from panic recovery, got nil")
68+
}
69+
if !strings.Contains(err.Error(), "panic") {
70+
t.Errorf("expected error to mention panic, got: %v", err)
71+
}
72+
}
73+
1574
func TestFullSync(t *testing.T) {
1675
env := newTestEnv(t)
1776
seedMessages(env, 3, 12345, "msg1", "msg2", "msg3")

0 commit comments

Comments
 (0)