Add body read timeout recovery to IMAP client

Introduce Client wrapper with socket deadline support
Add reconnection logic for body read timeouts
Implement test cases for underflow scenarios
Update orchestrator to handle reconnections
This commit is contained in:
2026-07-21 05:29:35 +07:00
parent c741cd19a0
commit 6bf3a6c4ca
6 changed files with 375 additions and 30 deletions
+37 -3
View File
@@ -333,10 +333,19 @@ func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID in
if err != nil {
return o.accountFailed(ctx, task.ID, runID, a, srcEP, dstEP, "src", err)
}
defer func() { _ = src.Logout().Wait() }()
if err := src.Login(a.SrcLogin, string(srcPass)).Wait(); err != nil {
_ = src.Logout().Wait()
return o.accountFailed(ctx, task.ID, runID, a, srcEP, dstEP, "src", err)
}
// srcClient holds the LIVE source connection. A body-read timeout (server
// under-delivering a literal) forces a mid-run reconnect via reconnectSrc,
// which swaps this pointer. The cancel goroutine and the deferred logout
// below both read through it, so they always act on the current connection
// rather than a stale one that was already replaced and logged out.
var srcClient atomic.Pointer[imapx.Client]
srcClient.Store(src)
defer func() { _ = srcClient.Load().Logout().Wait() }()
dst, err := imapx.Connect(actx, dstEP)
if err != nil {
return o.accountFailed(ctx, task.ID, runID, a, srcEP, dstEP, "dst", err)
@@ -346,11 +355,32 @@ func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID in
return o.accountFailed(ctx, task.ID, runID, a, srcEP, dstEP, "dst", err)
}
// reconnectSrc dials and logs in a fresh source client, swaps it in as the
// live connection, and logs the old (desynced) one out. CopyFolder calls it
// to recover after a message body times out: the server left the connection
// mid-literal, so it can't be reused. Bound to actx, so a cancelled account
// fails the dial instead of reconnecting.
reconnectSrc := func() (*imapx.Client, error) {
nc, err := imapx.Connect(actx, srcEP)
if err != nil {
return nil, err
}
if err := nc.Login(a.SrcLogin, string(srcPass)).Wait(); err != nil {
_ = nc.Logout().Wait()
return nil, err
}
if old := srcClient.Swap(nc); old != nil {
_ = old.Logout().Wait()
}
slog.Info("reconnected src after message body timeout", "account", a.ID, "src_login", a.SrcLogin)
return nc, nil
}
// On cancel, close the connections so any in-flight network read (a slow
// FETCH/Collect that ctx.Err() checks can't interrupt) unblocks immediately.
go func() {
<-actx.Done()
_ = src.Close()
_ = srcClient.Load().Close()
_ = dst.Close()
}()
@@ -445,6 +475,10 @@ func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID in
// stall watchdog sees a large-but-live transfer as progress instead of
// cancelling it as a wedged connection.
OnActivity: touch,
// Recovers from a message body timeout (server under-delivering a
// literal) by swapping in a fresh source connection so the folder can
// resume with the remaining messages.
ReconnectSrc: reconnectSrc,
OnProgress: func(c, s int) {
touch()
now := time.Now()
@@ -491,7 +525,7 @@ func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID in
if actx.Err() != nil {
break // cancelled — stop scheduling more folders
}
res, err := imapx.CopyFolder(actx, src, dst, fp.src, fp.dst, deps)
res, err := imapx.CopyFolder(actx, srcClient.Load(), dst, fp.src, fp.dst, deps)
folderErr := int64(0)
if err != nil && actx.Err() == nil {
slog.Warn("folder copy error", "account", a.ID, "src_login", a.SrcLogin, "folder", fp.src, "err", err)