Remove idle connection timeout handling
Remove error handling for closed connections Add progress watchdog to detect stalled accounts Improve error modal styling and pagination
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/vasyansk/imap-copier/internal/crypto"
|
||||
@@ -22,6 +23,16 @@ var ErrAlreadyRunning = errors.New("task already running")
|
||||
// suppressed" note.
|
||||
const maxAccountErrors = 500
|
||||
|
||||
// A running account that emits no scan/copy progress for stallTimeout is wedged
|
||||
// (silent server mid-FETCH, stalled APPEND). The watchdog cancels it so the
|
||||
// connections close and the worker unwinds instead of hanging forever. This
|
||||
// replaces socket-level read deadlines, which can't tell an idle connection
|
||||
// from a stuck one. The threshold is generous so slow-but-live runs aren't cut.
|
||||
const (
|
||||
stallTimeout = 3 * time.Minute
|
||||
stallCheckInterval = 30 * time.Second
|
||||
)
|
||||
|
||||
// folderPlan is one source folder scheduled for copy and its destination name.
|
||||
type folderPlan struct {
|
||||
src, dst string
|
||||
@@ -316,7 +327,34 @@ func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID in
|
||||
_ = dst.Close()
|
||||
}()
|
||||
|
||||
// Progress watchdog: track the last time we saw scan/copy activity; if it
|
||||
// goes quiet for stallTimeout, cancel the account so the connections close
|
||||
// and this worker unwinds (it would otherwise block forever on a silent
|
||||
// server). touch() is called on every progress signal below.
|
||||
var lastActivity atomic.Int64
|
||||
lastActivity.Store(time.Now().UnixNano())
|
||||
touch := func() { lastActivity.Store(time.Now().UnixNano()) }
|
||||
go func() {
|
||||
t := time.NewTicker(stallCheckInterval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-actx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
if time.Since(time.Unix(0, lastActivity.Load())) > stallTimeout {
|
||||
slog.Warn("account stalled with no progress; cancelling",
|
||||
"account", a.ID, "src_login", a.SrcLogin, "stall", stallTimeout)
|
||||
_ = o.store.SetAccountError(ctx, a.ID, "stalled: no progress for "+stallTimeout.String()+", cancelled")
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
folders, err := imapx.ListFolders(src)
|
||||
touch()
|
||||
if err != nil {
|
||||
return o.accountFailed(ctx, task.ID, runID, a, srcEP, dstEP, "src", err)
|
||||
}
|
||||
@@ -335,6 +373,7 @@ func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID in
|
||||
}
|
||||
plan[i].total = n
|
||||
grandTotal += n
|
||||
touch()
|
||||
}
|
||||
o.hub.Publish(wshub.Event{Type: "plan", TaskID: task.ID, Data: map[string]any{
|
||||
"account_id": a.ID, "src_login": a.SrcLogin, "folders": len(plan), "total": grandTotal,
|
||||
@@ -367,6 +406,7 @@ func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID in
|
||||
MarkMigrated: func(folder, k string) error { return o.store.MarkMigrated(ctx, a.ID, folder, k) },
|
||||
OnError: func(ref, msg string) { addErr("message", curFolder, ref, msg) },
|
||||
OnProgress: func(c, s int) {
|
||||
touch()
|
||||
now := time.Now()
|
||||
done := c + s
|
||||
// throttle to ~3/sec per account, but always emit folder completion
|
||||
@@ -386,6 +426,7 @@ func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID in
|
||||
},
|
||||
// Fires after EXAMINE (before the long fetch) with the folder's message count.
|
||||
OnFolder: func(srcFolder, dstFolder string, total int64) {
|
||||
touch()
|
||||
curFolder, curTotal = srcFolder, total
|
||||
o.hub.Publish(wshub.Event{Type: "folder", TaskID: task.ID, Data: map[string]any{
|
||||
"account_id": a.ID, "src_login": a.SrcLogin,
|
||||
@@ -395,6 +436,7 @@ func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID in
|
||||
// Fires while streaming metadata (dedup scan) so the UI shows movement
|
||||
// before bodies start copying. Throttled to ~4/sec, always emit the last.
|
||||
OnScan: func(scanned, total int64) {
|
||||
touch()
|
||||
now := time.Now()
|
||||
if now.Sub(lastScanEmit) < 250*time.Millisecond && scanned < total {
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user