diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 04b14ae57..c657c0b1a 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -1251,6 +1251,7 @@ struct AppScene: View { isPaykitSceneActive = newPhase == .active if newPhase != .inactive { isContactRefreshForeground = newPhase == .active } Logger.info("Scene phase changed: \(newPhase)", context: "AppScene") + BackupService.shared.setAppActive(newPhase == .active) let returnedFromBackground = foregroundReturnTracker.scenePhaseChanged(to: newPhase) diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift index f3158d6a4..aad3411a6 100644 --- a/Bitkit/Services/BackupService.swift +++ b/Bitkit/Services/BackupService.swift @@ -114,12 +114,13 @@ class BackupService { private var isObserving = false private var isRestoring = false private var isWiping = false - private var lastNotificationTime: UInt64 = 0 + private var backupFailureNotificationGate = BackupFailureNotificationGate() private let defaults: UserDefaults private let backupData: ((BackupCategory) async throws -> Data)? private let uploadBackup: (String, Data) async throws -> Void private let waitForBackupDelay: () async throws -> Void + private let waitForForegroundRecoveryTimeout: () async throws -> Void private let backupStatusesKey = "backupStatuses" private var backupVersions: [BackupCategory: UInt64] = [:] @@ -150,12 +151,16 @@ class BackupService { }, waitForBackupDelay: @escaping () async throws -> Void = { try await Task.sleep(nanoseconds: 5_000_000_000) + }, + waitForForegroundRecoveryTimeout: @escaping () async throws -> Void = { + try await Task.sleep(for: .seconds(BackupService.backupFailureCheckInterval)) } ) { self.defaults = defaults self.backupData = backupData self.uploadBackup = uploadBackup self.waitForBackupDelay = waitForBackupDelay + self.waitForForegroundRecoveryTimeout = waitForForegroundRecoveryTimeout let statuses = getAllBackupStatuses() var clearedStatuses = statuses for category in BackupCategory.allCases { @@ -189,10 +194,76 @@ class BackupService { } } - func startObservingBackups() { - guard !shouldSkipBackup() else { return } + @discardableResult + func setAppActive(_ isActive: Bool) -> Task? { + let shouldRecover = stateQueue.sync { + backupFailureNotificationGate.setActive(isActive) + return isActive && backupFailureNotificationGate.beginForegroundRecovery() + } + + guard shouldRecover else { return nil } + return Task { + await waitForForegroundRetries() + self.stateQueue.sync { + self.backupFailureNotificationGate.endForegroundRecovery() + } + let isObserving = try? await ServiceQueue.background(.backup) { + self.isObserving + } + let shouldCheck = self.stateQueue.sync { + self.backupFailureNotificationGate.shouldCheckForFailedBackups( + isObserving: isObserving == true + ) + } + + guard shouldCheck else { return } + self.checkForFailedBackups() + } + } + private func waitForForegroundRetries() async { + let (settled, finish) = AsyncStream.makeStream() + // Bound alert suppression, not the uploads: an FFI write may ignore cancellation. Task { + await self.retryPendingBackups() + finish.finish() + } + let timeout = Task { + do { + try await self.waitForForegroundRecoveryTimeout() + try Task.checkCancellation() + finish.finish() + } catch {} + } + for await _ in settled {} + timeout.cancel() + } + + private func retryPendingBackups() async { + let tasks = try? await ServiceQueue.background(.backup) { [self] in + guard isObserving, !shouldSkipBackup() else { return [Task]() } + return BackupCategory.allCases.compactMap { category -> Task? in + guard category != .lightningConnections else { return nil } + let status = getBackupStatus(category: category) + guard status.isRequired, !status.running, + backupJobs[category] == nil, runningBackupTasks[category] == nil + else { return nil } + + let task = Task { await self.runBackup(category: category, isScheduled: true) } + runningBackupTasks[category] = task + return task + } + } + for task in tasks ?? [] { + _ = await task.value + } + } + + @discardableResult + func startObservingBackups() -> Task? { + guard !shouldSkipBackup() else { return nil } + + return Task { let shouldStart = try? await ServiceQueue.background(.backup) { guard !self.isObserving else { return false } self.isObserving = true @@ -254,13 +325,19 @@ class BackupService { var outcome = BackupOutcome.failed var requiredAtStart: UInt64 = 0 var versionAtStart: UInt64 = 0 + let backupTime = UInt64(Date().timeIntervalSince1970) updateBackupStatus(category: category) { status in - requiredAtStart = max(status.required, status.synced + 1, UInt64(Date().timeIntervalSince1970)) + // Retrying must not restart the age of a backup that is already overdue. + requiredAtStart = status.isRequired ? status.required : max(status.required, status.synced + 1, backupTime) versionAtStart = self.backupVersions[category, default: 0] return BackupItemStatus(synced: status.synced, required: requiredAtStart, running: true) } do { + try Task.checkCancellation() + if isScheduled, shouldSkipBackup() { + throw CancellationError() + } if category == .wallet { try await PaykitPaymentActivity.shared.waitUntilIdle() if isScheduled { @@ -270,12 +347,16 @@ class BackupService { } } else { let data = try await getBackupDataBytes(category: category) + try Task.checkCancellation() + if isScheduled, shouldSkipBackup() { + throw CancellationError() + } try await uploadBackup(category.rawValue, data) } updateBackupStatus(category: category) { status in let changed = self.backupVersions[category, default: 0] != versionAtStart return BackupItemStatus( - synced: changed ? min(requiredAtStart, status.required - 1) : requiredAtStart, + synced: changed ? min(requiredAtStart, status.required - 1) : max(requiredAtStart, backupTime), required: status.required, running: false ) @@ -286,7 +367,7 @@ class BackupService { updateBackupStatus(category: category) { status in BackupItemStatus( synced: status.synced, - required: category == .wallet ? status.required : status.synced, + required: status.required, running: false ) } @@ -299,6 +380,7 @@ class BackupService { Logger.warn("Wallet backup deferred after three changing snapshots", context: "BackupService") } else { Logger.error("Backup failed for: '\(category.rawValue)': \(error)", context: "BackupService") + checkForFailedBackups() } } _ = try? await ServiceQueue.background(.backup) { self.runningBackupTasks.removeValue(forKey: category) } @@ -396,7 +478,9 @@ class BackupService { // VssBackupClient's existing custom backup namespace is explicitly wallet index 0. let backupWallet = try await PaykitPaymentStateBackup.ActiveOnchainAttempt.Wallet.backupNamespace(index: 0) try await PaykitPaymentProofService.shared.restoreBackup(paymentState, wallet: backupWallet) - if let usdt = paymentState.usdt { try await PaykitUsdtPaymentService.shared.restoreBackup(usdt) } + if let usdt = paymentState.usdt { + try await PaykitUsdtPaymentService.shared.restoreBackup(usdt) + } } try TransferStorage.shared.upsertList(payload.transfers) await PrivatePaykitAddressReservationStore.shared.restoreBackup(payload.privatePaykitHighestReservedReceiveIndexByAddressType) @@ -701,7 +785,9 @@ class BackupService { } guard getBackupStatus(category: category).isRequired && !shouldSkipBackup() else { break } outcome = await triggerBackup(category: category, isScheduled: true) - if !outcome.canRetryPendingBackup || !getBackupStatus(category: category).isRequired { break } + if !outcome.canRetryPendingBackup || !getBackupStatus(category: category).isRequired { + break + } } _ = try? await ServiceQueue.background(.backup) { self.backupJobs.removeValue(forKey: category) } updateBackupStatus(category: category) { status in @@ -716,27 +802,36 @@ class BackupService { private func checkForFailedBackups() { let currentTime = UInt64(Date().timeIntervalSince1970) - - let hasFailedBackups = BackupCategory.allCases.contains { category in - let status = getBackupStatus(category: category) - return status.isRequired && - currentTime > status.required && - (currentTime - status.required) > Self.failedBackupCheckTime + if hasFailedBackups(at: currentTime) { + showBackupFailureNotification() } + } - if hasFailedBackups { - showBackupFailureNotification(currentTime: currentTime) + private func hasFailedBackups(at currentTime: UInt64) -> Bool { + BackupCategory.allCases.contains { category in + let status = getBackupStatus(category: category) + return BackupFailureNotificationGate.hasFailedBackup( + status, + at: currentTime, + failureAge: Self.failedBackupCheckTime + ) } } - private func showBackupFailureNotification(currentTime: UInt64) { + private func showBackupFailureNotification() { Task { try? await ServiceQueue.background(.backup) { - if currentTime - self.lastNotificationTime < Self.failedBackupNotificationInterval { - return + guard self.isObserving, !self.shouldSkipBackup() else { return } + let currentTime = UInt64(Date().timeIntervalSince1970) + guard self.statusUpdateQueue.sync(execute: { self.hasFailedBackups(at: currentTime) }) else { return } + + let shouldNotify = self.stateQueue.sync { + self.backupFailureNotificationGate.shouldNotify( + at: currentTime, + notificationInterval: Self.failedBackupNotificationInterval + ) } - - self.lastNotificationTime = currentTime + guard shouldNotify else { return } let backupCheckIntervalMinutes = Int(Self.backupFailureCheckInterval / 60) self.backupFailureSubject.send(backupCheckIntervalMinutes) @@ -855,7 +950,9 @@ class BackupService { } private func getBackupDataBytes(category: BackupCategory, usdt: String? = nil) async throws -> Data { - if let backupData { return try await backupData(category) } + if let backupData { + return try await backupData(category) + } switch category { case .settings: let settingsDict = await SettingsViewModel.shared.getSettingsDictionary() @@ -1106,7 +1203,9 @@ final class WalletBackupChangeTracker { } catch WalletBackupSnapshotError.changed { try Task.checkCancellation() Logger.debug("Wallet snapshot changed during capture (attempt \(attempt)/3)", context: "BackupService") - if attempt == 3 { throw WalletBackupSnapshotError.changed } + if attempt == 3 { + throw WalletBackupSnapshotError.changed + } } } throw WalletBackupSnapshotError.changed @@ -1135,3 +1234,54 @@ actor WalletBackupWriter { } } } + +struct BackupFailureNotificationGate { + private var isActive = false + private var isRecovering = false + private var lastNotificationTime: UInt64? + + mutating func setActive(_ isActive: Bool) { + self.isActive = isActive + } + + mutating func beginForegroundRecovery() -> Bool { + guard !isRecovering else { return false } + isRecovering = true + return true + } + + mutating func endForegroundRecovery() { + isRecovering = false + } + + func shouldCheckForFailedBackups(isObserving: Bool) -> Bool { + isActive && isObserving + } + + static func hasFailedBackup( + _ status: BackupItemStatus, + at currentTime: UInt64, + failureAge: UInt64 + ) -> Bool { + status.isRequired && !status.running && currentTime >= status.required && + currentTime - status.required > failureAge + } + + mutating func shouldNotify( + at currentTime: UInt64, + notificationInterval: UInt64 + ) -> Bool { + guard isActive, !isRecovering else { return false } + + if let lastNotificationTime { + guard currentTime >= lastNotificationTime, + currentTime - lastNotificationTime >= notificationInterval + else { + return false + } + } + + lastNotificationTime = currentTime + return true + } +} diff --git a/BitkitTests/BackupFailureNotificationGateTests.swift b/BitkitTests/BackupFailureNotificationGateTests.swift new file mode 100644 index 000000000..fdf2b5b79 --- /dev/null +++ b/BitkitTests/BackupFailureNotificationGateTests.swift @@ -0,0 +1,144 @@ +@testable import Bitkit +import XCTest + +final class BackupFailureNotificationGateTests: XCTestCase { + func testRunningBackupIsNotReportedAsFailed() { + let status = BackupItemStatus(synced: 0, required: 100, running: true) + + XCTAssertFalse( + BackupFailureNotificationGate.hasFailedBackup( + status, + at: 200, + failureAge: 50 + ) + ) + } + + func testRecoveredBackupIsNotReportedAsFailed() { + let status = BackupItemStatus(synced: 100, required: 100, running: false) + + XCTAssertFalse( + BackupFailureNotificationGate.hasFailedBackup( + status, + at: 200, + failureAge: 50 + ) + ) + } + + func testOutstandingBackupPastThresholdIsReportedAsFailed() { + let status = BackupItemStatus(synced: 0, required: 100, running: false) + + XCTAssertTrue( + BackupFailureNotificationGate.hasFailedBackup( + status, + at: 151, + failureAge: 50 + ) + ) + } + + func testForegroundFailureCheckRequiresBackupObservation() { + var gate = BackupFailureNotificationGate() + gate.setActive(true) + + XCTAssertFalse(gate.shouldCheckForFailedBackups(isObserving: false)) + XCTAssertTrue(gate.shouldCheckForFailedBackups(isObserving: true)) + + gate.setActive(false) + + XCTAssertFalse(gate.shouldCheckForFailedBackups(isObserving: true)) + } + + func testInactiveCheckDoesNotConsumeNotificationCooldown() { + var gate = BackupFailureNotificationGate() + + XCTAssertFalse( + gate.shouldNotify( + at: 1000, + notificationInterval: 600 + ) + ) + + gate.setActive(true) + + XCTAssertTrue( + gate.shouldNotify( + at: 1000, + notificationInterval: 600 + ) + ) + } + + func testReturningToForegroundDoesNotResetNotificationEligibility() { + var gate = BackupFailureNotificationGate() + gate.setActive(true) + + XCTAssertTrue( + gate.shouldNotify( + at: 1000, + notificationInterval: 600 + ) + ) + + gate.setActive(false) + gate.setActive(true) + + XCTAssertFalse( + gate.shouldNotify( + at: 1599, + notificationInterval: 600 + ) + ) + XCTAssertTrue( + gate.shouldNotify( + at: 1600, + notificationInterval: 600 + ) + ) + } + + func testNotificationCooldownPreventsRepeatedToasts() { + var gate = BackupFailureNotificationGate() + gate.setActive(true) + + XCTAssertTrue( + gate.shouldNotify( + at: 1000, + notificationInterval: 600 + ) + ) + XCTAssertFalse( + gate.shouldNotify( + at: 1599, + notificationInterval: 600 + ) + ) + XCTAssertTrue( + gate.shouldNotify( + at: 1600, + notificationInterval: 600 + ) + ) + } + + func testForegroundRecoverySuppressesNotificationsWithoutConsumingCooldown() { + var gate = BackupFailureNotificationGate() + gate.setActive(true) + + XCTAssertTrue(gate.beginForegroundRecovery()) + XCTAssertFalse(gate.beginForegroundRecovery()) + XCTAssertFalse(gate.shouldNotify(at: 1000, notificationInterval: 600)) + + gate.endForegroundRecovery() + + XCTAssertTrue(gate.shouldNotify(at: 1000, notificationInterval: 600)) + gate.setActive(false) + gate.setActive(true) + XCTAssertTrue(gate.beginForegroundRecovery()) + gate.endForegroundRecovery() + + XCTAssertFalse(gate.shouldNotify(at: 1599, notificationInterval: 600)) + XCTAssertTrue(gate.shouldNotify(at: 1600, notificationInterval: 600)) + } +} diff --git a/BitkitTests/BackupForegroundRecoveryTests.swift b/BitkitTests/BackupForegroundRecoveryTests.swift new file mode 100644 index 000000000..bf2a3f44b --- /dev/null +++ b/BitkitTests/BackupForegroundRecoveryTests.swift @@ -0,0 +1,388 @@ +@testable import Bitkit +import Combine +import XCTest + +@MainActor +final class BackupForegroundRecoveryTests: XCTestCase { + func testForegroundRecoversEveryPendingCategoryWithoutStaleAlert() async throws { + let fixture = try Fixture(categories: BackupCategory.allCases.filter { $0 != .lightningConnections }) + defer { fixture.removeDefaults() } + let uploads = UploadRecorder() + let service = fixture.service(uploadBackup: { key, _ in await uploads.record(key) }) + await service.startObservingBackups()?.value + let staleAlert = expectation(description: "Recovered backups must not emit a failure alert") + staleAlert.isInverted = true + let subscription = service.backupFailurePublisher.sink { _ in staleAlert.fulfill() } + defer { subscription.cancel() } + + let recoveryTime = UInt64(Date().timeIntervalSince1970) + await service.setAppActive(true)?.value + await fulfillment(of: [staleAlert], timeout: 0.2) + + let recorded = await uploads.counts + for category in fixture.categories { + let status = service.getBackupStatus(category: category) + XCTAssertEqual(recorded[category.rawValue], 1, category.rawValue) + XCTAssertFalse(status.isRequired, category.rawValue) + XCTAssertFalse(status.running, category.rawValue) + XCTAssertGreaterThanOrEqual(status.synced, recoveryTime, category.rawValue) + } + service.setAppActive(false) + await service.stopObservingBackups() + } + + func testFailedRecoveryWaitsForOtherRetriesAndPreservesAgeAndCooldown() async throws { + let fixture = try Fixture(categories: [.settings, .widgets]) + defer { fixture.removeDefaults() } + let uploads = UploadRecorder() + let failed = expectation(description: "Settings retry failed") + let started = expectation(description: "Widgets retry started") + let (uploadGate, releaseUpload) = AsyncStream.makeStream() + defer { releaseUpload.finish() } + let service = fixture.service(uploadBackup: { key, _ in + await uploads.record(key) + if key == BackupCategory.settings.rawValue { + throw URLError(.notConnectedToInternet) + } + started.fulfill() + for await _ in uploadGate {} + }) + await service.startObservingBackups()?.value + let statusSubscription = service.backupStatusesPublisher + .map { $0[.settings]?.running == true } + .removeDuplicates() + .drop(while: { !$0 }) + .filter { !$0 } + .first() + .sink { _ in failed.fulfill() } + defer { statusSubscription.cancel() } + let earlyAlert = expectation(description: "No alert until all foreground retries settle") + earlyAlert.isInverted = true + var failureSubscription = service.backupFailurePublisher.sink { _ in earlyAlert.fulfill() } + + let recovery = service.setAppActive(true) + await fulfillment(of: [failed, started], timeout: 2) + await fulfillment(of: [earlyAlert], timeout: 0.2) + failureSubscription.cancel() + + let genuineAlert = expectation(description: "Still-failing overdue backup is reported") + failureSubscription = service.backupFailurePublisher.sink { _ in genuineAlert.fulfill() } + releaseUpload.finish() + await recovery?.value + await fulfillment(of: [genuineAlert], timeout: 2) + failureSubscription.cancel() + + XCTAssertEqual(service.getBackupStatus(category: .settings).required, fixture.required) + XCTAssertTrue(service.getBackupStatus(category: .settings).isRequired) + XCTAssertFalse(service.getBackupStatus(category: .widgets).isRequired) + + let repeatedAlert = expectation(description: "Foreground retry must preserve notification cooldown") + repeatedAlert.isInverted = true + failureSubscription = service.backupFailurePublisher.sink { _ in repeatedAlert.fulfill() } + service.setAppActive(false) + await service.setAppActive(true)?.value + await fulfillment(of: [repeatedAlert], timeout: 0.2) + failureSubscription.cancel() + let recorded = await uploads.counts + XCTAssertEqual(recorded[BackupCategory.settings.rawValue], 2) + XCTAssertEqual(recorded[BackupCategory.widgets.rawValue], 1) + XCTAssertEqual(service.getBackupStatus(category: .settings).required, fixture.required) + service.setAppActive(false) + await service.stopObservingBackups() + } + + func testForegroundDoesNotDuplicateRunningOrScheduledBackups() async throws { + let fixture = try Fixture(categories: [.settings, .widgets]) + defer { fixture.removeDefaults() } + let uploads = UploadRecorder() + let uploading = expectation(description: "Manual settings backup started") + let scheduled = expectation(description: "Widgets backup scheduled") + let completed = expectation(description: "Scheduled widgets backup completed") + let (uploadGate, releaseUpload) = AsyncStream.makeStream() + let (delayGate, releaseDelay) = AsyncStream.makeStream() + defer { + releaseUpload.finish() + releaseDelay.finish() + } + let service = fixture.service(uploadBackup: { key, _ in + await uploads.record(key) + if key == BackupCategory.settings.rawValue { + uploading.fulfill() + for await _ in uploadGate {} + } + }, waitForBackupDelay: { + scheduled.fulfill() + for await _ in delayGate {} + }) + await service.startObservingBackups()?.value + let manual = Task { await service.triggerBackup(category: .settings) } + await fulfillment(of: [uploading], timeout: 2) + service.markBackupRequired(category: .widgets) + await fulfillment(of: [scheduled], timeout: 2) + + await service.setAppActive(true)?.value + let recorded = await uploads.counts + XCTAssertEqual(recorded, [BackupCategory.settings.rawValue: 1]) + + let subscription = service.backupStatusesPublisher + .filter { $0[.widgets].map { !$0.isRequired && !$0.running } == true } + .first() + .sink { _ in completed.fulfill() } + defer { subscription.cancel() } + releaseUpload.finish() + _ = await manual.value + releaseDelay.finish() + await fulfillment(of: [completed], timeout: 2) + let finished = await uploads.counts + XCTAssertEqual(finished, [BackupCategory.settings.rawValue: 1, BackupCategory.widgets.rawValue: 1]) + service.setAppActive(false) + await service.stopObservingBackups() + } + + func testStoppingRecoveryRetainsPendingBackupAndDoesNotUpload() async throws { + let fixture = try Fixture(categories: [.settings]) + defer { fixture.removeDefaults() } + let capturing = expectation(description: "Foreground backup capture started") + let (captureGate, releaseCapture) = AsyncStream.makeStream() + defer { releaseCapture.finish() } + let service = BackupService(defaults: fixture.defaults, backupData: { _ in + capturing.fulfill() + for await _ in captureGate {} + return Data("isolated-snapshot".utf8) + }, uploadBackup: { _, _ in XCTFail("Stopped recovery must not upload") }) + await service.startObservingBackups()?.value + + let recovery = service.setAppActive(true) + await fulfillment(of: [capturing], timeout: 2) + service.setWiping(true) + await service.stopObservingBackups() + releaseCapture.finish() + await recovery?.value + + let status = service.getBackupStatus(category: .settings) + XCTAssertTrue(status.isRequired) + XCTAssertEqual(status.required, fixture.required) + XCTAssertFalse(status.running) + service.setAppActive(false) + } + + func testForegroundDoesNotRetryWithoutObservationOrDuringRestoreOrWipe() async throws { + let fixture = try Fixture(categories: [.settings]) + defer { fixture.removeDefaults() } + let service = fixture.service(uploadBackup: { _, _ in XCTFail("Recovery must not upload") }) + await service.setAppActive(true)?.value + service.setAppActive(false) + await service.startObservingBackups()?.value + + service.setRestoring(true) + await service.setAppActive(true)?.value + service.setAppActive(false) + service.setRestoring(false) + service.setWiping(true) + await service.setAppActive(true)?.value + + XCTAssertTrue(service.getBackupStatus(category: .settings).isRequired) + service.setAppActive(false) + await service.stopObservingBackups() + } + + func testHungRetryDoesNotSuppressOtherOverdueFailures() async throws { + let fixture = try Fixture(categories: [.settings, .metadata]) + defer { fixture.removeDefaults() } + let hung = expectation(description: "Metadata retry is held") + let failed = expectation(description: "Settings retry failed") + let recovered = expectation(description: "Metadata upload eventually succeeds") + let alert = expectation(description: "Overdue settings failure is reported while metadata is running") + let (uploadGate, releaseUpload) = AsyncStream.makeStream() + let (timeoutGate, expireWindow) = AsyncStream.makeStream() + defer { + releaseUpload.finish() + expireWindow.finish() + } + let service = fixture.service(uploadBackup: { key, _ in + if key == BackupCategory.settings.rawValue { + throw URLError(.notConnectedToInternet) + } + hung.fulfill() + for await _ in uploadGate {} + XCTAssertFalse(Task.isCancelled, "Expiring the recovery window must not cancel an upload") + }, waitForForegroundRecoveryTimeout: { + for await _ in timeoutGate {} + }) + await service.startObservingBackups()?.value + let failedSubscription = service.backupStatusesPublisher + .map { $0[.settings]?.running == true } + .removeDuplicates() + .drop(while: { !$0 }) + .filter { !$0 } + .first() + .sink { _ in failed.fulfill() } + let recoveredSubscription = service.backupStatusesPublisher + .filter { $0[.metadata].map { !$0.isRequired && !$0.running } == true } + .first() + .sink { _ in recovered.fulfill() } + let failureSubscription = service.backupFailurePublisher.sink { _ in alert.fulfill() } + defer { + failedSubscription.cancel() + recoveredSubscription.cancel() + failureSubscription.cancel() + } + + let recovery = service.setAppActive(true) + await fulfillment(of: [hung, failed], timeout: 2) + expireWindow.finish() + await fulfillment(of: [alert], timeout: 2) + + XCTAssertTrue(service.getBackupStatus(category: .metadata).running) + XCTAssertTrue(service.getBackupStatus(category: .settings).isRequired) + XCTAssertFalse(service.getBackupStatus(category: .settings).running) + XCTAssertEqual(service.getBackupStatus(category: .settings).required, fixture.required) + releaseUpload.finish() + await fulfillment(of: [recovered], timeout: 2) + await recovery?.value + service.setAppActive(false) + await service.stopObservingBackups() + } + + func testLateRetryCompletionDoesNotEndNewRecoveryWindow() async throws { + let fixture = try Fixture(categories: [.settings, .metadata]) + defer { fixture.removeDefaults() } + let uploads = UploadRecorder() + let deadlines = UploadRecorder() + let oldUploadStarted = expectation(description: "First window has a held metadata upload") + let oldRetryFailed = expectation(description: "First settings retry failed") + let oldDeadlineStarted = expectation(description: "First window has its own deadline") + let oldWindowEnded = expectation(description: "First window ends while its upload is still held") + let newUploadStarted = expectation(description: "Second window has a held settings upload") + let newDeadlineStarted = expectation(description: "Second window has its own deadline") + let oldUploadFinished = expectation(description: "First window's upload finishes late") + let newWindowStillWaiting = expectation(description: "Second window stays open until its own completion or deadline") + newWindowStillWaiting.isInverted = true + let (oldUpload, releaseOldUpload) = AsyncStream.makeStream() + let (newUpload, releaseNewUpload) = AsyncStream.makeStream() + let (oldTimeout, expireOldWindow) = AsyncStream.makeStream() + let (newTimeout, expireNewWindow) = AsyncStream.makeStream() + defer { + releaseOldUpload.finish() + releaseNewUpload.finish() + expireOldWindow.finish() + expireNewWindow.finish() + } + let service = fixture.service(uploadBackup: { key, _ in + await uploads.record(key) + if key == BackupCategory.metadata.rawValue { + oldUploadStarted.fulfill() + for await _ in oldUpload {} + return + } + let counts = await uploads.counts + if counts[key] == 1 { + throw URLError(.notConnectedToInternet) + } + newUploadStarted.fulfill() + for await _ in newUpload {} + throw URLError(.notConnectedToInternet) + }, waitForForegroundRecoveryTimeout: { + await deadlines.record("window") + let counts = await deadlines.counts + if counts["window"] == 1 { + oldDeadlineStarted.fulfill() + for await _ in oldTimeout {} + } else { + newDeadlineStarted.fulfill() + for await _ in newTimeout {} + } + }) + await service.startObservingBackups()?.value + let subscription = service.backupStatusesPublisher + .filter { $0[.metadata].map { !$0.isRequired && !$0.running } == true } + .first() + .sink { _ in oldUploadFinished.fulfill() } + let failureSubscription = service.backupStatusesPublisher + .map { $0[.settings]?.running == true } + .removeDuplicates() + .drop(while: { !$0 }) + .filter { !$0 } + .first() + .sink { _ in oldRetryFailed.fulfill() } + defer { + subscription.cancel() + failureSubscription.cancel() + } + + let oldRecovery = service.setAppActive(true) + let oldWindowCompletion = Task { + await oldRecovery?.value + oldWindowEnded.fulfill() + } + await fulfillment(of: [oldUploadStarted, oldRetryFailed, oldDeadlineStarted], timeout: 2) + expireOldWindow.finish() + await fulfillment(of: [oldWindowEnded], timeout: 2) + service.setAppActive(false) + let newRecovery = service.setAppActive(true) + let newWindowCompletion = Task { + await newRecovery?.value + if !Task.isCancelled { + newWindowStillWaiting.fulfill() + } + } + await fulfillment(of: [newUploadStarted, newDeadlineStarted], timeout: 2) + + releaseOldUpload.finish() + await fulfillment(of: [oldUploadFinished], timeout: 2) + await fulfillment(of: [newWindowStillWaiting], timeout: 0.2) + XCTAssertNil(service.setAppActive(true), "A late retry must not release the newer window's gate") + let counts = await uploads.counts + XCTAssertEqual(counts[BackupCategory.metadata.rawValue], 1) + XCTAssertEqual(counts[BackupCategory.settings.rawValue], 2) + + newWindowCompletion.cancel() + service.setAppActive(false) + releaseNewUpload.finish() + await newWindowCompletion.value + await oldWindowCompletion.value + await service.stopObservingBackups() + } +} + +private struct Fixture { + let suite = "BackupForegroundRecoveryTests.\(UUID().uuidString)" + let defaults: UserDefaults + let categories: [BackupCategory] + let required = UInt64(Date().timeIntervalSince1970) - 31 * 60 + + init(categories: [BackupCategory]) throws { + self.categories = categories + defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + let statuses = Dictionary(uniqueKeysWithValues: categories.map { + ($0.rawValue, BackupItemStatus(synced: 0, required: required, running: false)) + }) + try defaults.set(JSONEncoder().encode(statuses), forKey: "backupStatuses") + } + + func service( + uploadBackup: @escaping (String, Data) async throws -> Void, + waitForBackupDelay: @escaping () async throws -> Void = { throw CancellationError() }, + waitForForegroundRecoveryTimeout: @escaping () async throws -> Void = { try await Task.sleep(for: .seconds(60)) } + ) -> BackupService { + BackupService( + defaults: defaults, + backupData: { _ in Data("isolated-snapshot".utf8) }, + uploadBackup: uploadBackup, + waitForBackupDelay: waitForBackupDelay, + waitForForegroundRecoveryTimeout: waitForForegroundRecoveryTimeout + ) + } + + func removeDefaults() { + defaults.removePersistentDomain(forName: suite) + } +} + +private actor UploadRecorder { + private(set) var counts: [String: Int] = [:] + + func record(_ key: String) { + counts[key, default: 0] += 1 + } +} diff --git a/changelog.d/next/800.fixed.md b/changelog.d/next/800.fixed.md new file mode 100644 index 000000000..6280b7b0e --- /dev/null +++ b/changelog.d/next/800.fixed.md @@ -0,0 +1 @@ +Prevented stale backup failure alerts from appearing after unlocking the device.