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.

Add sleep/wake network resilience to backup pipeline

Auto-pause uploads when network drops (NWPathMonitor), auto-resume when
it returns, and exit cleanly after 15-minute timeout with manifest saved.
Prevent idle sleep during backup via ProcessInfo.beginActivity. Terminal
shows pause status with elapsed wait time, excludes pause from speed.

+660 -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)
+154 -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 + private func isTransientUploadError(_ error: Error) -> Bool { 237 + let message = String(describing: error).lowercased() 238 + let patterns = [ 239 + "timeout", "timed out", "econnreset", "econnrefused", 240 + "epipe", "socket", "network", "fetch failed", 241 + "nsurlerrordomain", "cfnetwork", 242 + ] 243 + return patterns.contains { message.contains($0) } 244 + } 245 + 225 246 // MARK: - Upload helper 226 247 227 248 private func uploadExported( ··· 233 254 report: inout BackupReport, 234 255 sinceLastSave: inout Int, 235 256 saveInterval: Int, 236 - progress: any BackupProgressDelegate 257 + progress: any BackupProgressDelegate, 258 + networkMonitor: (any NetworkMonitoring)? = nil, 259 + networkTimeout: Duration = .seconds(900) 237 260 ) async throws { 238 261 // Record export errors 239 262 for err in batchResult.errors { ··· 260 283 ) 261 284 262 285 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 280 - ) 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) 286 + try await uploadAssetToS3( 287 + exported: exported, asset: asset, 288 + s3Key: s3Key, ext: ext, s3: s3, 289 + manifest: &manifest, report: &report, 290 + sinceLastSave: &sinceLastSave, 291 + saveInterval: saveInterval, 292 + manifestStore: manifestStore, progress: progress 297 293 ) 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 294 } catch is CancellationError { 317 295 throw CancellationError() 318 296 } catch { 297 + // Check if this is a network issue we can wait out 298 + if let monitor = networkMonitor, isTransientUploadError(error) { 299 + let networkUp = await monitor.isNetworkAvailable 300 + if !networkUp { 301 + // Save manifest before waiting (preserve progress) 302 + if sinceLastSave > 0 { 303 + try? await manifestStore.save(manifest) 304 + progress.manifestSaved(entriesCount: manifest.entries.count) 305 + sinceLastSave = 0 306 + } 307 + 308 + progress.backupPaused(reason: "Waiting for network...") 309 + let recovered = try await monitor.waitForNetwork( 310 + timeout: networkTimeout 311 + ) 312 + progress.backupResumed() 313 + 314 + if recovered { 315 + // Retry the same asset after network recovery 316 + do { 317 + try await uploadAssetToS3( 318 + exported: exported, asset: asset, 319 + s3Key: s3Key, ext: ext, s3: s3, 320 + manifest: &manifest, report: &report, 321 + sinceLastSave: &sinceLastSave, 322 + saveInterval: saveInterval, 323 + manifestStore: manifestStore, progress: progress 324 + ) 325 + try? FileManager.default.removeItem(atPath: exported.path) 326 + continue 327 + } catch { 328 + // Retry after recovery also failed — fall through 329 + } 330 + } else { 331 + // Network timeout — save manifest and stop 332 + report.appendError( 333 + uuid: exported.uuid, 334 + message: "Network unavailable for 15 minutes, backup paused" 335 + ) 336 + report.failed += 1 337 + if sinceLastSave > 0 { 338 + try? await manifestStore.save(manifest) 339 + sinceLastSave = 0 340 + } 341 + return 342 + } 343 + } 344 + } 345 + 319 346 let msg = String(describing: error) 320 347 let filename = asset.originalFilename ?? exported.uuid 321 348 progress.assetFailed(uuid: exported.uuid, filename: filename, message: msg) ··· 327 354 try? FileManager.default.removeItem(atPath: exported.path) 328 355 } 329 356 } 357 + 358 + /// Upload a single asset (original + metadata) to S3 and update the manifest. 359 + private func uploadAssetToS3( 360 + exported: ExportResult, 361 + asset: AssetInfo, 362 + s3Key: String, 363 + ext: String, 364 + s3: any S3Providing, 365 + manifest: inout Manifest, 366 + report: inout BackupReport, 367 + sinceLastSave: inout Int, 368 + saveInterval: Int, 369 + manifestStore: any ManifestStoring, 370 + progress: any BackupProgressDelegate 371 + ) async throws { 372 + // Upload original via file URL (avoids loading into memory) 373 + let fileURL = URL(fileURLWithPath: exported.path) 374 + try await withRetry { 375 + try await s3.putObject( 376 + key: s3Key, 377 + fileURL: fileURL, 378 + contentType: contentTypeForExtension(ext) 379 + ) 380 + } 381 + 382 + // Build and upload metadata 383 + let isoNow = isoFormatter.string(from: Date()) 384 + let meta = buildMetadataJSON( 385 + asset: asset, 386 + s3Key: s3Key, 387 + checksum: "sha256:\(exported.sha256)", 388 + backedUpAt: isoNow 389 + ) 390 + let metaData = try metadataEncoder.encode(meta) 391 + let metaKey = try S3Paths.metadataKey(uuid: asset.uuid) 392 + try await withRetry { 393 + try await s3.putObject( 394 + key: metaKey, 395 + body: metaData, 396 + contentType: "application/json" 397 + ) 398 + } 399 + 400 + // Update manifest 401 + manifest.markBackedUp( 402 + uuid: asset.uuid, 403 + s3Key: s3Key, 404 + checksum: "sha256:\(exported.sha256)", 405 + size: Int(exported.size) 406 + ) 407 + sinceLastSave += 1 408 + report.uploaded += 1 409 + report.totalBytes += Int(exported.size) 410 + 411 + let filename = asset.originalFilename ?? "unknown" 412 + progress.assetUploaded( 413 + uuid: asset.uuid, 414 + filename: filename, 415 + type: asset.kind, 416 + size: Int(exported.size) 417 + ) 418 + 419 + // Periodic manifest save 420 + if sinceLastSave >= saveInterval { 421 + try await manifestStore.save(manifest) 422 + progress.manifestSaved(entriesCount: manifest.entries.count) 423 + sinceLastSave = 0 424 + } 425 + }
+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 + }
+59
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 + self?.lock.withLock { 22 + self?.currentStatus = path.status 23 + } 24 + } 25 + monitor.start(queue: queue) 26 + } 27 + 28 + deinit { 29 + monitor.cancel() 30 + } 31 + 32 + public var isNetworkAvailable: Bool { 33 + lock.withLock { currentStatus == .satisfied } 34 + } 35 + 36 + public func waitForNetwork(timeout: Duration) async throws -> Bool { 37 + let deadline = ContinuousClock.now + timeout 38 + let pollInterval: Duration = .milliseconds(500) 39 + 40 + while ContinuousClock.now < deadline { 41 + try Task.checkCancellation() 42 + 43 + if isNetworkAvailable { 44 + // Wait for stabilization to avoid flicker 45 + try await Task.sleep(for: stabilizationDelay) 46 + try Task.checkCancellation() 47 + 48 + // Confirm network is still up after stabilization 49 + if isNetworkAvailable { 50 + return true 51 + } 52 + } 53 + 54 + try await Task.sleep(for: pollInterval) 55 + } 56 + 57 + return false 58 + } 59 + }
+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 + }
+28
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 + public func end() { 22 + ProcessInfo.processInfo.endActivity(activity) 23 + } 24 + 25 + deinit { 26 + end() 27 + } 28 + }
+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 + }