fix: bound reconnect duration to prevent indefinite lock holding (#113)

ReconnectWithBackoff retries forever with exponential backoff (2s–30s).
When sync --follow loses its WhatsApp connection and can't recover, the
process sits retrying indefinitely while holding the store lock, blocking
all other wacli commands.

Add a reconnect() wrapper that applies a deadline to the backoff loop.
New --max-reconnect flag on sync (default 5m) controls this. Set to 0
for the old unlimited behavior.

Fixes #88.

Co-authored-by: Dinakar Sarbada <dinakars777@users.noreply.github.com>
This commit is contained in:
Dinakar Sarbada 2026-04-14 14:45:18 -07:00 committed by GitHub
parent f02ce5d301
commit a684ff03ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 24 additions and 2 deletions

View File

@ -14,6 +14,7 @@ func newSyncCmd(flags *rootFlags) *cobra.Command {
var once bool
var follow bool
var idleExit time.Duration
var maxReconnect time.Duration
var downloadMedia bool
var refreshContacts bool
var refreshGroups bool
@ -51,6 +52,7 @@ func newSyncCmd(flags *rootFlags) *cobra.Command {
RefreshContacts: refreshContacts,
RefreshGroups: refreshGroups,
IdleExit: idleExit,
MaxReconnect: maxReconnect,
})
if err != nil {
return err
@ -70,6 +72,7 @@ func newSyncCmd(flags *rootFlags) *cobra.Command {
cmd.Flags().BoolVar(&once, "once", false, "sync until idle and exit")
cmd.Flags().BoolVar(&follow, "follow", true, "keep syncing until Ctrl+C")
cmd.Flags().DurationVar(&idleExit, "idle-exit", 30*time.Second, "exit after being idle (once mode)")
cmd.Flags().DurationVar(&maxReconnect, "max-reconnect", 5*time.Minute, "give up reconnecting after this duration (0 = unlimited)")
cmd.Flags().BoolVar(&downloadMedia, "download-media", false, "download media in the background during sync")
cmd.Flags().BoolVar(&refreshContacts, "refresh-contacts", false, "refresh contacts from session store into local DB")
cmd.Flags().BoolVar(&refreshGroups, "refresh-groups", false, "refresh joined groups (live) into local DB")

View File

@ -31,6 +31,7 @@ type SyncOptions struct {
RefreshContacts bool
RefreshGroups bool
IdleExit time.Duration // only used for bootstrap/once
MaxReconnect time.Duration // max time to attempt reconnection before giving up (0 = unlimited)
Verbosity int // future
}
@ -183,7 +184,7 @@ func (a *App) Sync(ctx context.Context, opts SyncOptions) (SyncResult, error) {
return SyncResult{MessagesStored: messagesStored.Load()}, nil
case <-disconnected:
fmt.Fprintln(os.Stderr, "Reconnecting...")
if err := a.wa.ReconnectWithBackoff(ctx, 2*time.Second, 30*time.Second); err != nil {
if err := a.reconnect(ctx, opts.MaxReconnect); err != nil {
return SyncResult{MessagesStored: messagesStored.Load()}, err
}
}
@ -204,7 +205,7 @@ func (a *App) Sync(ctx context.Context, opts SyncOptions) (SyncResult, error) {
return SyncResult{MessagesStored: messagesStored.Load()}, nil
case <-disconnected:
fmt.Fprintln(os.Stderr, "Reconnecting...")
if err := a.wa.ReconnectWithBackoff(ctx, 2*time.Second, 30*time.Second); err != nil {
if err := a.reconnect(ctx, opts.MaxReconnect); err != nil {
return SyncResult{MessagesStored: messagesStored.Load()}, err
}
case <-ticker.C:
@ -217,6 +218,24 @@ func (a *App) Sync(ctx context.Context, opts SyncOptions) (SyncResult, error) {
}
}
// reconnect wraps ReconnectWithBackoff with an optional deadline.
// If maxDuration is positive, reconnection gives up after that long.
// A zero or negative value means retry indefinitely (until ctx is cancelled).
func (a *App) reconnect(ctx context.Context, maxDuration time.Duration) error {
rctx := ctx
var cancel context.CancelFunc
if maxDuration > 0 {
rctx, cancel = context.WithTimeout(ctx, maxDuration)
defer cancel()
}
err := a.wa.ReconnectWithBackoff(rctx, 2*time.Second, 30*time.Second)
if err != nil && ctx.Err() == nil {
// Deadline hit but parent context is still alive — we gave up, not the user.
return fmt.Errorf("could not reconnect after %s: %w", maxDuration, err)
}
return err
}
func chatKind(chat types.JID) string {
if chat.Server == types.GroupServer {
return "group"