Keep using Photos.app like you always do. Attic quietly backs up your originals and edits to an S3 bucket you control. One-way, append-only.
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge feat/network-resilience: sleep/wake network resilience

+667 -63
+14 -1
Sources/AtticCLI/BackupCommand.swift
··· 63 63 dryRun: dryRun 64 64 ) 65 65 66 + // Prevent idle sleep during backup (released automatically via deinit) 67 + let powerAssertion = PowerAssertion(reason: "Backing up photos to S3") 68 + let networkMonitor = NWPathNetworkMonitor() 69 + 66 70 let report = try await runBackup( 67 71 assets: assets, 68 72 manifest: &manifest, ··· 70 74 exporter: exporter, 71 75 s3: s3, 72 76 options: options, 73 - progress: progress 77 + progress: progress, 78 + networkMonitor: networkMonitor 74 79 ) 80 + 81 + _ = powerAssertion // prevent unused warning, released in deinit 75 82 76 83 if !isTTY { 77 84 debugPrint("Backup complete: \(report.uploaded) uploaded, \(report.failed) failed (\(formatBytes(report.totalBytes)))") ··· 95 102 } 96 103 func manifestSaved(entriesCount: Int) { 97 104 debugPrint(" Manifest saved (\(entriesCount) entries)") 105 + } 106 + func backupPaused(reason: String) { 107 + debugPrint(" ⏸ Paused: \(reason)") 108 + } 109 + func backupResumed() { 110 + debugPrint(" ▶ Resumed") 98 111 } 99 112 func backupCompleted(uploaded: Int, failed: Int, totalBytes: Int) { 100 113 debugPrint("Done: \(uploaded) uploaded, \(failed) failed (\(formatBytes(totalBytes)))")
+40 -4
Sources/AtticCLI/TerminalRenderer.swift
··· 30 30 var uploadedPhotos: Int = 0 31 31 var uploadedVideos: Int = 0 32 32 var headerPrinted: Bool = false 33 + var isPaused: Bool = false 34 + var pauseReason: String = "" 35 + var pauseStarted: Date? 36 + var totalPauseDuration: TimeInterval = 0 33 37 } 34 38 35 39 // MARK: - BackupProgressDelegate ··· 76 80 // No visual update needed 77 81 } 78 82 83 + func backupPaused(reason: String) { 84 + lock.withLock { 85 + state.isPaused = true 86 + state.pauseReason = reason 87 + state.pauseStarted = Date() 88 + } 89 + render() 90 + } 91 + 92 + func backupResumed() { 93 + lock.withLock { 94 + state.isPaused = false 95 + if let pauseStart = state.pauseStarted { 96 + state.totalPauseDuration += Date().timeIntervalSince(pauseStart) 97 + } 98 + state.pauseStarted = nil 99 + state.pauseReason = "" 100 + } 101 + render() 102 + } 103 + 79 104 func backupCompleted(uploaded: Int, failed: Int, totalBytes: Int) { 80 105 lock.withLock { 81 106 state.uploaded = uploaded ··· 91 116 92 117 private func render() { 93 118 let (s, elapsed): (RenderState, TimeInterval) = lock.withLock { 94 - let e = startTime.map { Date().timeIntervalSince($0) } ?? 0 95 - return (state, e) 119 + let total = startTime.map { Date().timeIntervalSince($0) } ?? 0 120 + // Exclude pause time from elapsed for accurate speed calculation 121 + var currentPause: TimeInterval = 0 122 + if let pauseStart = state.pauseStarted { 123 + currentPause = Date().timeIntervalSince(pauseStart) 124 + } 125 + let active = total - state.totalPauseDuration - currentPause 126 + return (state, max(0, active)) 96 127 } 97 128 let speed = elapsed > 0 ? Double(s.totalBytes) / elapsed : 0 98 129 ··· 111 142 lines.append(" Progress [\(bar)] \(completed)/\(s.total)") 112 143 lines.append(" Photos \(s.uploadedPhotos) uploaded") 113 144 lines.append(" Videos \(s.uploadedVideos) uploaded") 114 - lines.append(" Speed \(formatBytes(Int(speed)))/s") 145 + lines.append(" Speed \(s.isPaused ? "—" : "\(formatBytes(Int(speed)))/s")") 115 146 lines.append(" Errors \(s.failed)") 116 147 lines.append("") 117 - lines.append(" Current \(s.currentFile)") 148 + if s.isPaused, let pauseStart = s.pauseStarted { 149 + let waitTime = Date().timeIntervalSince(pauseStart) 150 + lines.append(" Status \u{1b}[33m⏸ \(s.pauseReason) (\(formatDuration(waitTime)))\u{1b}[0m") 151 + } else { 152 + lines.append(" Current \(s.currentFile)") 153 + } 118 154 lines.append(" Elapsed \(formatDuration(elapsed))") 119 155 120 156 // Move cursor up to overwrite previous render (8 lines of content)
+160 -58
Sources/AtticCore/BackupPipeline.swift
··· 21 21 public var type: AssetKind? 22 22 public var dryRun: Bool 23 23 public var saveInterval: Int 24 + public var networkTimeout: Duration 24 25 25 26 public init( 26 27 batchSize: Int = 50, 27 28 limit: Int = 0, 28 29 type: AssetKind? = nil, 29 30 dryRun: Bool = false, 30 - saveInterval: Int = 50 31 + saveInterval: Int = 50, 32 + networkTimeout: Duration = .seconds(900) 31 33 ) { 32 34 self.batchSize = batchSize 33 35 self.limit = limit 34 36 self.type = type 35 37 self.dryRun = dryRun 36 38 self.saveInterval = saveInterval 39 + self.networkTimeout = networkTimeout 37 40 } 38 41 } 39 42 ··· 60 63 exporter: any ExportProviding, 61 64 s3: any S3Providing, 62 65 options: BackupOptions = BackupOptions(), 63 - progress: any BackupProgressDelegate = NullProgressDelegate() 66 + progress: any BackupProgressDelegate = NullProgressDelegate(), 67 + networkMonitor: (any NetworkMonitoring)? = nil 64 68 ) async throws -> BackupReport { 65 69 // Filter to pending assets, optionally by type 66 70 var pending = assets.filter { asset in ··· 147 151 manifest: &manifest, manifestStore: manifestStore, 148 152 report: &report, sinceLastSave: &sinceLastSave, 149 153 saveInterval: options.saveInterval, 150 - progress: progress 154 + progress: progress, 155 + networkMonitor: networkMonitor, 156 + networkTimeout: options.networkTimeout 151 157 ) 152 158 continue 153 159 } catch let error as ExportProviderError where error.isPermission { ··· 180 186 manifest: &manifest, manifestStore: manifestStore, 181 187 report: &report, sinceLastSave: &sinceLastSave, 182 188 saveInterval: options.saveInterval, 183 - progress: progress 189 + progress: progress, 190 + networkMonitor: networkMonitor, 191 + networkTimeout: options.networkTimeout 184 192 ) 185 193 } 186 194 ··· 195 203 manifest: &manifest, manifestStore: manifestStore, 196 204 report: &report, sinceLastSave: &sinceLastSave, 197 205 saveInterval: options.saveInterval, 198 - progress: progress 206 + progress: progress, 207 + networkMonitor: networkMonitor, 208 + networkTimeout: options.networkTimeout 199 209 ) 200 210 } catch { 201 211 let msg = String(describing: error) ··· 222 232 return report 223 233 } 224 234 235 + /// Check if an upload error looks like a transient network issue. 236 + /// 237 + /// Intentionally a superset of `RetryPolicy.isTransient` — includes 238 + /// "nsurlerrordomain" and "cfnetwork" so the pipeline's network-pause 239 + /// logic catches errors that `withRetry` deliberately does not retry 240 + /// (avoiding 7s of backoff before the network monitor can take over). 241 + private func isTransientUploadError(_ error: Error) -> Bool { 242 + let message = String(describing: error).lowercased() 243 + let patterns = [ 244 + "timeout", "timed out", "econnreset", "econnrefused", 245 + "epipe", "socket", "network", "fetch failed", 246 + "nsurlerrordomain", "cfnetwork", 247 + ] 248 + return patterns.contains { message.contains($0) } 249 + } 250 + 225 251 // MARK: - Upload helper 226 252 227 253 private func uploadExported( ··· 233 259 report: inout BackupReport, 234 260 sinceLastSave: inout Int, 235 261 saveInterval: Int, 236 - progress: any BackupProgressDelegate 262 + progress: any BackupProgressDelegate, 263 + networkMonitor: (any NetworkMonitoring)? = nil, 264 + networkTimeout: Duration = .seconds(900) 237 265 ) async throws { 238 266 // Record export errors 239 267 for err in batchResult.errors { ··· 260 288 ) 261 289 262 290 do { 263 - // Upload original via file URL (avoids loading into memory) 264 - let fileURL = URL(fileURLWithPath: exported.path) 265 - try await withRetry { 266 - try await s3.putObject( 267 - key: s3Key, 268 - fileURL: fileURL, 269 - contentType: contentTypeForExtension(ext) 270 - ) 271 - } 272 - 273 - // Build and upload metadata 274 - let isoNow = isoFormatter.string(from: Date()) 275 - let meta = buildMetadataJSON( 276 - asset: asset, 277 - s3Key: s3Key, 278 - checksum: "sha256:\(exported.sha256)", 279 - backedUpAt: isoNow 291 + try await uploadAssetToS3( 292 + exported: exported, asset: asset, 293 + s3Key: s3Key, ext: ext, s3: s3, 294 + manifest: &manifest, report: &report, 295 + sinceLastSave: &sinceLastSave, 296 + saveInterval: saveInterval, 297 + manifestStore: manifestStore, progress: progress 280 298 ) 281 - let metaData = try metadataEncoder.encode(meta) 282 - let metaKey = try S3Paths.metadataKey(uuid: asset.uuid) 283 - try await withRetry { 284 - try await s3.putObject( 285 - key: metaKey, 286 - body: metaData, 287 - contentType: "application/json" 288 - ) 289 - } 290 - 291 - // Update manifest 292 - manifest.markBackedUp( 293 - uuid: asset.uuid, 294 - s3Key: s3Key, 295 - checksum: "sha256:\(exported.sha256)", 296 - size: Int(exported.size) 297 - ) 298 - sinceLastSave += 1 299 - report.uploaded += 1 300 - report.totalBytes += Int(exported.size) 301 - 302 - let filename = asset.originalFilename ?? "unknown" 303 - progress.assetUploaded( 304 - uuid: asset.uuid, 305 - filename: filename, 306 - type: asset.kind, 307 - size: Int(exported.size) 308 - ) 309 - 310 - // Periodic manifest save (skip sortedKeys for speed) 311 - if sinceLastSave >= saveInterval { 312 - try await manifestStore.save(manifest) 313 - progress.manifestSaved(entriesCount: manifest.entries.count) 314 - sinceLastSave = 0 315 - } 316 299 } catch is CancellationError { 317 300 throw CancellationError() 318 301 } catch { 302 + // Check if this is a network issue we can wait out 303 + if let monitor = networkMonitor, isTransientUploadError(error) { 304 + let networkUp = await monitor.isNetworkAvailable 305 + if !networkUp { 306 + // Save manifest before waiting (preserve progress) 307 + if sinceLastSave > 0 { 308 + try? await manifestStore.save(manifest) 309 + progress.manifestSaved(entriesCount: manifest.entries.count) 310 + sinceLastSave = 0 311 + } 312 + 313 + progress.backupPaused(reason: "Waiting for network...") 314 + let recovered = try await monitor.waitForNetwork( 315 + timeout: networkTimeout 316 + ) 317 + progress.backupResumed() 318 + 319 + if recovered { 320 + // Retry the same asset after network recovery 321 + do { 322 + try await uploadAssetToS3( 323 + exported: exported, asset: asset, 324 + s3Key: s3Key, ext: ext, s3: s3, 325 + manifest: &manifest, report: &report, 326 + sinceLastSave: &sinceLastSave, 327 + saveInterval: saveInterval, 328 + manifestStore: manifestStore, progress: progress 329 + ) 330 + try? FileManager.default.removeItem(atPath: exported.path) 331 + continue 332 + } catch { 333 + // Retry after recovery also failed — fall through 334 + } 335 + } else { 336 + // Network timeout — save manifest and stop 337 + let timeoutMinutes = Int(networkTimeout.components.seconds) / 60 338 + report.appendError( 339 + uuid: exported.uuid, 340 + message: "Network unavailable for \(timeoutMinutes) minutes, backup paused" 341 + ) 342 + report.failed += 1 343 + if sinceLastSave > 0 { 344 + try? await manifestStore.save(manifest) 345 + sinceLastSave = 0 346 + } 347 + return 348 + } 349 + } 350 + } 351 + 319 352 let msg = String(describing: error) 320 353 let filename = asset.originalFilename ?? exported.uuid 321 354 progress.assetFailed(uuid: exported.uuid, filename: filename, message: msg) ··· 327 360 try? FileManager.default.removeItem(atPath: exported.path) 328 361 } 329 362 } 363 + 364 + /// Upload a single asset (original + metadata) to S3 and update the manifest. 365 + private func uploadAssetToS3( 366 + exported: ExportResult, 367 + asset: AssetInfo, 368 + s3Key: String, 369 + ext: String, 370 + s3: any S3Providing, 371 + manifest: inout Manifest, 372 + report: inout BackupReport, 373 + sinceLastSave: inout Int, 374 + saveInterval: Int, 375 + manifestStore: any ManifestStoring, 376 + progress: any BackupProgressDelegate 377 + ) async throws { 378 + // Upload original via file URL (avoids loading into memory) 379 + let fileURL = URL(fileURLWithPath: exported.path) 380 + try await withRetry { 381 + try await s3.putObject( 382 + key: s3Key, 383 + fileURL: fileURL, 384 + contentType: contentTypeForExtension(ext) 385 + ) 386 + } 387 + 388 + // Build and upload metadata 389 + let isoNow = isoFormatter.string(from: Date()) 390 + let meta = buildMetadataJSON( 391 + asset: asset, 392 + s3Key: s3Key, 393 + checksum: "sha256:\(exported.sha256)", 394 + backedUpAt: isoNow 395 + ) 396 + let metaData = try metadataEncoder.encode(meta) 397 + let metaKey = try S3Paths.metadataKey(uuid: asset.uuid) 398 + try await withRetry { 399 + try await s3.putObject( 400 + key: metaKey, 401 + body: metaData, 402 + contentType: "application/json" 403 + ) 404 + } 405 + 406 + // Update manifest 407 + manifest.markBackedUp( 408 + uuid: asset.uuid, 409 + s3Key: s3Key, 410 + checksum: "sha256:\(exported.sha256)", 411 + size: Int(exported.size) 412 + ) 413 + sinceLastSave += 1 414 + report.uploaded += 1 415 + report.totalBytes += Int(exported.size) 416 + 417 + let filename = asset.originalFilename ?? "unknown" 418 + progress.assetUploaded( 419 + uuid: asset.uuid, 420 + filename: filename, 421 + type: asset.kind, 422 + size: Int(exported.size) 423 + ) 424 + 425 + // Periodic manifest save 426 + if sinceLastSave >= saveInterval { 427 + try await manifestStore.save(manifest) 428 + progress.manifestSaved(entriesCount: manifest.entries.count) 429 + sinceLastSave = 0 430 + } 431 + }
+12
Sources/AtticCore/BackupProgress.swift
··· 22 22 23 23 /// Backup completed. 24 24 func backupCompleted(uploaded: Int, failed: Int, totalBytes: Int) 25 + 26 + /// Backup paused (e.g., network lost during upload). 27 + func backupPaused(reason: String) 28 + 29 + /// Backup resumed after a pause. 30 + func backupResumed() 31 + } 32 + 33 + /// Default no-op implementations for optional delegate methods. 34 + public extension BackupProgressDelegate { 35 + func backupPaused(reason: String) {} 36 + func backupResumed() {} 25 37 } 26 38 27 39 /// No-op delegate for quiet/test runs.
+42
Sources/AtticCore/MockNetworkMonitor.swift
··· 1 + import Foundation 2 + 3 + /// In-memory network monitor mock for tests. 4 + /// 5 + /// Controllable `isAvailable` property. `waitForNetwork` polls with a short 6 + /// interval and respects both timeout and cancellation. 7 + public actor MockNetworkMonitor: NetworkMonitoring { 8 + private var available: Bool 9 + 10 + public init(available: Bool = true) { 11 + self.available = available 12 + } 13 + 14 + public var isNetworkAvailable: Bool { 15 + available 16 + } 17 + 18 + /// Simulate network recovery. 19 + public func setAvailable() { 20 + available = true 21 + } 22 + 23 + /// Simulate network loss. 24 + public func setUnavailable() { 25 + available = false 26 + } 27 + 28 + public func waitForNetwork(timeout: Duration) async throws -> Bool { 29 + if available { return true } 30 + 31 + let deadline = ContinuousClock.now + timeout 32 + let pollInterval: Duration = .milliseconds(50) 33 + 34 + while ContinuousClock.now < deadline { 35 + try Task.checkCancellation() 36 + try await Task.sleep(for: pollInterval) 37 + if available { return true } 38 + } 39 + 40 + return false 41 + } 42 + }
+64
Sources/AtticCore/NWPathNetworkMonitor.swift
··· 1 + import Foundation 2 + import Network 3 + 4 + /// Real network monitor using `NWPathMonitor`. 5 + /// 6 + /// Detects network availability changes in real time. Used by the backup 7 + /// pipeline to pause uploads when the network drops and resume when it returns. 8 + public final class NWPathNetworkMonitor: NetworkMonitoring, @unchecked Sendable { 9 + private let monitor: NWPathMonitor 10 + private let queue = DispatchQueue(label: "attic.network-monitor") 11 + private let lock = NSLock() 12 + private var currentStatus: NWPath.Status = .satisfied 13 + 14 + /// Stabilization period before declaring network restored. 15 + /// Prevents thrashing on flicker (rapid drop/restore cycles). 16 + private let stabilizationDelay: Duration = .seconds(3) 17 + 18 + public init() { 19 + monitor = NWPathMonitor() 20 + monitor.pathUpdateHandler = { [weak self] path in 21 + guard let self else { return } 22 + self.lock.withLock { 23 + self.currentStatus = path.status 24 + } 25 + } 26 + monitor.start(queue: queue) 27 + } 28 + 29 + deinit { 30 + monitor.cancel() 31 + } 32 + 33 + public var isNetworkAvailable: Bool { 34 + lock.withLock { currentStatus == .satisfied } 35 + } 36 + 37 + public func waitForNetwork(timeout: Duration) async throws -> Bool { 38 + let deadline = ContinuousClock.now + timeout 39 + let pollInterval: Duration = .milliseconds(500) 40 + 41 + while ContinuousClock.now < deadline { 42 + try Task.checkCancellation() 43 + 44 + if isNetworkAvailable { 45 + // Wait for stabilization to avoid flicker, capped to remaining time 46 + let remaining = deadline - ContinuousClock.now 47 + let stabilize = min(stabilizationDelay, remaining) 48 + if stabilize > .zero { 49 + try await Task.sleep(for: stabilize) 50 + } 51 + try Task.checkCancellation() 52 + 53 + // Confirm network is still up after stabilization 54 + if isNetworkAvailable { 55 + return true 56 + } 57 + } 58 + 59 + try await Task.sleep(for: pollInterval) 60 + } 61 + 62 + return false 63 + } 64 + }
+21
Sources/AtticCore/NetworkMonitoring.swift
··· 1 + import Foundation 2 + 3 + /// Protocol for monitoring network availability. 4 + /// 5 + /// Used by the backup pipeline to detect network loss and wait for recovery 6 + /// (e.g., after sleep/wake). Implementations must be `Sendable` for use 7 + /// with Swift Concurrency. 8 + public protocol NetworkMonitoring: Sendable { 9 + /// Whether the network is currently available for uploads. 10 + var isNetworkAvailable: Bool { get async } 11 + 12 + /// Suspends until the network becomes available or the timeout expires. 13 + /// 14 + /// Includes a brief stabilization period (e.g., 3 seconds) before 15 + /// declaring network restored, to avoid thrashing on flicker. 16 + /// 17 + /// - Parameter timeout: Maximum time to wait for network recovery. 18 + /// - Returns: `true` if network recovered, `false` if timed out. 19 + /// - Throws: `CancellationError` if the task is cancelled during the wait. 20 + func waitForNetwork(timeout: Duration) async throws -> Bool 21 + }
+24
Sources/AtticCore/PowerAssertion.swift
··· 1 + import Foundation 2 + 3 + /// Prevents idle system sleep and App Nap during long-running operations. 4 + /// 5 + /// Uses `ProcessInfo.beginActivity()` with `.userInitiated` and 6 + /// `.idleSystemSleepDisabled` options. RAII-style: the assertion is 7 + /// automatically released when this object is deallocated. 8 + /// 9 + /// This prevents idle sleep (screen off timer, desktop inactivity) but 10 + /// cannot prevent user-initiated sleep (lid close, Apple menu > Sleep). 11 + public final class PowerAssertion: @unchecked Sendable { 12 + private let activity: NSObjectProtocol 13 + 14 + public init(reason: String) { 15 + activity = ProcessInfo.processInfo.beginActivity( 16 + options: [.userInitiated, .idleSystemSleepDisabled], 17 + reason: reason 18 + ) 19 + } 20 + 21 + deinit { 22 + ProcessInfo.processInfo.endActivity(activity) 23 + } 24 + }
+290
Tests/AtticCoreTests/NetworkPauseTests.swift
··· 1 + import Testing 2 + import Foundation 3 + import LadderKit 4 + @testable import AtticCore 5 + 6 + // MARK: - Test helpers 7 + 8 + /// S3 provider that fails with a network error after a configured number of 9 + /// successful uploads. Simulates network loss mid-backup. 10 + actor NetworkFailingS3Provider: S3Providing { 11 + private let inner = MockS3Provider() 12 + private var putCallCount = 0 13 + private let failAfterPuts: Int 14 + private var shouldFail = true 15 + 16 + init(failAfterPuts: Int = 0) { 17 + self.failAfterPuts = failAfterPuts 18 + } 19 + 20 + func stopFailing() { 21 + shouldFail = false 22 + } 23 + 24 + func putObject(key: String, body: Data, contentType: String?) async throws { 25 + putCallCount += 1 26 + if shouldFail && putCallCount > failAfterPuts { 27 + throw NetworkError.networkDown 28 + } 29 + try await inner.putObject(key: key, body: body, contentType: contentType) 30 + } 31 + 32 + func putObject(key: String, fileURL: URL, contentType: String?) async throws { 33 + putCallCount += 1 34 + if shouldFail && putCallCount > failAfterPuts { 35 + throw NetworkError.networkDown 36 + } 37 + try await inner.putObject(key: key, fileURL: fileURL, contentType: contentType) 38 + } 39 + 40 + func getObject(key: String) async throws -> Data { 41 + try await inner.getObject(key: key) 42 + } 43 + 44 + func headObject(key: String) async throws -> S3ObjectMeta? { 45 + try await inner.headObject(key: key) 46 + } 47 + 48 + func listObjects(prefix: String) async throws -> [S3ListObject] { 49 + try await inner.listObjects(prefix: prefix) 50 + } 51 + } 52 + 53 + enum NetworkError: Error, CustomStringConvertible { 54 + case networkDown 55 + 56 + var description: String { 57 + // Use "nsurlerrordomain" — recognized by isTransientUploadError in 58 + // BackupPipeline but NOT by withRetry's isTransient patterns, so 59 + // withRetry throws immediately without sleeping through retries. 60 + "NSURLErrorDomain Code=-1009" 61 + } 62 + } 63 + 64 + /// Network monitor that always reports unavailable and always times out. 65 + /// Used for testing the timeout path without any polling or actor overhead. 66 + struct AlwaysUnavailableNetworkMonitor: NetworkMonitoring { 67 + var isNetworkAvailable: Bool { false } 68 + 69 + func waitForNetwork(timeout: Duration) async throws -> Bool { 70 + try Task.checkCancellation() 71 + try await Task.sleep(for: timeout) 72 + return false 73 + } 74 + } 75 + 76 + /// Progress delegate that records pause/resume events for assertions. 77 + final class RecordingProgressDelegate: BackupProgressDelegate, @unchecked Sendable { 78 + private let lock = NSLock() 79 + private var _events: [String] = [] 80 + 81 + var events: [String] { 82 + lock.withLock { _events } 83 + } 84 + 85 + private func record(_ event: String) { 86 + lock.withLock { _events.append(event) } 87 + } 88 + 89 + func backupStarted(pending: Int, photos: Int, videos: Int) { 90 + record("started(\(pending))") 91 + } 92 + func batchStarted(batchNumber: Int, totalBatches: Int, assetCount: Int) { 93 + record("batch(\(batchNumber))") 94 + } 95 + func assetUploaded(uuid: String, filename: String, type: AssetKind, size: Int) { 96 + record("uploaded(\(uuid))") 97 + } 98 + func assetFailed(uuid: String, filename: String, message: String) { 99 + record("failed(\(uuid))") 100 + } 101 + func manifestSaved(entriesCount: Int) { 102 + record("manifestSaved(\(entriesCount))") 103 + } 104 + func backupCompleted(uploaded: Int, failed: Int, totalBytes: Int) { 105 + record("completed(\(uploaded),\(failed))") 106 + } 107 + func backupPaused(reason: String) { 108 + record("paused") 109 + } 110 + func backupResumed() { 111 + record("resumed") 112 + } 113 + } 114 + 115 + // MARK: - Tests 116 + 117 + @Suite("NetworkPause") 118 + struct NetworkPauseTests { 119 + @Test func backupCompletesNormallyWithoutNetworkMonitor() async throws { 120 + let assets = [makeTestAsset(uuid: "uuid-1")] 121 + let exporter = MockExportProvider(assets: [ 122 + "uuid-1": ("IMG_0001.HEIC", Data("photo1".utf8)), 123 + ]) 124 + let (s3, manifestStore) = try await createTestContext() 125 + var manifest = try await manifestStore.load() 126 + 127 + let report = try await runBackup( 128 + assets: assets, 129 + manifest: &manifest, 130 + manifestStore: manifestStore, 131 + exporter: exporter, 132 + s3: s3, 133 + options: BackupOptions(batchSize: 10) 134 + ) 135 + 136 + #expect(report.uploaded == 1) 137 + #expect(report.failed == 0) 138 + } 139 + 140 + @Test func backupCompletesNormallyWithAvailableNetwork() async throws { 141 + let assets = [makeTestAsset(uuid: "uuid-1")] 142 + let exporter = MockExportProvider(assets: [ 143 + "uuid-1": ("IMG_0001.HEIC", Data("photo1".utf8)), 144 + ]) 145 + let (s3, manifestStore) = try await createTestContext() 146 + var manifest = try await manifestStore.load() 147 + let monitor = MockNetworkMonitor(available: true) 148 + 149 + let report = try await runBackup( 150 + assets: assets, 151 + manifest: &manifest, 152 + manifestStore: manifestStore, 153 + exporter: exporter, 154 + s3: s3, 155 + options: BackupOptions(batchSize: 10), 156 + networkMonitor: monitor 157 + ) 158 + 159 + #expect(report.uploaded == 1) 160 + #expect(report.failed == 0) 161 + } 162 + 163 + @Test func pausesAndResumesWhenNetworkDropsAndRecovers() async throws { 164 + let assets = [makeTestAsset(uuid: "uuid-1")] 165 + let exporter = MockExportProvider(assets: [ 166 + "uuid-1": ("IMG_0001.HEIC", Data("photo1".utf8)), 167 + ]) 168 + 169 + // S3 provider that fails on first put (simulating network loss) 170 + let s3 = NetworkFailingS3Provider(failAfterPuts: 0) 171 + let manifestStore = S3ManifestStore(s3: s3) 172 + var manifest = try await manifestStore.load() 173 + 174 + let monitor = MockNetworkMonitor(available: false) 175 + let progress = RecordingProgressDelegate() 176 + 177 + // Simulate network recovery after a short delay 178 + Task { 179 + try await Task.sleep(for: .milliseconds(100)) 180 + await s3.stopFailing() 181 + await monitor.setAvailable() 182 + } 183 + 184 + let report = try await runBackup( 185 + assets: assets, 186 + manifest: &manifest, 187 + manifestStore: manifestStore, 188 + exporter: exporter, 189 + s3: s3, 190 + options: BackupOptions(batchSize: 10), 191 + progress: progress, 192 + networkMonitor: monitor 193 + ) 194 + 195 + #expect(report.uploaded == 1) 196 + #expect(report.failed == 0) 197 + #expect(progress.events.contains("paused")) 198 + #expect(progress.events.contains("resumed")) 199 + } 200 + 201 + @Test(.timeLimit(.minutes(1))) 202 + func networkTimeoutExitsCleanly() async throws { 203 + // Verify that AlwaysUnavailableNetworkMonitor times out correctly 204 + let monitor = AlwaysUnavailableNetworkMonitor() 205 + let result = try await monitor.waitForNetwork(timeout: .milliseconds(100)) 206 + #expect(!result) 207 + 208 + // Test the full pipeline with network failure + timeout 209 + let assets = [makeTestAsset(uuid: "uuid-1")] 210 + let exporter = MockExportProvider(assets: [ 211 + "uuid-1": ("IMG_0001.HEIC", Data("photo1".utf8)), 212 + ]) 213 + // Use separate S3 instances: one for manifest (works), one for uploads (fails) 214 + let goodS3 = MockS3Provider() 215 + let manifestStore = S3ManifestStore(s3: goodS3) 216 + var manifest = try await manifestStore.load() 217 + 218 + let failingS3 = NetworkFailingS3Provider(failAfterPuts: 0) 219 + let progress = RecordingProgressDelegate() 220 + 221 + let report = try await runBackup( 222 + assets: assets, 223 + manifest: &manifest, 224 + manifestStore: manifestStore, 225 + exporter: exporter, 226 + s3: failingS3, 227 + options: BackupOptions(batchSize: 10, networkTimeout: .milliseconds(100)), 228 + progress: progress, 229 + networkMonitor: monitor 230 + ) 231 + 232 + #expect(progress.events.contains("paused")) 233 + #expect(progress.events.contains("resumed")) 234 + #expect(report.failed >= 1) 235 + } 236 + 237 + @Test func cancellationDuringNetworkWaitExitsCleanly() async throws { 238 + let assets = [makeTestAsset(uuid: "uuid-1")] 239 + let exporter = MockExportProvider(assets: [ 240 + "uuid-1": ("IMG_0001.HEIC", Data("photo1".utf8)), 241 + ]) 242 + 243 + let s3 = NetworkFailingS3Provider(failAfterPuts: 0) 244 + let manifestStore = S3ManifestStore(s3: s3) 245 + var manifest = try await manifestStore.load() 246 + 247 + let monitor = AlwaysUnavailableNetworkMonitor() 248 + 249 + let task = Task { 250 + try await runBackup( 251 + assets: assets, 252 + manifest: &manifest, 253 + manifestStore: manifestStore, 254 + exporter: exporter, 255 + s3: s3, 256 + options: BackupOptions(batchSize: 10, networkTimeout: .seconds(30)), 257 + networkMonitor: monitor 258 + ) 259 + } 260 + 261 + // Cancel after a brief delay 262 + try await Task.sleep(for: .milliseconds(200)) 263 + task.cancel() 264 + 265 + // Should throw CancellationError 266 + do { 267 + _ = try await task.value 268 + Issue.record("Expected CancellationError") 269 + } catch is CancellationError { 270 + // Expected 271 + } catch { 272 + Issue.record("Expected CancellationError, got \(error)") 273 + } 274 + } 275 + 276 + @Test func mockNetworkMonitorBasicBehavior() async throws { 277 + let monitor = MockNetworkMonitor(available: true) 278 + let available = await monitor.isNetworkAvailable 279 + #expect(available) 280 + 281 + await monitor.setUnavailable() 282 + let unavailable = await monitor.isNetworkAvailable 283 + #expect(!unavailable) 284 + 285 + // waitForNetwork returns immediately when available 286 + await monitor.setAvailable() 287 + let recovered = try await monitor.waitForNetwork(timeout: .seconds(1)) 288 + #expect(recovered) 289 + } 290 + }