diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift index ac0d64c47..4a7bbfc50 100644 --- a/Bitkit/Services/BackupService.swift +++ b/Bitkit/Services/BackupService.swift @@ -1,6 +1,7 @@ import BitkitCore import Combine import Foundation +import Network import Paykit import VssRustClientFfi @@ -119,13 +120,17 @@ class BackupService { @MainActor weak var usdtWallet: UsdtWalletManager? private var backupJobs: [BackupCategory: Task] = [:] private var runningBackupTasks: [BackupCategory: Task] = [:] - private let cancellables = BackupObservationSubscriptions() - private var periodicCheckTask: Task? - private var isObserving = false + @MainActor private var dataStoreListeners = Set() + @MainActor private var statusListeners = Set() + @MainActor private var periodicCheckTask: Task? + @MainActor private var observationTask: Task? + @MainActor private var isObserving = false + private var schedulingGeneration: UInt64 = 0 + private var isStoppingBackups = false private var isRestoring = false private var isWiping = false + private var wipeGeneration: UInt64 = 0 private var backupFailureNotificationGate = BackupFailureNotificationGate() - private var foregroundObservationGeneration: UInt64 = 0 private let defaults: UserDefaults private let backupData: ((BackupCategory) async throws -> Data)? @@ -208,29 +213,44 @@ class BackupService { /// Sets the wiping flag to prevent backups during wipe operations func setWiping(_ isWiping: Bool) { stateQueue.sync { + if isWiping, !self.isWiping { + wipeGeneration &+= 1 + } self.isWiping = isWiping } } + private func captureWipeGeneration() throws -> UInt64 { + try stateQueue.sync { + guard !isWiping else { throw CancellationError() } + return wipeGeneration + } + } + + private func requireWipeGeneration(_ generation: UInt64) throws { + try stateQueue.sync { + guard !isWiping, wipeGeneration == generation else { throw CancellationError() } + } + } + + @MainActor @discardableResult func setAppActive(_ isActive: Bool) -> Task? { + let observingAtEntry = isObserving let (shouldRecover, generation) = stateQueue.sync { backupFailureNotificationGate.setActive(isActive) - return (isActive && backupFailureNotificationGate.beginForegroundRecovery(), foregroundObservationGeneration) + return (isActive && backupFailureNotificationGate.beginForegroundRecovery(), schedulingGeneration) } guard shouldRecover else { return nil } return Task { - await waitForForegroundRetries(generation: generation) + await waitForForegroundRetries(generation: generation, observingAtEntry: observingAtEntry) self.stateQueue.sync { self.backupFailureNotificationGate.endForegroundRecovery() } - let isObserving = try? await ServiceQueue.background(.backup) { - self.isObserving - } let shouldCheck = self.stateQueue.sync { - self.foregroundObservationGeneration == generation && self.backupFailureNotificationGate.shouldCheckForFailedBackups( - isObserving: isObserving == true + self.schedulingGeneration == generation && self.backupFailureNotificationGate.shouldCheckForFailedBackups( + isObserving: self.isObserving ) } @@ -239,11 +259,11 @@ class BackupService { } } - private func waitForForegroundRetries(generation: UInt64) async { + private func waitForForegroundRetries(generation: UInt64, observingAtEntry: Bool) async { let (settled, finish) = AsyncStream.makeStream() // Bound alert suppression, not the uploads: an FFI write may ignore cancellation. Task { - await self.retryPendingBackups(generation: generation) + await self.retryPendingBackups(generation: generation, observingAtEntry: observingAtEntry) finish.finish() } let timeout = Task { @@ -257,9 +277,11 @@ class BackupService { timeout.cancel() } - private func retryPendingBackups(generation: UInt64) async { + private func retryPendingBackups(generation: UInt64, observingAtEntry: Bool) async { let tasks = try? await ServiceQueue.background(.backup) { [self] in - guard isObserving, stateQueue.sync(execute: { foregroundObservationGeneration == generation }) else { + guard observingAtEntry, !isStoppingBackups, generation == currentSchedulingGeneration, + let wipeGeneration = try? captureWipeGeneration() + else { return [Task]() } return BackupCategory.allCases.compactMap { category -> Task? in @@ -270,9 +292,9 @@ class BackupService { else { return nil } let task = Task { - let outcome = await self.runBackup(category: category, isScheduled: true) + let outcome = await self.runBackup(category: category, isScheduled: true, wipeGeneration: wipeGeneration) if outcome.canRetryPendingBackup, !Task.isCancelled, self.getBackupStatus(category: category).isRequired { - await self.scheduleBackup(category: category, initialOutcome: outcome, foregroundGeneration: generation) + await self.scheduleBackup(category: category, generation: generation, initialOutcome: outcome) } return outcome } @@ -285,51 +307,62 @@ class BackupService { } } + @MainActor @discardableResult func startObservingBackups() -> Task? { - guard !shouldSkipBackup() else { return nil } - let generation = stateQueue.sync { foregroundObservationGeneration } - - return Task { - let publishers = await MainActor.run { - let settings = SettingsViewModel.shared - return (settings.settingsPublisher, settings.widgetsPublisher, settings.appStatePublisher) + let previous = observationTask + let listeners = makeDataStoreListeners() + let starting = Task { + await previous?.value + guard !shouldSkipBackup(), !isObserving else { return } + isObserving = true + Logger.debug("Start observing backup statuses and data store changes", context: "BackupService") + dataStoreListeners.formUnion(listeners) + startBackupStatusObservers() + startPeriodicBackupFailureCheck() + } + observationTask = starting + return starting + } + + @MainActor + func stopObservingBackups() async { + let previous = observationTask + let stopping = Task { + await previous?.value + isObserving = false + statusListeners.removeAll() + periodicCheckTask?.cancel() + periodicCheckTask = nil + + let tasks = try? await ServiceQueue.background(.backup) { + self.stateQueue.sync { self.schedulingGeneration &+= 1 } + self.isStoppingBackups = true + let scheduled = Array(self.backupJobs.values) + let running = Array(self.runningBackupTasks.values) + scheduled.forEach { $0.cancel() } + running.forEach { $0.cancel() } + return (scheduled, running) + } + // Cleanup uses the backup queue too, so drain outside it before a replacement starts. + if let (scheduled, running) = tasks { + for task in scheduled { + await task.value + } + for task in running { + _ = await task.value + } } + await walletBackupWriter.waitUntilIdle() + await usdtBackupWriter.waitUntilIdle() + dataStoreListeners.removeAll() try? await ServiceQueue.background(.backup) { - guard !self.isObserving, !self.shouldSkipBackup(), - self.stateQueue.sync(execute: { self.foregroundObservationGeneration == generation }) - else { return } - self.isObserving = true - - Logger.debug("Start observing backup statuses and data store changes", context: "BackupService") - self.startBackupStatusObservers(generation: generation) - self.startDataStoreListeners(settings: publishers.0, widgets: publishers.1, appState: publishers.2) - self.startPeriodicBackupFailureCheck() + self.isStoppingBackups = false } + Logger.debug("Stopped observing backup statuses and data store changes", context: "BackupService") } - } - - func stopObservingBackups() async { - let stopped = try? await ServiceQueue.background(.backup) { - let wasObserving = self.isObserving - self.isObserving = false - self.stateQueue.sync { self.foregroundObservationGeneration &+= 1 } - - self.backupJobs.values.forEach { $0.cancel() } - self.backupJobs.removeAll() - // Cancelled uploads remain registered until their owning task finishes. - self.runningBackupTasks.values.forEach { $0.cancel() } - self.periodicCheckTask?.cancel() - self.periodicCheckTask = nil - self.cancellables.cancelAll() - return (wasObserving: wasObserving, metadataTask: self.runningBackupTasks[.metadata]) - } - - _ = await stopped?.metadataTask?.value - await walletBackupWriter.waitUntilIdle() - await usdtBackupWriter.waitUntilIdle() - guard stopped?.wasObserving == true else { return } - Logger.debug("Stopped observing backup statuses and data store changes", context: "BackupService") + observationTask = stopping + await stopping.value } @discardableResult @@ -338,26 +371,45 @@ class BackupService { } private func triggerBackup(category: BackupCategory, isScheduled: Bool, waitForExisting: Bool = false) async -> BackupOutcome { - let work = try? await ServiceQueue.background(.backup) { [self] () -> (task: Task, owned: Bool)? in - if let existing = runningBackupTasks[category] { - return waitForExisting ? (existing, false) : nil + guard let wipeGeneration = try? captureWipeGeneration() else { return .failed } + while !Task.isCancelled { + let admission = try? await ServiceQueue.background(.backup) { [self] () -> ( + task: Task?, + owned: Bool, + stopping: Bool + ) in + try requireWipeGeneration(wipeGeneration) + guard !isStoppingBackups else { return (nil, false, true) } + if let existing = runningBackupTasks[category] { + return (waitForExisting ? existing : nil, false, false) + } + let task = Task { await self.runBackup(category: category, isScheduled: isScheduled, wipeGeneration: wipeGeneration) } + runningBackupTasks[category] = task + return (task, true, false) } - let backupTask = Task { await self.runBackup(category: category, isScheduled: isScheduled) } - runningBackupTasks[category] = backupTask - return (backupTask, true) - } - - guard let work else { return .busy } - return await withTaskCancellationHandler { - await work.task.value - } onCancel: { - if work.owned { - work.task.cancel() + guard let admission else { return .failed } + if admission.stopping, !isScheduled { + await waitForObservationTransition() + continue + } + guard let backupTask = admission.task else { return .busy } + return await withTaskCancellationHandler { + await backupTask.value + } onCancel: { + if admission.owned { + backupTask.cancel() + } } } + return .failed + } + + @MainActor + private func waitForObservationTransition() async { + await observationTask?.value } - private func runBackup(category: BackupCategory, isScheduled: Bool) async -> BackupOutcome { + private func runBackup(category: BackupCategory, isScheduled: Bool, wipeGeneration: UInt64) async -> BackupOutcome { Logger.debug("Backup starting for: '\(category.rawValue)'", context: "BackupService") var outcome = BackupOutcome.failed var requiredAtStart: UInt64 = 0 @@ -372,6 +424,7 @@ class BackupService { do { try Task.checkCancellation() + try requireWipeGeneration(wipeGeneration) if isScheduled, shouldSkipBackup(category: category) { throw CancellationError() } @@ -393,11 +446,13 @@ class BackupService { try requireMetadataBackupReady() } try Task.checkCancellation() + try requireWipeGeneration(wipeGeneration) if isScheduled, shouldSkipBackup(category: category) { throw CancellationError() } try await uploadBackup(category.rawValue, data) } + try requireWipeGeneration(wipeGeneration) updateBackupStatus(category: category) { status in let changed = self.backupVersions[category, default: 0] != versionAtStart return BackupItemStatus( @@ -469,10 +524,35 @@ class BackupService { case MetadataBackupError.walletTemporarilyUnavailable, PaykitError.ConcurrentUpdate, PaykitError.SharedStateBusy, PaykitError.Transport, VssError.ConnectionError, VssError.NetworkError, VssError.StoreError, VssError.PutError: - true + return true + case let VssError.UnknownError(details): + return isVssRequestSendFailure(details, pathSuffix: "/putObjects") + case let VssError.AuthError(details): + let prefix = "error performing external request: " + let requestFailure = details.hasPrefix(prefix) ? String(details.dropFirst(prefix.count)) : details + return isVssRequestSendFailure(requestFailure) default: - false + return false + } + } + + private static func isVssRequestSendFailure(_ details: String, pathSuffix: String? = nil) -> Bool { + let prefix = "error sending request for url (" + let invalidHostCharacters = CharacterSet.whitespacesAndNewlines.union(.controlCharacters) + .union(CharacterSet(charactersIn: "/%@?#\\")) + guard details.hasPrefix(prefix), details.hasSuffix(")"), + let url = URLComponents(string: String(details.dropFirst(prefix.count).dropLast()), encodingInvalidCharacters: false), + url.scheme == "http" || url.scheme == "https", + let host = url.host, !host.isEmpty, host.rangeOfCharacter(from: invalidHostCharacters) == nil, + (0 ... 65535).contains(url.port ?? 0), + url.url != nil + else { return false } + if host.hasPrefix("[") { + guard host.hasSuffix("]"), IPv6Address(String(host.dropFirst().dropLast())) != nil else { return false } + } else if host.contains(":") || host.contains("[") || host.contains("]") { + return false } + return pathSuffix.map { url.path.hasSuffix($0) } ?? true } func persistWalletBackup() async throws { @@ -772,7 +852,8 @@ class BackupService { // MARK: - Private Methods - private func startBackupStatusObservers(generation: UInt64) { + @MainActor + private func startBackupStatusObservers() { for category in BackupCategory.allCases { let categoryPublisher = backupStatusesPublisher .prepend(getAllBackupStatuses()) @@ -789,102 +870,94 @@ class BackupService { .sink { [weak self] status in guard let self, !self.shouldSkipBackup(category: category) else { return } if status.isRequired && !status.running { + let generation = currentSchedulingGeneration Task { - await self.scheduleBackup(category: category, foregroundGeneration: generation) + await self.scheduleBackup(category: category, generation: generation) } } } - .store(in: cancellables) + .store(in: &statusListeners) } Logger.debug("Started \(BackupCategory.allCases.count) backup status observers", context: "BackupService") } - private func startDataStoreListeners( - settings: AnyPublisher<[String: Any], Never>, - widgets: AnyPublisher, - appState: AnyPublisher - ) { + @MainActor + private func makeDataStoreListeners() -> Set { + var listeners = Set() // SETTINGS - settings - .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) + SettingsViewModel.shared.settingsPublisher .sink { [weak self] _ in guard let self, !self.shouldSkipBackup() else { return } - markBackupRequired(category: .settings) + markObservedBackupRequired(category: .settings) } - .store(in: cancellables) + .store(in: &listeners) // WIDGETS - widgets - .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) + SettingsViewModel.shared.widgetsPublisher .sink { [weak self] _ in guard let self, !self.shouldSkipBackup() else { return } - markBackupRequired(category: .widgets) + markObservedBackupRequired(category: .widgets) } - .store(in: cancellables) + .store(in: &listeners) // Record requirements synchronously; the scheduled upload already has a debounce. WalletBackupChangeTracker.localChanges .merge(with: PaykitSdkService.walletBackupDataChangedPublisher) .sink { [weak self] _ in guard let self, !self.shouldSkipBackup() else { return } - markBackupRequired(category: .wallet) + markObservedBackupRequired(category: .wallet) } - .store(in: cancellables) + .store(in: &listeners) UsdtWalletManager.walletBackupDataChangedPublisher .merge(with: PaykitUsdtPaymentService.walletBackupDataChangedPublisher) .sink { [weak self] _ in guard let self, !self.isWipingOrRestoringUsdt() else { return } - markBackupRequired(category: .usdt) + markObservedBackupRequired(category: .usdt) } - .store(in: cancellables) + .store(in: &listeners) // ACTIVITIES CoreService.shared.activity.activitiesChangedPublisher - .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) .sink { [weak self] _ in guard let self, !self.shouldSkipBackup() else { return } - markBackupRequired(category: .activity) + markObservedBackupRequired(category: .activity) } - .store(in: cancellables) + .store(in: &listeners) // METADATA (from ActivityService) CoreService.shared.activity.metadataChangedPublisher - .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) .sink { [weak self] _ in guard let self, !self.shouldSkipBackup() else { return } - markBackupRequired(category: .metadata) + markObservedBackupRequired(category: .metadata) } - .store(in: cancellables) + .store(in: &listeners) // METADATA (hardware wallet names). Scoped to the names alone: the known-device store is also // rewritten by every connect, and reconnect traffic must not re-upload the whole envelope. HwKnownDeviceStorage.namesChangedPublisher - .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) .sink { [weak self] _ in guard let self, !self.shouldSkipBackup() else { return } - markBackupRequired(category: .metadata) + markObservedBackupRequired(category: .metadata) } - .store(in: cancellables) + .store(in: &listeners) // APP STATE (UserDefaults changes, etc.) - appState - .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) + SettingsViewModel.shared.appStatePublisher .sink { [weak self] _ in guard let self, !self.shouldSkipBackup() else { return } - markBackupRequired(category: .metadata) + markObservedBackupRequired(category: .metadata) } - .store(in: cancellables) + .store(in: &listeners) // BLOCKTANK CoreService.shared.blocktank.stateChangedPublisher - .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) .sink { [weak self] _ in guard let self, !self.shouldSkipBackup() else { return } - markBackupRequired(category: .blocktank) + markObservedBackupRequired(category: .blocktank) } - .store(in: cancellables) + .store(in: &listeners) // LIGHTNING SYNC STATUS LightningService.shared.syncStatusChangedPublisher @@ -899,17 +972,28 @@ class BackupService { ) } } - .store(in: cancellables) + .store(in: &listeners) - Logger.debug("Started data store listeners", context: "BackupService") + return listeners } + private func markObservedBackupRequired(category: BackupCategory) { + // Persist notifications during restart without admitting uploads before cleanup finishes. + markBackupRequired(category: category, schedule: false) + Task { @MainActor in + await waitForObservationTransition() + guard isObserving else { return } + await scheduleBackup(category: category, generation: currentSchedulingGeneration) + } + } + + @MainActor private func startPeriodicBackupFailureCheck() { periodicCheckTask = Task { while !Task.isCancelled { try? await Task.sleep(nanoseconds: UInt64(Self.backupFailureCheckInterval * 1_000_000_000)) guard !Task.isCancelled else { break } - self.checkForFailedBackups() + checkForFailedBackups() } } } @@ -936,24 +1020,40 @@ class BackupService { return BackupItemStatus( synced: status.synced, required: max(status.required, status.synced + 1, UInt64(Date().timeIntervalSince1970)), - running: schedule || status.running + running: status.running ) } guard schedule else { return } - Task { - let status = getBackupStatus(category: category) - if status.isRequired && !shouldSkipBackup(category: category) { - await scheduleBackup(category: category) - } else if !status.isRequired { + let generation = currentSchedulingGeneration + Task { await scheduleBackup(category: category, generation: generation) } + } + + private var currentSchedulingGeneration: UInt64 { + stateQueue.sync { schedulingGeneration } + } + + private func scheduleBackup(category: BackupCategory, generation: UInt64, initialOutcome: BackupOutcome = .failed) async { + while !Task.isCancelled { + let waitingForStop = try? await ServiceQueue.background(.backup) { [self] in + guard generation == currentSchedulingGeneration else { return false } + guard !isStoppingBackups else { return true } + guard backupJobs[category] == nil, getBackupStatus(category: category).isRequired, + !shouldSkipBackup(category: category) else { return false } updateBackupStatus(category: category) { status in BackupItemStatus( synced: status.synced, required: status.required, - running: false + running: true ) } + backupJobs[category] = Task { + await self.runScheduledBackup(category: category, generation: generation, initialOutcome: initialOutcome) + } + return false } + guard waitingForStop == true else { return } + await waitForObservationTransition() } } @@ -969,26 +1069,7 @@ class BackupService { _ = persistBackupStatuses(statuses, defaults: defaults) } - private func scheduleBackup(category: BackupCategory, initialOutcome: BackupOutcome = .failed, foregroundGeneration: UInt64? = nil) async { - try? await ServiceQueue.background(.backup) { [self] in - if let foregroundGeneration { - guard isObserving, !shouldSkipBackup(category: category), - stateQueue.sync(execute: { foregroundObservationGeneration == foregroundGeneration }) - else { return } - } - guard backupJobs[category] == nil else { return } - updateBackupStatus(category: category) { status in - BackupItemStatus( - synced: status.synced, - required: status.required, - running: true - ) - } - backupJobs[category] = Task { await self.runScheduledBackup(category: category, initialOutcome: initialOutcome) } - } - } - - private func runScheduledBackup(category: BackupCategory, initialOutcome: BackupOutcome) async { + private func runScheduledBackup(category: BackupCategory, generation: UInt64, initialOutcome: BackupOutcome) async { var outcome = initialOutcome var retryDelay: UInt64 = 10_000_000_000 while !Task.isCancelled { @@ -1010,14 +1091,16 @@ class BackupService { break } } - _ = try? await ServiceQueue.background(.backup) { self.backupJobs.removeValue(forKey: category) } - updateBackupStatus(category: category) { status in - BackupItemStatus(synced: status.synced, required: status.required, running: false) + _ = try? await ServiceQueue.background(.backup) { + self.backupJobs.removeValue(forKey: category) + self.updateBackupStatus(category: category) { status in + BackupItemStatus(synced: status.synced, required: status.required, running: false) + } } if outcome.canRetryPendingBackup, !Task.isCancelled, getBackupStatus(category: category).isRequired, !shouldSkipBackup(category: category) { - await scheduleBackup(category: category) + await scheduleBackup(category: category, generation: generation) } } @@ -1040,25 +1123,23 @@ class BackupService { } private func showBackupFailureNotification() { - Task { - try? await ServiceQueue.background(.backup) { - 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 - ) - } - guard shouldNotify else { return } - - let backupCheckIntervalMinutes = Int(Self.backupFailureCheckInterval / 60) - self.backupFailureSubject.send(backupCheckIntervalMinutes) - - Logger.warn("Backup failed for more than 30 minutes", context: "BackupService") + let generation = currentSchedulingGeneration + Task { @MainActor in + guard self.isObserving, generation == self.currentSchedulingGeneration, !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 + ) } + guard shouldNotify else { return } + + let backupCheckIntervalMinutes = Int(Self.backupFailureCheckInterval / 60) + self.backupFailureSubject.send(backupCheckIntervalMinutes) + Logger.warn("Backup failed for more than 30 minutes", context: "BackupService") } } @@ -1110,6 +1191,7 @@ class BackupService { } func scheduleFullBackup() async { + let generation = currentSchedulingGeneration let currentTime = UInt64(Date().timeIntervalSince1970) for category in BackupCategory.allCases { updateBackupStatus(category: category) { status in @@ -1125,7 +1207,7 @@ class BackupService { for category in BackupCategory.allCases { let status = getBackupStatus(category: category) if status.isRequired && !status.running { - await scheduleBackup(category: category) + await scheduleBackup(category: category, generation: generation) } } } @@ -1134,7 +1216,9 @@ class BackupService { statusUpdateQueue.sync { var statuses = getAllBackupStatuses() let currentStatus = statuses[category] ?? BackupItemStatus() - statuses[category] = update(currentStatus) + let nextStatus = update(currentStatus) + guard statuses[category] != nextStatus else { return } + statuses[category] = nextStatus saveBackupStatuses(statuses) } } diff --git a/Bitkit/Views/Onboarding/InitializingWalletView.swift b/Bitkit/Views/Onboarding/InitializingWalletView.swift index 63f1a22ea..e56638cc0 100644 --- a/Bitkit/Views/Onboarding/InitializingWalletView.swift +++ b/Bitkit/Views/Onboarding/InitializingWalletView.swift @@ -10,11 +10,22 @@ struct InitializingWalletView: View { @Binding var nodeLifecycleState: NodeLifecycleState let onComplete: () -> Void + init( + nodeLifecycleState: Binding, + initialPercentage: Double = 0, + onComplete: @escaping () -> Void + ) { + _nodeLifecycleState = nodeLifecycleState + _percentage = State(initialValue: initialPercentage) + self.onComplete = onComplete + } + private var shouldFinish: Bool { nodeLifecycleState == .running } private func handleCompletion() { + guard shouldFinish, !shouldStopAnimation else { return } timer?.invalidate() shouldStopAnimation = true Haptics.stopHaptics() diff --git a/BitkitTests/BackupForegroundRecoveryTests.swift b/BitkitTests/BackupForegroundRecoveryTests.swift index 277587adf..4989593d6 100644 --- a/BitkitTests/BackupForegroundRecoveryTests.swift +++ b/BitkitTests/BackupForegroundRecoveryTests.swift @@ -33,24 +33,25 @@ final class BackupForegroundRecoveryTests: XCTestCase { } func testFailedRecoveryWaitsForOtherRetriesAndPreservesAgeAndCooldown() async throws { - let fixture = try Fixture(categories: [.settings, .widgets]) + let fixture = try Fixture(categories: [.usdt, .widgets]) defer { fixture.removeDefaults() } let uploads = UploadRecorder() - let failed = expectation(description: "Settings retry failed") + let failed = expectation(description: "USDT 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 { + if key == BackupCategory.usdt.rawValue { throw URLError(.notConnectedToInternet) } + guard key == BackupCategory.widgets.rawValue else { return } started.fulfill() for await _ in uploadGate {} }) await startAfterPendingJobsSettle(service, categories: fixture.categories) let statusSubscription = service.backupStatusesPublisher - .map { $0[.settings]?.running == true } + .map { $0[.usdt]?.running == true } .removeDuplicates() .drop(while: { !$0 }) .filter { !$0 } @@ -73,8 +74,8 @@ final class BackupForegroundRecoveryTests: XCTestCase { await fulfillment(of: [genuineAlert], timeout: 2) failureSubscription.cancel() - XCTAssertEqual(service.getBackupStatus(category: .settings).required, fixture.required) - XCTAssertTrue(service.getBackupStatus(category: .settings).isRequired) + XCTAssertEqual(service.getBackupStatus(category: .usdt).required, fixture.required) + XCTAssertTrue(service.getBackupStatus(category: .usdt).isRequired) XCTAssertFalse(service.getBackupStatus(category: .widgets).isRequired) let repeatedAlert = expectation(description: "Foreground retry must preserve notification cooldown") @@ -85,9 +86,9 @@ final class BackupForegroundRecoveryTests: XCTestCase { await fulfillment(of: [repeatedAlert], timeout: 0.2) failureSubscription.cancel() let recorded = await uploads.counts - XCTAssertEqual(recorded[BackupCategory.settings.rawValue], 2) + XCTAssertEqual(recorded[BackupCategory.usdt.rawValue], 2) XCTAssertEqual(recorded[BackupCategory.widgets.rawValue], 1) - XCTAssertEqual(service.getBackupStatus(category: .settings).required, fixture.required) + XCTAssertEqual(service.getBackupStatus(category: .usdt).required, fixture.required) service.setAppActive(false) await service.stopObservingBackups() } @@ -171,11 +172,14 @@ final class BackupForegroundRecoveryTests: XCTestCase { 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 cancelled = expectation(description: "Stop cancels the captured operation") + let stoppedEarly = expectation(description: "Stop must drain non-cooperative capture") + stoppedEarly.isInverted = true + let gate = CaptureGate() let service = BackupService(defaults: fixture.defaults, backupData: { _ in - capturing.fulfill() - for await _ in captureGate {} + await withTaskCancellationHandler { + await gate.wait(started: capturing) + } onCancel: { cancelled.fulfill() } return Data("isolated-snapshot".utf8) }, uploadBackup: { _, _ in XCTFail("Stopped recovery must not upload") }, waitForBackupDelay: { throw CancellationError() }) await startAfterPendingJobsSettle(service, categories: fixture.categories) @@ -183,8 +187,16 @@ final class BackupForegroundRecoveryTests: XCTestCase { let recovery = service.setAppActive(true) await fulfillment(of: [capturing], timeout: 2) service.setWiping(true) - await service.stopObservingBackups() - releaseCapture.finish() + let stop = Task { + await service.stopObservingBackups() + if await gate.isHeld { + stoppedEarly.fulfill() + } + } + await fulfillment(of: [cancelled], timeout: 2) + await fulfillment(of: [stoppedEarly], timeout: 0.1) + await gate.release() + await stop.value await recovery?.value let status = service.getBackupStatus(category: .settings) @@ -296,6 +308,56 @@ final class BackupForegroundRecoveryTests: XCTestCase { await service.stopObservingBackups() } + func testScheduledIdlePublicationWaitsForForegroundAdmissionCleanup() async throws { + let fixture = try Fixture(categories: [.settings]) + defer { fixture.removeDefaults() } + let uploads = UploadRecorder() + let delayStarted = expectation(description: "Initial scheduled backup is held") + let queueHeld = expectation(description: "Backup admission queue is held") + let prematureIdle = expectation(description: "Idle must not precede admission cleanup") + prematureIdle.isInverted = true + let settled = expectation(description: "Scheduled backup becomes available for foreground retry") + let (delay, releaseDelay) = AsyncStream.makeStream() + let releaseQueue = DispatchSemaphore(value: 0) + defer { + releaseDelay.finish() + releaseQueue.signal() + } + let service = fixture.service(uploadBackup: { key, _ in + await uploads.record(key) + }, waitForBackupDelay: { + delayStarted.fulfill() + for await _ in delay {} + throw CancellationError() + }) + await service.startObservingBackups()?.value + await fulfillment(of: [delayStarted], timeout: 2) + Bitkit.ServiceQueue.ServiceTypes.backup.queue.async { + queueHeld.fulfill() + XCTAssertEqual(releaseQueue.wait(timeout: .now() + 2), .success) + } + await fulfillment(of: [queueHeld], timeout: 2) + let earlySubscription = service.backupStatusesPublisher + .filter { $0[.settings]?.running == false } + .first().sink { _ in prematureIdle.fulfill() } + releaseDelay.finish() + await fulfillment(of: [prematureIdle], timeout: 0.2) + earlySubscription.cancel() + let settledSubscription = service.backupStatusesPublisher + .filter { $0[.settings]?.running == false } + .first().sink { _ in settled.fulfill() } + defer { settledSubscription.cancel() } + releaseQueue.signal() + await fulfillment(of: [settled], timeout: 2) + + await service.setAppActive(true)?.value + let recorded = await uploads.counts + XCTAssertEqual(recorded, [BackupCategory.settings.rawValue: 1]) + XCTAssertFalse(service.getBackupStatus(category: .settings).isRequired) + service.setAppActive(false) + await service.stopObservingBackups() + } + private func startAfterPendingJobsSettle(_ service: BackupService, categories: [BackupCategory]) async { let settled = categories.map { expectation(description: "Initial \($0.rawValue) scheduling settled") } let subscriptions = zip(categories, settled).map { category, completed in @@ -526,3 +588,22 @@ private actor UploadRecorder { counts[key, default: 0] += 1 } } + +private actor CaptureGate { + private(set) var isHeld = true + private var continuation: CheckedContinuation? + + func wait(started: XCTestExpectation) async { + guard isHeld else { return } + await withCheckedContinuation { continuation in + self.continuation = continuation + started.fulfill() + } + } + + func release() { + isHeld = false + continuation?.resume() + continuation = nil + } +} diff --git a/BitkitTests/BackupServiceTests.swift b/BitkitTests/BackupServiceTests.swift index 5a2a7a36c..5bbc41ba4 100644 --- a/BitkitTests/BackupServiceTests.swift +++ b/BitkitTests/BackupServiceTests.swift @@ -1,10 +1,220 @@ @testable import Bitkit import Combine import Paykit +import VssRustClientFfi import XCTest @MainActor final class BackupServiceTests: XCTestCase { + func testExplicitBackupsRefusePayloadCaptureDuringWipe() async throws { + let suite = "BackupServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let recorder = BackupRecorder() + let service = BackupService(defaults: defaults, backupData: { _ in + await recorder.recordExport() + return Data("original-wallet".utf8) + }, uploadBackup: { key, data in + await recorder.recordUpload(key: key, data: data) + }) + service.setWiping(true) + for category in [BackupCategory.settings, .widgets, .activity, .blocktank] { + let outcome = await service.triggerBackup(category: category) + XCTAssertEqual(outcome, .failed) + } + let exports = await recorder.exports + let uploads = await recorder.uploads + XCTAssertEqual(exports, 0) + XCTAssertTrue(uploads.isEmpty) + service.setWiping(false) + await service.stopObservingBackups() + } + + func testExplicitPayloadCannotUploadAfterWipeFlagReopens() async throws { + let suite = "BackupServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let captured = expectation(description: "Original wallet payload capture held") + let gate = BackupDelayGate() + let recorder = BackupRecorder() + let service = BackupService(defaults: defaults, backupData: { _ in + await gate.wait(started: captured) + return Data("original-wallet".utf8) + }, uploadBackup: { key, data in + await recorder.recordUpload(key: key, data: data) + }) + let backup = Task { await service.triggerBackup(category: .settings) } + await fulfillment(of: [captured], timeout: 2) + service.setWiping(true) + service.setWiping(false) + await gate.release() + let outcome = await backup.value + XCTAssertEqual(outcome, .failed) + let uploads = await recorder.uploads + XCTAssertTrue(uploads.isEmpty) + XCTAssertTrue(service.getBackupStatus(category: .settings).isRequired) + XCTAssertFalse(service.getBackupStatus(category: .settings).running) + await service.stopObservingBackups() + } + + func testReleasedVssAuthTransportIsTemporaryWithoutRetryingAuthorizationFailures() { + let requestFailure = "error sending request for url (https://backup.example/lnurl_auth/auth)" + for details in [requestFailure, "error performing external request: \(requestFailure)", + "error sending request for url (http://127.0.0.1:1/custom/callback)", + "error sending request for url (http://[::1]:8080/auth?value=a%20b)", + "error sending request for url (https://xn--bcher-kva.example/auth)"] + { + XCTAssertTrue(BackupService.isTemporaryBackupFailure(VssError.AuthError(errorDetails: details)), details) + } + for details in ["permission denied", "invalid JWT", "HTTP status client error (401 Unauthorized)", + "error decoding response body", "error performing external request: permission denied", + "error performing external request: error performing external request: \(requestFailure)", + "prefix \(requestFailure)", "\(requestFailure) suffix", + "error sending request for url (not a URL)", + "error sending request for url (file://example.com/auth)", + "error sending request for url (https:///auth)", + "error sending request for url (https://backup.example/auth) trailing)", + "error sending request for url (https://backup.example/auth path)", + "error sending request for url (https://%20/auth)", + "error sending request for url (https://auth%20example.com/auth)", + "error sending request for url (https://%2F/auth)", + "error sending request for url (https://%3A/auth)", + "error sending request for url (https://%40/auth)", + "error sending request for url (https://[abc]/auth)", + "error sending request for url (https://[:::]/auth)", + "error sending request for url (https://host:99999/auth)"] + { + XCTAssertFalse(BackupService.isTemporaryBackupFailure(VssError.AuthError(errorDetails: details)), details) + } + XCTAssertFalse(BackupService.isTemporaryBackupFailure(VssError.UnknownError(errorDetails: requestFailure))) + } + + func testReleasedVssAuthTransportRetriesScheduledSettingsWithoutObservation() async throws { + let suite = "BackupServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let completed = expectation(description: "Settings acknowledged after auth transport recovers") + var attempts = 0 + var delays: [UInt64] = [] + let payload = Data("settings-snapshot".utf8) + let service = BackupService(defaults: defaults, backupData: { _ in payload }, uploadBackup: { key, data in + XCTAssertEqual(key, BackupCategory.settings.rawValue) + XCTAssertEqual(data, payload) + attempts += 1 + if attempts == 1 { + throw VssError.AuthError(errorDetails: "error performing external request: error sending request for url (http://127.0.0.1:1/auth)") + } + }, waitForBackupDelay: {}, waitForTemporaryFailureDelay: { delays.append($0) }) + await service.stopObservingBackups() + let subscription = service.backupStatusesPublisher + .filter { $0[.settings].map { !$0.isRequired && !$0.running } == true } + .first().sink { _ in completed.fulfill() } + service.markBackupRequired(category: .settings) + await fulfillment(of: [completed], timeout: 3) + subscription.cancel() + service.setWiping(true) + await service.stopObservingBackups() + XCTAssertEqual(attempts, 2) + XCTAssertEqual(delays, [10_000_000_000]) + XCTAssertFalse(service.getBackupStatus(category: .settings).isRequired) + } + + func testReleasedVssAuthorizationDenialStaysPendingWithoutAutomaticRetry() async throws { + let suite = "BackupServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let attempted = expectation(description: "Authorization refused") + let retried = expectation(description: "Authorization refusal automatically retried") + retried.isInverted = true + var attempts = 0 + let service = BackupService(defaults: defaults, backupData: { _ in Data("settings-snapshot".utf8) }, uploadBackup: { _, _ in + attempts += 1 + if attempts == 1 { + attempted.fulfill() + } else { + retried.fulfill() + } + throw VssError.AuthError(errorDetails: "permission denied") + }, waitForBackupDelay: {}, waitForTemporaryFailureDelay: { _ in retried.fulfill() }) + await service.stopObservingBackups() + service.markBackupRequired(category: .settings) + await fulfillment(of: [attempted], timeout: 3) + await fulfillment(of: [retried], timeout: 0.2) + service.setWiping(true) + await service.stopObservingBackups() + XCTAssertEqual(attempts, 1) + XCTAssertTrue(service.getBackupStatus(category: .settings).isRequired) + XCTAssertFalse(service.getBackupStatus(category: .settings).running) + } + + func testReleasedVssConnectionErrorIsTemporaryWithoutBroadUnknownRetry() { + XCTAssertTrue(BackupService.isTemporaryBackupFailure( + VssError.UnknownError(errorDetails: "error sending request for url (http://127.0.0.1:1/putObjects)") + )) + XCTAssertTrue(BackupService.isTemporaryBackupFailure( + VssError.UnknownError(errorDetails: "error sending request for url (https://backup.example/vss/putObjects)") + )) + for details in ["invalid ciphertext", "permission denied", "HTTP status client error (401 Unauthorized)", + "error sending request for url (not a URL)", "prefix error sending request for url (https://example.com)", + "error sending request for url (https://example.com/getObject)", + "error sending request for url (file://example.com/putObjects)", + "error sending request for url (https:///putObjects)", + "error sending request for url (https://example.com/putObjects) suffix"] + { + XCTAssertFalse(BackupService.isTemporaryBackupFailure(VssError.UnknownError(errorDetails: details))) + } + XCTAssertFalse(BackupService.isTemporaryBackupFailure(URLError(.notConnectedToInternet))) + XCTAssertFalse(BackupService.isTemporaryBackupFailure(CancellationError())) + } + + func testReleasedVssConnectionFailureRetriesStoppedUsdtWithBoundedBackoff() async throws { + let suite = "BackupServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let completed = expectation(description: "Recovery upload acknowledged after connection resumes") + var attempts = 0 + var delays: [UInt64] = [] + let payload = Data("same-signed-recovery".utf8) + let service = BackupService(defaults: defaults, backupData: { _ in payload }, uploadBackup: { key, data in + XCTAssertEqual(key, BackupCategory.usdt.rawValue) + XCTAssertEqual(data, payload) + attempts += 1 + if attempts <= 8 { + throw VssError.UnknownError(errorDetails: "error sending request for url (http://127.0.0.1:1/putObjects)") + } + }, waitForBackupDelay: {}, waitForTemporaryFailureDelay: { delays.append($0) }) + await service.stopObservingBackups() + let subscription = service.backupStatusesPublisher + .filter { $0[.usdt].map { !$0.isRequired && !$0.running } == true } + .first().sink { _ in completed.fulfill() } + service.markBackupRequired(category: .usdt) + await fulfillment(of: [completed], timeout: 3) + subscription.cancel() + service.setWiping(true) + await service.stopObservingBackups() + XCTAssertEqual(attempts, 9) + XCTAssertEqual(delays, [10, 20, 40, 80, 160, 300, 300, 300].map { UInt64($0) * 1_000_000_000 }) + XCTAssertFalse(service.getBackupStatus(category: .usdt).isRequired) + } + + func testRepeatedPendingNotificationsDoNotRewriteUnchangedDurableStatus() throws { + let suite = "BackupServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(CountingBackupDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let required = UInt64(Date().timeIntervalSince1970) + 3600 + let pending = BackupItemStatus(synced: 0, required: required, running: false) + try defaults.set(JSONEncoder().encode([BackupCategory.wallet.rawValue: pending]), forKey: "backupStatuses") + let service = BackupService(defaults: defaults) + let writes = defaults.backupStatusWrites + for _ in 0 ..< 200 { + service.markBackupRequired(category: .wallet, schedule: false) + } + XCTAssertEqual(defaults.backupStatusWrites, writes) + XCTAssertEqual(service.getBackupStatus(category: .wallet), pending) + let restarted = BackupService(defaults: defaults) + XCTAssertEqual(restarted.getBackupStatus(category: .wallet), pending) + } + func testPublicAddressMigrationSchedulesWalletBackupOnStartup() async throws { let defaults = try makeIsolatedDefaults() defaults.set(3, forKey: AppDataMigrations.versionKey) @@ -221,6 +431,334 @@ final class BackupServiceTests: XCTestCase { } } + func testStopDrainsCancelledWorkBeforeManualBackupAndRestart() async throws { + let suite = "BackupServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let started = expectation(description: "Scheduled delay started") + let cancelled = expectation(description: "Scheduled delay cancelled") + let uploadedEarly = expectation(description: "Manual upload must wait for cleanup") + uploadedEarly.isInverted = true + let gate = BackupDelayGate() + let recorder = BackupRecorder() + let service = BackupService(defaults: defaults, backupData: { _ in Data("complete".utf8) }, uploadBackup: { key, data in + await recorder.recordUpload(key: key, data: data) + if await gate.isHeld { + uploadedEarly.fulfill() + } + }, waitForBackupDelay: { + try await withTaskCancellationHandler { + await gate.wait(started: started) + try Task.checkCancellation() + } onCancel: { cancelled.fulfill() } + }) + service.markBackupRequired(category: .wallet) + await fulfillment(of: [started], timeout: 2) + let stop = Task { + await service.stopObservingBackups() + await gate.recordStop() + } + await fulfillment(of: [cancelled], timeout: 2) + let deferred = expectation(description: "New USDT request survives the drain") + let deferredSubscription = service.backupStatusesPublisher + .filter { $0[.usdt].map { !$0.isRequired && !$0.running } == true } + .first().sink { _ in deferred.fulfill() } + service.markBackupRequired(category: .usdt) + let manual = Task { await service.triggerBackup(category: .activity) } + await fulfillment(of: [uploadedEarly], timeout: 0.1) + let stoppedBeforeDrain = await gate.didStop + XCTAssertFalse(stoppedBeforeDrain) + await gate.release() + await stop.value + let outcome = await manual.value + await fulfillment(of: [deferred], timeout: 2) + deferredSubscription.cancel() + XCTAssertEqual(outcome, .completed) + XCTAssertTrue(service.getBackupStatus(category: .wallet).isRequired) + XCTAssertFalse(service.getBackupStatus(category: .wallet).running) + let uploads = await recorder.uploads + XCTAssertEqual(Set(uploads.map(\.key)), [BackupCategory.activity.rawValue, BackupCategory.usdt.rawValue]) + XCTAssertEqual(uploads.count, 2) + await service.stopObservingBackups() + + let restarted = BackupService(defaults: defaults, backupData: { _ in Data("latest".utf8) }, + uploadBackup: { _, _ in }, waitForBackupDelay: {}) + let completed = expectation(description: "Restart uploads retained work") + let subscription = restarted.backupStatusesPublisher + .filter { $0[.wallet].map { !$0.isRequired && !$0.running } == true } + .first().sink { _ in completed.fulfill() } + restarted.startObservingBackups() + await fulfillment(of: [completed], timeout: 2) + subscription.cancel() + await restarted.stopObservingBackups() + XCTAssertFalse(restarted.getBackupStatus(category: .wallet).isRequired) + } + + func testRestartRecordsWalletChangesWhilePreviousCleanupDrains() async throws { + let suite = "BackupServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let started = expectation(description: "Old scheduled work started") + let cancelled = expectation(description: "Old work entered cancellation cleanup") + let completed = expectation(description: "Restart uploads the change made during cleanup") + let early = expectation(description: "New upload must wait for old cleanup") + early.isInverted = true + let gate = BackupDelayGate() + let recorder = BackupRecorder() + var state = Data("initial".utf8) + var delays = 0 + let service = BackupService(defaults: defaults, backupData: { _ in state }, uploadBackup: { key, data in + await recorder.recordUpload(key: key, data: data) + if data == Data("updated".utf8), await gate.isHeld { + early.fulfill() + } + }, waitForBackupDelay: { + delays += 1 + guard delays == 1 else { return } + try await withTaskCancellationHandler { + await gate.wait(started: started) + try Task.checkCancellation() + } onCancel: { cancelled.fulfill() } + }) + await service.triggerBackup(category: .wallet) + service.markBackupRequired(category: .settings) + await fulfillment(of: [started], timeout: 2) + let stop = Task { await service.stopObservingBackups() } + await fulfillment(of: [cancelled], timeout: 2) + + service.startObservingBackups() + state = Data("updated".utf8) + try Bitkit.WatchOnlyAccountStore.save([], defaults: defaults) + XCTAssertTrue(service.getBackupStatus(category: .wallet).isRequired) + let beforeDrain = await recorder.uploads + XCTAssertEqual(beforeDrain.map(\.data), [Data("initial".utf8)]) + let subscription = service.backupStatusesPublisher + .filter { $0[.wallet].map { !$0.isRequired && !$0.running } == true } + .first().sink { _ in completed.fulfill() } + await fulfillment(of: [early], timeout: 0.1) + await gate.release() + await stop.value + await fulfillment(of: [completed], timeout: 2) + subscription.cancel() + await service.stopObservingBackups() + let uploads = await recorder.uploads.filter { $0.key == BackupCategory.wallet.rawValue } + XCTAssertEqual(uploads.map(\.data), [Data("initial".utf8), Data("updated".utf8)]) + } + + func testStopRetainsEditsUntilAcknowledgedUploadDrains() async throws { + let suite = "BackupServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let settingsDefaults = UserDefaults.standard + let original = settingsDefaults.object(forKey: "swipeBalanceToHide") + defer { + if let original { + settingsDefaults.set(original, forKey: "swipeBalanceToHide") + } else { + settingsDefaults.removeObject(forKey: "swipeBalanceToHide") + } + } + let entered = expectation(description: "Captured settings upload entered") + let cancelled = expectation(description: "Stop cancelled the held upload") + let unexpectedUpload = expectation(description: "Stopped observers cannot upload new data") + unexpectedUpload.isInverted = true + let gate = BackupDelayGate() + let recorder = BackupRecorder() + var data = Data("initial".utf8) + var holdUpload = false + var mayUploadLatest = false + let service = BackupService(defaults: defaults, backupData: { _ in data }, uploadBackup: { key, bytes in + await recorder.recordUpload(key: key, data: bytes) + if bytes == Data("latest".utf8), !mayUploadLatest { + unexpectedUpload.fulfill() + } + if holdUpload, key == BackupCategory.settings.rawValue { + holdUpload = false + await withTaskCancellationHandler { + await gate.wait(started: entered) + } onCancel: { cancelled.fulfill() } + } + }, waitForBackupDelay: {}) + await service.triggerBackup(category: .settings) + await service.triggerBackup(category: .wallet) + service.startObservingBackups() + holdUpload = true + data = Data("captured".utf8) + settingsDefaults.set(!settingsDefaults.bool(forKey: "swipeBalanceToHide"), forKey: "swipeBalanceToHide") + await fulfillment(of: [entered], timeout: 2) + let stop = Task { await service.stopObservingBackups() } + await fulfillment(of: [cancelled], timeout: 2) + + data = Data("latest".utf8) + settingsDefaults.set(!settingsDefaults.bool(forKey: "swipeBalanceToHide"), forKey: "swipeBalanceToHide") + try Bitkit.WatchOnlyAccountStore.save([], defaults: defaults) + XCTAssertTrue(service.getBackupStatus(category: .settings).isRequired) + XCTAssertTrue(service.getBackupStatus(category: .wallet).isRequired) + await gate.release() + await stop.value + for category in [BackupCategory.settings, .wallet] { + XCTAssertTrue(service.getBackupStatus(category: category).isRequired) + XCTAssertFalse(service.getBackupStatus(category: category).running) + } + await fulfillment(of: [unexpectedUpload], timeout: 0.1) + let stoppedUploads = await recorder.uploads + XCTAssertEqual(stoppedUploads.map(\.data), [Data("initial".utf8), Data("initial".utf8), Data("captured".utf8)]) + + mayUploadLatest = true + let completed = expectation(description: "Restart uploads both latest sources") + let subscription = service.backupStatusesPublisher + .filter { statuses in + [BackupCategory.settings, .wallet].allSatisfy { category in + statuses[category].map { !$0.isRequired && !$0.running } == true + } + }.first().sink { _ in completed.fulfill() } + service.startObservingBackups() + await fulfillment(of: [completed], timeout: 2) + subscription.cancel() + await service.stopObservingBackups() + let latest = await recorder.uploads.filter { $0.data == Data("latest".utf8) } + XCTAssertEqual(Set(latest.map(\.key)), [BackupCategory.settings.rawValue, BackupCategory.wallet.rawValue]) + XCTAssertEqual(latest.count, 2) + } + + func testCancelledUploadRetainsNewerSettingsAfterRestart() async throws { + let suite = "BackupServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let entered = expectation(description: "Settings upload entered") + let gate = BackupDelayGate() + let service = BackupService(defaults: defaults, backupData: { _ in Data("old".utf8) }, uploadBackup: { _, _ in + await gate.wait(started: entered) + try Task.checkCancellation() + }) + let upload = Task { await service.triggerBackup(category: .settings) } + await fulfillment(of: [entered], timeout: 2) + upload.cancel() + service.markBackupRequired(category: .settings, schedule: false) + await gate.release() + let outcome = await upload.value + XCTAssertEqual(outcome, .failed) + XCTAssertTrue(service.getBackupStatus(category: .settings).isRequired) + await service.stopObservingBackups() + + let uploaded = expectation(description: "Restart uploads latest settings") + let restarted = BackupService(defaults: defaults, backupData: { _ in Data("latest".utf8) }, uploadBackup: { key, data in + XCTAssertEqual(key, BackupCategory.settings.rawValue) + XCTAssertEqual(data, Data("latest".utf8)) + uploaded.fulfill() + }, waitForBackupDelay: {}) + restarted.startObservingBackups() + await fulfillment(of: [uploaded], timeout: 2) + await restarted.stopObservingBackups() + } + + func testImmediateStopRetainsReceivedSettingsChange() async throws { + let suite = "BackupServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let settingsDefaults = UserDefaults.standard + let original = settingsDefaults.object(forKey: "swipeBalanceToHide") + defer { + if let original { + settingsDefaults.set(original, forKey: "swipeBalanceToHide") + } else { + settingsDefaults.removeObject(forKey: "swipeBalanceToHide") + } + } + let service = BackupService(defaults: defaults, backupData: { _ in Data() }, uploadBackup: { _, _ in + XCTFail("Immediate stop must prevent upload") + }) + service.startObservingBackups() + let received = expectation(description: "Settings notification received") + let subscription = SettingsViewModel.shared.settingsPublisher.first().sink { _ in received.fulfill() } + settingsDefaults.set(!settingsDefaults.bool(forKey: "swipeBalanceToHide"), forKey: "swipeBalanceToHide") + await fulfillment(of: [received], timeout: 2) + subscription.cancel() + await service.stopObservingBackups() + XCTAssertTrue(service.getBackupStatus(category: .settings).isRequired) + XCTAssertFalse(service.getBackupStatus(category: .settings).running) + } + + func testStopWaitsForFinalScheduledStatusWrite() async throws { + let suite = "BackupServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let delayStarted = expectation(description: "Scheduled backup entered delay") + let cleanupStarted = expectation(description: "Final status write entered") + let stoppedEarly = expectation(description: "Stop must wait for final status write") + stoppedEarly.isInverted = true + let delayGate = BackupDelayGate() + let cleanupGate = DispatchSemaphore(value: 0) + let stopGate = BackupDelayGate() + let service = BackupService(defaults: defaults, backupData: { _ in Data() }, + uploadBackup: { _, _ in XCTFail("Wiping must prevent upload") }, + waitForBackupDelay: { await delayGate.wait(started: delayStarted) }) + service.markBackupRequired(category: .wallet) + await fulfillment(of: [delayStarted], timeout: 2) + let subscription = service.backupStatusesPublisher + .filter { $0[.wallet]?.running == false } + .first().sink { _ in + cleanupStarted.fulfill() + cleanupGate.wait() + } + service.setWiping(true) + await delayGate.release() + await fulfillment(of: [cleanupStarted], timeout: 2) + let stop = Task { + await service.stopObservingBackups() + if await stopGate.isHeld { + stoppedEarly.fulfill() + } + } + await fulfillment(of: [stoppedEarly], timeout: 0.1) + await stopGate.release() + cleanupGate.signal() + await stop.value + subscription.cancel() + XCTAssertTrue(service.getBackupStatus(category: .wallet).isRequired) + XCTAssertFalse(service.getBackupStatus(category: .wallet).running) + } + + func testImmediateStartStopDoesNotObserveLaterWalletChanges() async throws { + let suite = "BackupServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let service = BackupService(defaults: defaults, backupData: { _ in Data("complete".utf8) }, + uploadBackup: { _, _ in }, waitForBackupDelay: {}) + for _ in 0 ..< 25 { + service.startObservingBackups() + await service.stopObservingBackups() + let stoppedStatus = service.getBackupStatus(category: .wallet) + try Bitkit.WatchOnlyAccountStore.save([], defaults: defaults) + XCTAssertEqual(service.getBackupStatus(category: .wallet), stoppedStatus) + } + } + + func testExplicitUsdtBackupRetriesWhileObserversRemainStopped() async throws { + let suite = "BackupServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let completed = expectation(description: "USDT retry acknowledged without node observation") + var captures = 0 + let service = BackupService(defaults: defaults, backupData: { _ in + captures += 1 + if captures == 1 { + throw PaykitError.SharedStateBusy(code: "shared_state_busy", context: "temporarily unavailable") + } + return Data("signed-recovery".utf8) + }, uploadBackup: { _, _ in }, waitForBackupDelay: {}, waitForTemporaryFailureDelay: { _ in }) + await service.stopObservingBackups() + let subscription = service.backupStatusesPublisher + .filter { $0[.usdt].map { !$0.isRequired && !$0.running } == true } + .first().sink { _ in completed.fulfill() } + service.markBackupRequired(category: .usdt) + await fulfillment(of: [completed], timeout: 2) + subscription.cancel() + await service.stopObservingBackups() + XCTAssertEqual(captures, 2) + XCTAssertFalse(service.getBackupStatus(category: .usdt).isRequired) + } + func testTemporaryFailureRemainsPendingAcrossServiceRestart() async throws { let suite = "BackupServiceTests.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) @@ -883,6 +1421,49 @@ final class BackupServiceTests: XCTestCase { await service.stopObservingBackups() } + func testCancellingMetadataWaitDoesNotCancelExistingWalletUpload() async throws { + let walletStarted = expectation(description: "Wallet upload started") + let walletCancelled = expectation(description: "Metadata waiter cancelled another owner's wallet upload") + walletCancelled.isInverted = true + let metadataFinished = expectation(description: "Metadata finished before the held wallet upload") + metadataFinished.isInverted = true + let (heldUpload, releaseUpload) = AsyncStream.makeStream() + defer { releaseUpload.finish() } + let defaults = try makeIsolatedDefaults() + var uploadedKeys: [String] = [] + let service = BackupService(defaults: defaults, backupData: { _ in Data() }, uploadBackup: { key, _ in + if key == BackupCategory.wallet.rawValue { + await withTaskCancellationHandler { + walletStarted.fulfill() + for await _ in heldUpload {} + } onCancel: { + walletCancelled.fulfill() + } + } + uploadedKeys.append(key) + }, waitForBackupDelay: { throw CancellationError() }) + service.markBackupRequired(category: .wallet, schedule: false) + let wallet = Task { await service.triggerBackup(category: .wallet) } + await fulfillment(of: [walletStarted], timeout: 2) + let metadata = Task { + let outcome = await service.triggerBackup(category: .metadata) + metadataFinished.fulfill() + return outcome + } + await fulfillment(of: [metadataFinished], timeout: 0.1) + metadata.cancel() + await fulfillment(of: [walletCancelled], timeout: 0.1) + releaseUpload.finish() + let walletOutcome = await wallet.value + let metadataOutcome = await metadata.value + XCTAssertTrue(walletOutcome == .completed) + XCTAssertFalse(metadataOutcome == .completed) + XCTAssertEqual(uploadedKeys, [BackupCategory.wallet.rawValue]) + XCTAssertTrue(service.getBackupStatus(category: .metadata).isRequired) + service.setWiping(true) + await service.stopObservingBackups() + } + private func makeLegacyMetadataBackupDefaults() throws -> UserDefaults { let defaults = try makeIsolatedDefaults() defaults.set([LegacyMetadataBackupServer.legacyAddress], forKey: PrivatePaykitAddressReservationStore.legacyPublicAddressesKey) @@ -1016,3 +1597,43 @@ private final class WalletBackupSdk: PaykitSdk, @unchecked Sendable { return "wallet-backup" } } + +private final class CountingBackupDefaults: UserDefaults { + private let writeLock = NSLock() + private var writes = 0 + + var backupStatusWrites: Int { + writeLock.withLock { writes } + } + + override func set(_ value: Any?, forKey defaultName: String) { + if defaultName == "backupStatuses" { + writeLock.withLock { writes += 1 } + } + super.set(value, forKey: defaultName) + } +} + +private actor BackupDelayGate { + private(set) var isHeld = true + private(set) var didStop = false + private var continuation: CheckedContinuation? + + func wait(started: XCTestExpectation) async { + guard isHeld else { return } + await withCheckedContinuation { continuation in + self.continuation = continuation + started.fulfill() + } + } + + func recordStop() { + didStop = true + } + + func release() { + isHeld = false + continuation?.resume() + continuation = nil + } +} diff --git a/BitkitTests/InitializingWalletViewTests.swift b/BitkitTests/InitializingWalletViewTests.swift index 7e23a2119..a8eb9b078 100644 --- a/BitkitTests/InitializingWalletViewTests.swift +++ b/BitkitTests/InitializingWalletViewTests.swift @@ -4,6 +4,29 @@ import XCTest @MainActor final class InitializingWalletViewTests: XCTestCase { + func testNormalizingCompletedProgressInvokesCompletionOnce() async { + var completionCount = 0 + let completion = expectation(description: "Wallet initialization completes once") + let window = UIWindow(frame: UIScreen.main.bounds) + window.rootViewController = UIHostingController(rootView: InitializingWalletView( + nodeLifecycleState: .constant(.running), initialPercentage: 98.75 + ) { + completionCount += 1 + if completionCount == 1 { + completion.fulfill() + } + }) + window.makeKeyAndVisible() + defer { + window.isHidden = true + window.rootViewController = nil + } + + await fulfillment(of: [completion], timeout: 3) + try? await Task.sleep(for: .milliseconds(200)) + XCTAssertEqual(completionCount, 1) + } + func testSlowRetryWaitsForRunningNodeBeforeCompleting() async { let state = InitializationTestState() var completionCount = 0 diff --git a/BitkitTests/PaykitSdkClientConfigTests.swift b/BitkitTests/PaykitSdkClientConfigTests.swift index 6c919cff3..88d2858a9 100644 --- a/BitkitTests/PaykitSdkClientConfigTests.swift +++ b/BitkitTests/PaykitSdkClientConfigTests.swift @@ -586,14 +586,22 @@ final class PaykitSdkClientConfigTests: XCTestCase { try Keychain.delete(key: .paykitKeyGeneration(publicKey: publicKey)) let republishStarted = expectation(description: "Republish started") let republishFinished = expectation(description: "Republish finished") + let republishFinishedBeforeGateOpened = expectation(description: "Republish finished before gate opened") + republishFinishedBeforeGateOpened.isInverted = true let activationReturned = expectation(description: "Activation returned") let gate = AsyncStream.makeStream() + defer { gate.continuation.finish() } let bootstrap = CacheActivationBootstrap(noPointer: .init()) bootstrap.republishOperation = { republishStarted.fulfill() + var gateOpened = false for await _ in gate.stream { + gateOpened = true break } + if !gateOpened { + republishFinishedBeforeGateOpened.fulfill() + } republishFinished.fulfill() return true } @@ -608,13 +616,24 @@ final class PaykitSdkClientConfigTests: XCTestCase { try await service.activateRegisteredIdentity(.init(result: result, walletGeneration: 0)) activationReturned.fulfill() } - // Shorter than the republish timeout, so an activation that awaited the publication would miss it. - await fulfillment(of: [activationReturned, republishStarted], timeout: 2) - XCTAssertEqual(bootstrap.republishedKeys, [result.publicKey]) + defer { activation.cancel() } + try await withTaskCancellationHandler { + let setup = await XCTWaiter.fulfillment(of: [republishStarted], timeout: 10) + XCTAssertEqual(setup, .completed, "Identity activation must reach the gated publication") + if setup == .completed { + // Measure the return after credential preparation, below the unchanged five-second publication cap. + await fulfillment(of: [activationReturned, republishFinishedBeforeGateOpened], timeout: 2) + XCTAssertEqual(bootstrap.republishedKeys, [result.publicKey]) + } - gate.continuation.yield() - await fulfillment(of: [republishFinished], timeout: 1) - try await activation.value + gate.continuation.yield() + gate.continuation.finish() + await fulfillment(of: [republishFinished], timeout: 1) + try await activation.value + } onCancel: { + gate.continuation.finish() + activation.cancel() + } } func testSessionImportDoesNotCreateRuntimeBeforeBootstrapSucceeds() async throws { diff --git a/BitkitUITests/UsdtWalletUITests.swift b/BitkitUITests/UsdtWalletUITests.swift index 4a72bd7e7..0e589078c 100644 --- a/BitkitUITests/UsdtWalletUITests.swift +++ b/BitkitUITests/UsdtWalletUITests.swift @@ -39,7 +39,7 @@ final class UsdtWalletUITests: XCTestCase { XCTAssertFalse(app.staticTexts["Could not update USDT. Check your connection and try again."].exists) XCTAssertFalse(app.staticTexts["Older activity is still syncing. Your balance is up to date when the connection is available."].exists) capture(app, "USDT unknown wallet in German region") - try XCTUnwrap(app.buttons.matching(identifier: "NavigationBack").allElementsBoundByIndex.first(where: \.isHittable)).tap() + try tapBack(in: app) app.buttons["Send"].tap() XCTAssertTrue(app.buttons["RecipientManual"].waitForExistence(timeout: 5)) app.buttons["RecipientManual"].tap() @@ -69,9 +69,9 @@ final class UsdtWalletUITests: XCTestCase { XCTAssertTrue(network.isEnabled) network.tap() XCTAssertTrue(app.buttons["UsdtNetwork-Polygon"].waitForExistence(timeout: 5)) - try XCTUnwrap(app.buttons.matching(identifier: "NavigationBack").allElementsBoundByIndex.first(where: \.isHittable)).tap() + try tapBack(in: app) XCTAssertTrue(app.buttons["UsdtReview"].waitForExistence(timeout: 5)) - try XCTUnwrap(app.buttons.matching(identifier: "NavigationBack").allElementsBoundByIndex.first(where: \.isHittable)).tap() + try tapBack(in: app) let restoredRecipient = app.textViews["RecipientInput"] XCTAssertTrue(restoredRecipient.waitForExistence(timeout: 5)) XCTAssertEqual(restoredRecipient.value as? String, recipient) @@ -178,6 +178,16 @@ final class UsdtWalletUITests: XCTestCase { capture(app, "USDT saved activity offline after restart") } + @MainActor + private func tapBack(in app: XCUIApplication) throws { + let buttons = app.buttons.matching(identifier: "NavigationBack") + let available = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in + buttons.allElementsBoundByIndex.contains(where: \.isHittable) + }, object: app) + XCTAssertEqual(XCTWaiter.wait(for: [available], timeout: 10), .completed, app.debugDescription) + try XCTUnwrap(buttons.allElementsBoundByIndex.first(where: \.isHittable)).tap() + } + @MainActor private func capture(_ app: XCUIApplication, _ name: String) { let attachment = XCTAttachment(screenshot: app.screenshot()) @@ -248,7 +258,7 @@ final class UsdtWalletUITests: XCTestCase { } app.buttons["SpecifyInvoiceButton"].tap() app.buttons["N5"].tap() - try XCTUnwrap(app.buttons.matching(identifier: "NavigationBack").allElementsBoundByIndex.first(where: \.isHittable)).tap() + try tapBack(in: app) XCTAssertTrue(qr.waitForExistence(timeout: 5)) XCTAssertEqual(qr.label, amountlessUri) app.buttons["SpecifyInvoiceButton"].tap() @@ -259,7 +269,7 @@ final class UsdtWalletUITests: XCTestCase { XCTAssertTrue(committedUri.contains("uint256=3000000")) app.buttons["SpecifyInvoiceButton"].tap() app.buttons["N5"].tap() - try XCTUnwrap(app.buttons.matching(identifier: "NavigationBack").allElementsBoundByIndex.first(where: \.isHittable)).tap() + try tapBack(in: app) XCTAssertTrue(qr.waitForExistence(timeout: 5)) XCTAssertEqual(qr.label, committedUri) app.buttons["UsdtReceiveDetails"].tap() diff --git a/changelog.d/next/1123.fixed.md b/changelog.d/next/1123.fixed.md new file mode 100644 index 000000000..892f8fd43 --- /dev/null +++ b/changelog.d/next/1123.fixed.md @@ -0,0 +1 @@ +Preserve pending backups through observation changes and temporary connection failures, keep foreground retries available as scheduled backups finish, complete wallet initialization only once after node startup, and refuse old backup requests across wallet removal. diff --git a/journeys/backup-restore/wallet-snapshot-consistency.md b/journeys/backup-restore/wallet-snapshot-consistency.md index 0c5e7acfb..767d93452 100644 --- a/journeys/backup-restore/wallet-snapshot-consistency.md +++ b/journeys/backup-restore/wallet-snapshot-consistency.md @@ -34,3 +34,30 @@ mutation. A genuine capture/upload failure must not enter an automatic retry loo Record the tested commit, gate locations, attempts and upload counts, and the restored state. Unit coverage does not establish a device pass or a maximum backup delay during sustained activity. + +## Backup observation shutdown + +The following controls require the same dedicated lifecycle/capture fixture; +these are not ordinary UI journey capabilities. No wallet reset is needed. + +1. Hold a scheduled backup in its cancellable debounce, stop observation, and + request a manual metadata backup while cancellation cleanup remains held. + Verify shutdown and the manual upload both wait for cleanup, including a final + status write held after the delay finishes. Release cleanup; + verify pending wallet work remains required and resumes on observation restart. + Change wallet/settings state after requesting restart while cleanup is held; + verify no new upload starts before the drain and the latest state uploads + afterward without another edit. Repeat with a canceled settings upload; its + cleanup must not clear a newer required marker. +2. Request a new USDT recovery backup during cleanup and again after observation + is stopped. Verify both requests can finish without restarting Lightning, and + transient backup failures retain bounded automatic retry. +3. Repeatedly start and immediately stop observation. Change a wallet record only + after stop returns; verify the stopped observer does not schedule an upload or + mutate its saved backup status. Restart observation and verify a single set of + listeners resumes pending work. Also receive a settings-change notification + immediately before stop: its pending marker must survive the stopped listener. + +The iOS lifecycle unit tests exercise these component boundaries with injected +capture/upload and delay controls. They do not prove a full device fault journey. +Android's related job-ownership and reset paths require separate validation.