fix: resolve duplicate-file failures, alreadySafe reset, and backup restart loop
BackupEngine: after all retries fail, call fileExists() before recording a failure. If the file is on NAS (uploaded earlier but fileExists silently threw on the pre-check), count it as skipped and add it to manifestEntries — this ensures writeManifest includes every safe asset and prevents alreadySafe from dropping to 1 after the post-backup reconcile. BackupView: remove the immediate refreshAndWait from onChange(.failed). That call raced with refreshAfterBackup's writeManifest, cancelled the manifest- correct refresh, then reconciled against a stale 1-entry NAS manifest. Auto-resolve (.failed → .completed when needBackup == 0) is now owned by the coordinator so it runs after writeManifest finishes. AutoBackupCoordinator: add backupTriggerArmed flag (set by onActive/ handleNASReachable, cleared by handleReconciliationComplete). Post-backup reconciles from refreshAfterBackup are no longer able to re-trigger auto- backup — only an explicit app-open or LAN-join can arm the trigger. Also added auto-resolve: calls engine.resolveSuccess() when job is .failed but needBackup == 0, which fires the .completed green-flash path in BackupView. Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
@@ -33,6 +33,11 @@ final class AutoBackupCoordinator: ObservableObject {
|
||||
private var lastAutoCheckAt: Date = .distantPast
|
||||
private var recheckTimer: AnyCancellable?
|
||||
|
||||
// One-shot flag armed by onActive()/handleNASReachable() and consumed by
|
||||
// handleReconciliationComplete(). Prevents the post-backup refresh cycle from
|
||||
// re-triggering auto-backup — only an explicit app-open or LAN-join should do that.
|
||||
private var backupTriggerArmed = false
|
||||
|
||||
private let engine = BackupEngine.shared
|
||||
private let statusService = BackupStatusService.shared
|
||||
private let store = ConnectionStore.shared
|
||||
@@ -62,6 +67,7 @@ final class AutoBackupCoordinator: ObservableObject {
|
||||
Task { @MainActor [weak self] in
|
||||
self?.recheckTimer = nil
|
||||
self?.lastAutoCheckAt = .distantPast
|
||||
self?.backupTriggerArmed = false
|
||||
log.debug("App resigned — recheck timer stopped, debounce reset")
|
||||
}
|
||||
}
|
||||
@@ -120,6 +126,7 @@ final class AutoBackupCoordinator: ObservableObject {
|
||||
}
|
||||
|
||||
lastAutoCheckAt = Date()
|
||||
backupTriggerArmed = true
|
||||
phase = .checking
|
||||
startPeriodicRecheckIfNeeded()
|
||||
log.info("onActive: trigger=\(lanTriggered ? "LAN" : "appOpen") — status check starting")
|
||||
@@ -134,13 +141,25 @@ final class AutoBackupCoordinator: ObservableObject {
|
||||
// No destination configured yet
|
||||
guard let conn = store.savedConnection, conn.remotePath != "/" else {
|
||||
phase = .idle
|
||||
backupTriggerArmed = false
|
||||
return
|
||||
}
|
||||
|
||||
// Nothing to back up — clear any stale state
|
||||
// Auto-resolve: engine reported failure but reconcile shows all assets are safe.
|
||||
// This fires after refreshAfterBackup's writeManifest completes and updates the manifest,
|
||||
// at which point needBackup correctly reflects what's still missing.
|
||||
if case .failed = engine.job.status {
|
||||
if snapshot.needBackup == 0 {
|
||||
engine.resolveSuccess()
|
||||
log.info("Auto-resolved .failed → .completed (needBackup == 0 after reconcile)")
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing to back up
|
||||
guard snapshot.needBackup > 0 else {
|
||||
phase = .idle
|
||||
pendingAtDisconnect = 0
|
||||
backupTriggerArmed = false
|
||||
return
|
||||
}
|
||||
|
||||
@@ -148,23 +167,35 @@ final class AutoBackupCoordinator: ObservableObject {
|
||||
if snapshot.connectionState == .offline {
|
||||
pendingAtDisconnect = snapshot.needBackup
|
||||
phase = .disconnected
|
||||
backupTriggerArmed = false
|
||||
log.info("NAS offline — \(snapshot.needBackup) items queued")
|
||||
return
|
||||
}
|
||||
|
||||
// Only fire auto-backup when explicitly armed by onActive() or handleNASReachable().
|
||||
// Post-backup reconciles (from refreshAfterBackup) must not re-trigger — that's the
|
||||
// source of the restart loop when alreadySafe is temporarily wrong.
|
||||
guard backupTriggerArmed else {
|
||||
phase = .idle
|
||||
log.info("handleReconciliationComplete: trigger not armed — skipping auto-backup")
|
||||
return
|
||||
}
|
||||
|
||||
// Gate auto-backup on trigger source:
|
||||
// LAN join → autoBackupEnabled (lanTriggerEnabled guards the SSID check in LANMonitor)
|
||||
// App open → autoBackupOnOpen (off by default; requires explicit opt-in)
|
||||
// LAN join → autoBackupEnabled
|
||||
// App open → autoBackupOnOpen (off by default)
|
||||
let autoAllowed = lastTriggerWasLAN
|
||||
? store.autoBackupEnabled
|
||||
: store.autoBackupOnOpen
|
||||
|
||||
guard autoAllowed else {
|
||||
phase = .idle
|
||||
backupTriggerArmed = false
|
||||
log.info("Auto-backup gated — trigger=\(self.lastTriggerWasLAN ? "LAN" : "appOpen") enabled=\(autoAllowed)")
|
||||
return
|
||||
}
|
||||
|
||||
backupTriggerArmed = false
|
||||
triggerAutoBackupIfNeeded(conn: conn)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user