···11+import Foundation
22+33+/// In-memory network monitor mock for tests.
44+///
55+/// Controllable `isAvailable` property. `waitForNetwork` polls with a short
66+/// interval and respects both timeout and cancellation.
77+public actor MockNetworkMonitor: NetworkMonitoring {
88+ private var available: Bool
99+1010+ public init(available: Bool = true) {
1111+ self.available = available
1212+ }
1313+1414+ public var isNetworkAvailable: Bool {
1515+ available
1616+ }
1717+1818+ /// Simulate network recovery.
1919+ public func setAvailable() {
2020+ available = true
2121+ }
2222+2323+ /// Simulate network loss.
2424+ public func setUnavailable() {
2525+ available = false
2626+ }
2727+2828+ public func waitForNetwork(timeout: Duration) async throws -> Bool {
2929+ if available { return true }
3030+3131+ let deadline = ContinuousClock.now + timeout
3232+ let pollInterval: Duration = .milliseconds(50)
3333+3434+ while ContinuousClock.now < deadline {
3535+ try Task.checkCancellation()
3636+ try await Task.sleep(for: pollInterval)
3737+ if available { return true }
3838+ }
3939+4040+ return false
4141+ }
4242+}
+64
Sources/AtticCore/NWPathNetworkMonitor.swift
···11+import Foundation
22+import Network
33+44+/// Real network monitor using `NWPathMonitor`.
55+///
66+/// Detects network availability changes in real time. Used by the backup
77+/// pipeline to pause uploads when the network drops and resume when it returns.
88+public final class NWPathNetworkMonitor: NetworkMonitoring, @unchecked Sendable {
99+ private let monitor: NWPathMonitor
1010+ private let queue = DispatchQueue(label: "attic.network-monitor")
1111+ private let lock = NSLock()
1212+ private var currentStatus: NWPath.Status = .satisfied
1313+1414+ /// Stabilization period before declaring network restored.
1515+ /// Prevents thrashing on flicker (rapid drop/restore cycles).
1616+ private let stabilizationDelay: Duration = .seconds(3)
1717+1818+ public init() {
1919+ monitor = NWPathMonitor()
2020+ monitor.pathUpdateHandler = { [weak self] path in
2121+ guard let self else { return }
2222+ self.lock.withLock {
2323+ self.currentStatus = path.status
2424+ }
2525+ }
2626+ monitor.start(queue: queue)
2727+ }
2828+2929+ deinit {
3030+ monitor.cancel()
3131+ }
3232+3333+ public var isNetworkAvailable: Bool {
3434+ lock.withLock { currentStatus == .satisfied }
3535+ }
3636+3737+ public func waitForNetwork(timeout: Duration) async throws -> Bool {
3838+ let deadline = ContinuousClock.now + timeout
3939+ let pollInterval: Duration = .milliseconds(500)
4040+4141+ while ContinuousClock.now < deadline {
4242+ try Task.checkCancellation()
4343+4444+ if isNetworkAvailable {
4545+ // Wait for stabilization to avoid flicker, capped to remaining time
4646+ let remaining = deadline - ContinuousClock.now
4747+ let stabilize = min(stabilizationDelay, remaining)
4848+ if stabilize > .zero {
4949+ try await Task.sleep(for: stabilize)
5050+ }
5151+ try Task.checkCancellation()
5252+5353+ // Confirm network is still up after stabilization
5454+ if isNetworkAvailable {
5555+ return true
5656+ }
5757+ }
5858+5959+ try await Task.sleep(for: pollInterval)
6060+ }
6161+6262+ return false
6363+ }
6464+}
+21
Sources/AtticCore/NetworkMonitoring.swift
···11+import Foundation
22+33+/// Protocol for monitoring network availability.
44+///
55+/// Used by the backup pipeline to detect network loss and wait for recovery
66+/// (e.g., after sleep/wake). Implementations must be `Sendable` for use
77+/// with Swift Concurrency.
88+public protocol NetworkMonitoring: Sendable {
99+ /// Whether the network is currently available for uploads.
1010+ var isNetworkAvailable: Bool { get async }
1111+1212+ /// Suspends until the network becomes available or the timeout expires.
1313+ ///
1414+ /// Includes a brief stabilization period (e.g., 3 seconds) before
1515+ /// declaring network restored, to avoid thrashing on flicker.
1616+ ///
1717+ /// - Parameter timeout: Maximum time to wait for network recovery.
1818+ /// - Returns: `true` if network recovered, `false` if timed out.
1919+ /// - Throws: `CancellationError` if the task is cancelled during the wait.
2020+ func waitForNetwork(timeout: Duration) async throws -> Bool
2121+}
+24
Sources/AtticCore/PowerAssertion.swift
···11+import Foundation
22+33+/// Prevents idle system sleep and App Nap during long-running operations.
44+///
55+/// Uses `ProcessInfo.beginActivity()` with `.userInitiated` and
66+/// `.idleSystemSleepDisabled` options. RAII-style: the assertion is
77+/// automatically released when this object is deallocated.
88+///
99+/// This prevents idle sleep (screen off timer, desktop inactivity) but
1010+/// cannot prevent user-initiated sleep (lid close, Apple menu > Sleep).
1111+public final class PowerAssertion: @unchecked Sendable {
1212+ private let activity: NSObjectProtocol
1313+1414+ public init(reason: String) {
1515+ activity = ProcessInfo.processInfo.beginActivity(
1616+ options: [.userInitiated, .idleSystemSleepDisabled],
1717+ reason: reason
1818+ )
1919+ }
2020+2121+ deinit {
2222+ ProcessInfo.processInfo.endActivity(activity)
2323+ }
2424+}
+290
Tests/AtticCoreTests/NetworkPauseTests.swift
···11+import Testing
22+import Foundation
33+import LadderKit
44+@testable import AtticCore
55+66+// MARK: - Test helpers
77+88+/// S3 provider that fails with a network error after a configured number of
99+/// successful uploads. Simulates network loss mid-backup.
1010+actor NetworkFailingS3Provider: S3Providing {
1111+ private let inner = MockS3Provider()
1212+ private var putCallCount = 0
1313+ private let failAfterPuts: Int
1414+ private var shouldFail = true
1515+1616+ init(failAfterPuts: Int = 0) {
1717+ self.failAfterPuts = failAfterPuts
1818+ }
1919+2020+ func stopFailing() {
2121+ shouldFail = false
2222+ }
2323+2424+ func putObject(key: String, body: Data, contentType: String?) async throws {
2525+ putCallCount += 1
2626+ if shouldFail && putCallCount > failAfterPuts {
2727+ throw NetworkError.networkDown
2828+ }
2929+ try await inner.putObject(key: key, body: body, contentType: contentType)
3030+ }
3131+3232+ func putObject(key: String, fileURL: URL, contentType: String?) async throws {
3333+ putCallCount += 1
3434+ if shouldFail && putCallCount > failAfterPuts {
3535+ throw NetworkError.networkDown
3636+ }
3737+ try await inner.putObject(key: key, fileURL: fileURL, contentType: contentType)
3838+ }
3939+4040+ func getObject(key: String) async throws -> Data {
4141+ try await inner.getObject(key: key)
4242+ }
4343+4444+ func headObject(key: String) async throws -> S3ObjectMeta? {
4545+ try await inner.headObject(key: key)
4646+ }
4747+4848+ func listObjects(prefix: String) async throws -> [S3ListObject] {
4949+ try await inner.listObjects(prefix: prefix)
5050+ }
5151+}
5252+5353+enum NetworkError: Error, CustomStringConvertible {
5454+ case networkDown
5555+5656+ var description: String {
5757+ // Use "nsurlerrordomain" — recognized by isTransientUploadError in
5858+ // BackupPipeline but NOT by withRetry's isTransient patterns, so
5959+ // withRetry throws immediately without sleeping through retries.
6060+ "NSURLErrorDomain Code=-1009"
6161+ }
6262+}
6363+6464+/// Network monitor that always reports unavailable and always times out.
6565+/// Used for testing the timeout path without any polling or actor overhead.
6666+struct AlwaysUnavailableNetworkMonitor: NetworkMonitoring {
6767+ var isNetworkAvailable: Bool { false }
6868+6969+ func waitForNetwork(timeout: Duration) async throws -> Bool {
7070+ try Task.checkCancellation()
7171+ try await Task.sleep(for: timeout)
7272+ return false
7373+ }
7474+}
7575+7676+/// Progress delegate that records pause/resume events for assertions.
7777+final class RecordingProgressDelegate: BackupProgressDelegate, @unchecked Sendable {
7878+ private let lock = NSLock()
7979+ private var _events: [String] = []
8080+8181+ var events: [String] {
8282+ lock.withLock { _events }
8383+ }
8484+8585+ private func record(_ event: String) {
8686+ lock.withLock { _events.append(event) }
8787+ }
8888+8989+ func backupStarted(pending: Int, photos: Int, videos: Int) {
9090+ record("started(\(pending))")
9191+ }
9292+ func batchStarted(batchNumber: Int, totalBatches: Int, assetCount: Int) {
9393+ record("batch(\(batchNumber))")
9494+ }
9595+ func assetUploaded(uuid: String, filename: String, type: AssetKind, size: Int) {
9696+ record("uploaded(\(uuid))")
9797+ }
9898+ func assetFailed(uuid: String, filename: String, message: String) {
9999+ record("failed(\(uuid))")
100100+ }
101101+ func manifestSaved(entriesCount: Int) {
102102+ record("manifestSaved(\(entriesCount))")
103103+ }
104104+ func backupCompleted(uploaded: Int, failed: Int, totalBytes: Int) {
105105+ record("completed(\(uploaded),\(failed))")
106106+ }
107107+ func backupPaused(reason: String) {
108108+ record("paused")
109109+ }
110110+ func backupResumed() {
111111+ record("resumed")
112112+ }
113113+}
114114+115115+// MARK: - Tests
116116+117117+@Suite("NetworkPause")
118118+struct NetworkPauseTests {
119119+ @Test func backupCompletesNormallyWithoutNetworkMonitor() async throws {
120120+ let assets = [makeTestAsset(uuid: "uuid-1")]
121121+ let exporter = MockExportProvider(assets: [
122122+ "uuid-1": ("IMG_0001.HEIC", Data("photo1".utf8)),
123123+ ])
124124+ let (s3, manifestStore) = try await createTestContext()
125125+ var manifest = try await manifestStore.load()
126126+127127+ let report = try await runBackup(
128128+ assets: assets,
129129+ manifest: &manifest,
130130+ manifestStore: manifestStore,
131131+ exporter: exporter,
132132+ s3: s3,
133133+ options: BackupOptions(batchSize: 10)
134134+ )
135135+136136+ #expect(report.uploaded == 1)
137137+ #expect(report.failed == 0)
138138+ }
139139+140140+ @Test func backupCompletesNormallyWithAvailableNetwork() async throws {
141141+ let assets = [makeTestAsset(uuid: "uuid-1")]
142142+ let exporter = MockExportProvider(assets: [
143143+ "uuid-1": ("IMG_0001.HEIC", Data("photo1".utf8)),
144144+ ])
145145+ let (s3, manifestStore) = try await createTestContext()
146146+ var manifest = try await manifestStore.load()
147147+ let monitor = MockNetworkMonitor(available: true)
148148+149149+ let report = try await runBackup(
150150+ assets: assets,
151151+ manifest: &manifest,
152152+ manifestStore: manifestStore,
153153+ exporter: exporter,
154154+ s3: s3,
155155+ options: BackupOptions(batchSize: 10),
156156+ networkMonitor: monitor
157157+ )
158158+159159+ #expect(report.uploaded == 1)
160160+ #expect(report.failed == 0)
161161+ }
162162+163163+ @Test func pausesAndResumesWhenNetworkDropsAndRecovers() async throws {
164164+ let assets = [makeTestAsset(uuid: "uuid-1")]
165165+ let exporter = MockExportProvider(assets: [
166166+ "uuid-1": ("IMG_0001.HEIC", Data("photo1".utf8)),
167167+ ])
168168+169169+ // S3 provider that fails on first put (simulating network loss)
170170+ let s3 = NetworkFailingS3Provider(failAfterPuts: 0)
171171+ let manifestStore = S3ManifestStore(s3: s3)
172172+ var manifest = try await manifestStore.load()
173173+174174+ let monitor = MockNetworkMonitor(available: false)
175175+ let progress = RecordingProgressDelegate()
176176+177177+ // Simulate network recovery after a short delay
178178+ Task {
179179+ try await Task.sleep(for: .milliseconds(100))
180180+ await s3.stopFailing()
181181+ await monitor.setAvailable()
182182+ }
183183+184184+ let report = try await runBackup(
185185+ assets: assets,
186186+ manifest: &manifest,
187187+ manifestStore: manifestStore,
188188+ exporter: exporter,
189189+ s3: s3,
190190+ options: BackupOptions(batchSize: 10),
191191+ progress: progress,
192192+ networkMonitor: monitor
193193+ )
194194+195195+ #expect(report.uploaded == 1)
196196+ #expect(report.failed == 0)
197197+ #expect(progress.events.contains("paused"))
198198+ #expect(progress.events.contains("resumed"))
199199+ }
200200+201201+ @Test(.timeLimit(.minutes(1)))
202202+ func networkTimeoutExitsCleanly() async throws {
203203+ // Verify that AlwaysUnavailableNetworkMonitor times out correctly
204204+ let monitor = AlwaysUnavailableNetworkMonitor()
205205+ let result = try await monitor.waitForNetwork(timeout: .milliseconds(100))
206206+ #expect(!result)
207207+208208+ // Test the full pipeline with network failure + timeout
209209+ let assets = [makeTestAsset(uuid: "uuid-1")]
210210+ let exporter = MockExportProvider(assets: [
211211+ "uuid-1": ("IMG_0001.HEIC", Data("photo1".utf8)),
212212+ ])
213213+ // Use separate S3 instances: one for manifest (works), one for uploads (fails)
214214+ let goodS3 = MockS3Provider()
215215+ let manifestStore = S3ManifestStore(s3: goodS3)
216216+ var manifest = try await manifestStore.load()
217217+218218+ let failingS3 = NetworkFailingS3Provider(failAfterPuts: 0)
219219+ let progress = RecordingProgressDelegate()
220220+221221+ let report = try await runBackup(
222222+ assets: assets,
223223+ manifest: &manifest,
224224+ manifestStore: manifestStore,
225225+ exporter: exporter,
226226+ s3: failingS3,
227227+ options: BackupOptions(batchSize: 10, networkTimeout: .milliseconds(100)),
228228+ progress: progress,
229229+ networkMonitor: monitor
230230+ )
231231+232232+ #expect(progress.events.contains("paused"))
233233+ #expect(progress.events.contains("resumed"))
234234+ #expect(report.failed >= 1)
235235+ }
236236+237237+ @Test func cancellationDuringNetworkWaitExitsCleanly() async throws {
238238+ let assets = [makeTestAsset(uuid: "uuid-1")]
239239+ let exporter = MockExportProvider(assets: [
240240+ "uuid-1": ("IMG_0001.HEIC", Data("photo1".utf8)),
241241+ ])
242242+243243+ let s3 = NetworkFailingS3Provider(failAfterPuts: 0)
244244+ let manifestStore = S3ManifestStore(s3: s3)
245245+ var manifest = try await manifestStore.load()
246246+247247+ let monitor = AlwaysUnavailableNetworkMonitor()
248248+249249+ let task = Task {
250250+ try await runBackup(
251251+ assets: assets,
252252+ manifest: &manifest,
253253+ manifestStore: manifestStore,
254254+ exporter: exporter,
255255+ s3: s3,
256256+ options: BackupOptions(batchSize: 10, networkTimeout: .seconds(30)),
257257+ networkMonitor: monitor
258258+ )
259259+ }
260260+261261+ // Cancel after a brief delay
262262+ try await Task.sleep(for: .milliseconds(200))
263263+ task.cancel()
264264+265265+ // Should throw CancellationError
266266+ do {
267267+ _ = try await task.value
268268+ Issue.record("Expected CancellationError")
269269+ } catch is CancellationError {
270270+ // Expected
271271+ } catch {
272272+ Issue.record("Expected CancellationError, got \(error)")
273273+ }
274274+ }
275275+276276+ @Test func mockNetworkMonitorBasicBehavior() async throws {
277277+ let monitor = MockNetworkMonitor(available: true)
278278+ let available = await monitor.isNetworkAvailable
279279+ #expect(available)
280280+281281+ await monitor.setUnavailable()
282282+ let unavailable = await monitor.isNetworkAvailable
283283+ #expect(!unavailable)
284284+285285+ // waitForNetwork returns immediately when available
286286+ await monitor.setAvailable()
287287+ let recovered = try await monitor.waitForNetwork(timeout: .seconds(1))
288288+ #expect(recovered)
289289+ }
290290+}