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:
@@ -153,18 +153,11 @@ struct BackupView: View {
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
statusService.refresh(force: true)
|
||||
}
|
||||
} else if case .failed = status {
|
||||
// Reconcile — if all files landed on NAS despite the failure, auto-resolve to .completed
|
||||
Task {
|
||||
await statusService.refreshAndWait(force: true)
|
||||
if statusService.snapshot.needBackup == 0 {
|
||||
engine.resolveSuccess()
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { ctaFlashGreen = true }
|
||||
try? await Task.sleep(nanoseconds: 2_500_000_000)
|
||||
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { ctaFlashGreen = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
// .failed auto-resolve is owned by AutoBackupCoordinator.handleReconciliationComplete.
|
||||
// It waits for refreshAfterBackup's writeManifest to finish before reconciling, then
|
||||
// calls engine.resolveSuccess() when needBackup == 0, which re-triggers .completed above.
|
||||
// Calling refreshAndWait here would race with writeManifest and read a stale NAS manifest.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -306,6 +306,26 @@ final class BackupEngine: ObservableObject {
|
||||
}
|
||||
|
||||
if !uploadSucceeded, let error = lastUploadError {
|
||||
// Before recording a failure, verify the file isn't already on NAS.
|
||||
// fileExists() may have returned nil earlier due to a transient SMB error;
|
||||
// if it's actually there, the asset is safe — count it as skipped, not failed.
|
||||
if (try? await transfer.fileExists(at: remotePath)) == true {
|
||||
job.fileCompleted(skipped: true)
|
||||
skipped += 1
|
||||
if assetIdx < queueItems.count {
|
||||
queueItems[assetIdx].status = .skipped
|
||||
queueItems[assetIdx].completedAt = Date()
|
||||
}
|
||||
manifestEntries.append(ManifestEntry(
|
||||
localIdentifier: asset.localIdentifier,
|
||||
filename: asset.filename,
|
||||
creationDate: asset.creationDate,
|
||||
fileSize: 0,
|
||||
remotePath: remotePath,
|
||||
uploadedAt: Date()
|
||||
))
|
||||
logger.info("Upload failed but file confirmed on NAS — marking safe: \(asset.filename, privacy: .public)")
|
||||
} else {
|
||||
logger.error("Upload permanently failed for \(asset.filename, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
job.fileFailed()
|
||||
failed += 1
|
||||
@@ -320,6 +340,7 @@ final class BackupEngine: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
signposter.endInterval("UploadLoop", spUpload, "\(uploaded) uploaded, \(failed) failed")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user