RadrootsAppleBackgroundTransferCoordinator.swift (17463B)
1 import Foundation 2 3 actor RadrootsTransferCoordinator { 4 private let sessionIdentifier: String 5 private let store: any RadrootsBackgroundTransferStore 6 private let fileResolver: any RadrootsBackgroundTransferFileResolver 7 private let now: @Sendable () -> Date 8 private let fileManager: FileManager 9 private var completionHandlers: [@Sendable () -> Void] 10 private var unclaimedFinishedEventCount: Int 11 private var pendingReceiptCount = 0 12 private var deferredFinishedEvents = 0 13 14 var hasPendingReceipts: Bool { 15 pendingReceiptCount > 0 16 } 17 18 init( 19 sessionIdentifier: String, store: any RadrootsBackgroundTransferStore, 20 fileResolver: any RadrootsBackgroundTransferFileResolver, 21 now: @escaping @Sendable () -> Date = Date.init, fileManager: FileManager = .default 22 ) { 23 self.sessionIdentifier = sessionIdentifier 24 self.store = store 25 self.fileResolver = fileResolver 26 self.now = now 27 self.fileManager = fileManager 28 completionHandlers = [] 29 unclaimedFinishedEventCount = 0 30 } 31 32 func updateProgress( 33 identifier: RadrootsBackgroundTransferIdentifier, bytesTransferred: Int64, 34 totalBytesExpected: Int64?, executionID: UUID? = nil 35 ) async { 36 guard let existing = try? await snapshot(for: identifier), existing.executionID == executionID, 37 existing.state == .running || existing.state == .queued 38 else { return } 39 guard 40 let progress = Self.progress( 41 bytesTransferred: bytesTransferred, totalBytesExpected: totalBytesExpected, 42 fallback: existing.progress 43 ) 44 else { return } 45 _ = try? await store.compareExchangeSnapshot(expected: existing, desired: 46 try RadrootsBackgroundTransferSnapshot( 47 request: existing.request, state: .running, progress: progress, 48 failure: existing.failure, 49 response: existing.response, possibleRemoteOrphan: existing.possibleRemoteOrphan, 50 updatedAt: now(), executionID: existing.executionID, uploadLease: existing.uploadLease 51 )) 52 } 53 54 func complete( 55 identifier: RadrootsBackgroundTransferIdentifier, completion: RadrootsTransferCompletion, 56 executionID: UUID? = nil 57 ) async { 58 pendingReceiptCount += 1 59 defer { receiptFinished() } 60 guard let existing = await recoverableSnapshot(for: identifier), existing.executionID == executionID, 61 [.queued, .running, .interrupted].contains(existing.state) 62 else { return } 63 if let failure = Self.completionFailure(request: existing.request, completion: completion) { 64 Self.removeStagedDownload(completion.stagedDownloadResult, fileManager: fileManager) 65 await fail(existing: existing, code: failure, possibleRemoteOrphan: existing.request.isUpload) 66 return 67 } 68 let response: RadrootsBackgroundTransferResponse 69 do { 70 response = try Self.validatedResponse(for: existing.request, httpResult: completion.httpResult) 71 } catch { 72 Self.removeStagedDownload(completion.stagedDownloadResult, fileManager: fileManager) 73 await fail(existing: existing, 74 code: (error as? RadrootsTransferResponseError)?.failure ?? .responseInvalid, 75 possibleRemoteOrphan: existing.request.isUpload) 76 return 77 } 78 switch existing.request.operation { 79 case let .download(destination): 80 await completeDownload( 81 existing: existing, 82 destination: destination, 83 response: response, 84 completion: completion 85 ) 86 case .upload: 87 await completeUpload(existing: existing, response: response, bytesTransferred: completion.bytesTransferred, 88 totalBytesExpected: completion.totalBytesExpected) 89 } 90 } 91 92 private static func completionFailure( 93 request: RadrootsBackgroundTransferRequest, completion: RadrootsTransferCompletion 94 ) -> RadrootsBackgroundTransferFailure? { 95 if completion.httpResult.destinationMismatch { 96 return .responseInvalid 97 } 98 if let failure = completion.httpResult.headerFailure { 99 return failure 100 } 101 if let encoding = completion.httpResult.contentEncoding, encoding != "identity" { 102 return .responseContentEncoding 103 } 104 if UInt64(max(completion.bytesTransferred, 0)) > request.maximumTransferBytes 105 || completion.totalBytesExpected.map({ UInt64(max($0, 0)) > request.maximumTransferBytes }) == true { 106 return .transferTooLarge 107 } 108 if completion.httpResult.bodyExceeded { 109 return .responseTooLarge 110 } 111 if completion.httpResult.mediaTypeWasMalformed { 112 return .responseMediaType 113 } 114 if completion.platformError != nil { 115 return .platformFailure 116 } 117 guard let status = completion.httpResult.statusCode else { return .responseMissing } 118 return (200 ... 299).contains(status) ? nil : .httpStatus 119 } 120 121 func releaseUploadLease(executionID: UUID?) { 122 guard let executionID else { return } 123 // Only the native terminal callback calls this. Cancellation intent alone 124 // cannot release bytes still owned by URLSession. Failed cleanup remains 125 // an owned orphan for later reconciliation. 126 try? fileResolver.releaseUploadLease(executionID: executionID) 127 } 128 129 func handleBackgroundEvents(identifier: String, completionHandler: @escaping @Sendable () -> Void) { 130 guard identifier == sessionIdentifier else { 131 completionHandler() 132 return 133 } 134 if unclaimedFinishedEventCount > 0 { 135 unclaimedFinishedEventCount -= 1 136 completionHandler() 137 return 138 } 139 completionHandlers.append(completionHandler) 140 } 141 142 func finishBackgroundEvents(identifier: String?) { 143 guard identifier == nil || identifier == sessionIdentifier else { return } 144 guard pendingReceiptCount == 0 else { 145 deferredFinishedEvents = min(deferredFinishedEvents + 1, 8) 146 return 147 } 148 guard !completionHandlers.isEmpty else { 149 unclaimedFinishedEventCount = min(unclaimedFinishedEventCount + 1, 8) 150 return 151 } 152 let handlers = completionHandlers 153 completionHandlers.removeAll() 154 for handler in handlers { 155 handler() 156 } 157 } 158 } 159 160 extension RadrootsTransferCoordinator { 161 private func completeUpload( 162 existing: RadrootsBackgroundTransferSnapshot, response: RadrootsBackgroundTransferResponse, 163 bytesTransferred: Int64, 164 totalBytesExpected: Int64? 165 ) async { 166 let progress = 167 Self.progress( 168 bytesTransferred: bytesTransferred, totalBytesExpected: totalBytesExpected, 169 fallback: existing.progress 170 ) 171 ?? existing.progress 172 do { 173 let desired = try RadrootsBackgroundTransferSnapshot( 174 request: existing.request, state: .awaitingVerification, progress: progress, 175 response: response, updatedAt: now(), executionID: existing.executionID, 176 uploadLease: existing.uploadLease 177 ) 178 await persistTerminal(desired) 179 } catch { 180 await fail(existing: existing, code: .responseInvalid, possibleRemoteOrphan: true) 181 } 182 } 183 184 private func completeDownload( 185 existing: RadrootsBackgroundTransferSnapshot, destination: RadrootsBackgroundTransferLocalFile, 186 response: RadrootsBackgroundTransferResponse, completion: RadrootsTransferCompletion 187 ) async { 188 let stagedDownloadResult = completion.stagedDownloadResult 189 let mediaType = completion.httpResult.mediaType 190 let bytesTransferred = completion.bytesTransferred 191 let totalBytesExpected = completion.totalBytesExpected 192 guard case let .file(stagedFileURL) = stagedDownloadResult else { 193 await fail(existing: existing, code: .downloadStagingFailure) 194 return 195 } 196 do { 197 guard case let .file(destinationReference) = destination else { 198 throw RadrootsBackgroundTransferError.invalidRequest 199 } 200 let destinationURL = try fileResolver.resolve(destination) 201 let fileSize = try Self.fileSize(at: stagedFileURL, fileManager: fileManager) 202 guard fileSize > 0, UInt64(fileSize) <= existing.request.maximumTransferBytes else { 203 throw RadrootsBackgroundTransferError.transferFailure 204 } 205 let downloadedArtifact = try RadrootsBackgroundDownloadedArtifact( 206 file: destinationReference, 207 sha256: RadrootsAppleFileDigest.sha256(at: stagedFileURL), 208 byteSize: UInt64(fileSize), 209 mediaType: mediaType 210 ) 211 try installDownload(stagedFileURL, at: destinationURL) 212 let progress = 213 Self.progress( 214 bytesTransferred: max(bytesTransferred, fileSize), totalBytesExpected: totalBytesExpected, 215 fallback: existing.progress 216 ) 217 ?? existing.progress 218 let desired = try RadrootsBackgroundTransferSnapshot( 219 request: existing.request, state: .awaitingVerification, progress: progress, 220 response: response, 221 downloadedArtifact: downloadedArtifact, updatedAt: now(), executionID: existing.executionID, 222 uploadLease: existing.uploadLease 223 ) 224 await persistTerminal(desired) 225 } catch { 226 Self.removeStagedDownload(.file(stagedFileURL), fileManager: fileManager) 227 await fail(existing: existing, code: .destinationFailure) 228 } 229 } 230 231 private func installDownload(_ source: URL, at destination: URL) throws { 232 try fileManager.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true) 233 try Self.moveReplacingItem(from: source, to: destination, fileManager: fileManager) 234 #if os(iOS) 235 try fileManager.setAttributes( 236 [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], 237 ofItemAtPath: destination.path 238 ) 239 #endif 240 try RadrootsAtomicFile.synchronizeExisting(at: destination) 241 } 242 243 private func fail( 244 existing: RadrootsBackgroundTransferSnapshot, 245 code: RadrootsBackgroundTransferFailure, 246 possibleRemoteOrphan: Bool = false 247 ) async { 248 while true { 249 let observed = now() 250 let timestamp = observed.timeIntervalSinceReferenceDate.isFinite ? observed : existing.updatedAt 251 let desired = try? RadrootsBackgroundTransferSnapshot( 252 request: existing.request, state: .failed, progress: existing.progress, failure: code, 253 possibleRemoteOrphan: possibleRemoteOrphan, updatedAt: timestamp, executionID: existing.executionID, 254 uploadLease: existing.uploadLease 255 ) 256 if let desired { 257 await persistTerminal(desired); return 258 } 259 await Self.persistenceRetryDelay() 260 } 261 } 262 263 private func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws 264 -> RadrootsBackgroundTransferSnapshot? { 265 try await store.loadSnapshots().first { $0.identifier == identifier } 266 } 267 268 private func recoverableSnapshot(for identifier: RadrootsBackgroundTransferIdentifier) async 269 -> RadrootsBackgroundTransferSnapshot? { 270 while true { 271 do { 272 return try await snapshot(for: identifier) 273 } catch { 274 await Self.persistenceRetryDelay() 275 } 276 } 277 } 278 279 /// Progress and reconciliation may replace a snapshot during an await. Retry 280 /// against the current exact value without changing the receipt's attempt. 281 /// Store failure retains the receipt and blocks finished-event acknowledgement. 282 private func persistTerminal(_ desired: RadrootsBackgroundTransferSnapshot) async { 283 while true { 284 do { 285 guard let current = try await snapshot(for: desired.identifier), 286 current.executionID == desired.executionID, current.request == desired.request, 287 [.queued, .running, .interrupted].contains(current.state) 288 else { return } 289 if try await store.compareExchangeSnapshot(expected: current, desired: desired) { 290 return 291 } 292 } catch { /* Retain receipt ownership until storage becomes available. */ } 293 await Self.persistenceRetryDelay() 294 } 295 } 296 297 private static func persistenceRetryDelay() async { 298 // The OS acknowledgement barrier must survive caller cancellation. A 299 // separately owned delay avoids a cancelled task spinning on sleep. 300 await Task.detached { try? await Task.sleep(for: .milliseconds(100)) }.value 301 } 302 303 private func receiptFinished() { 304 pendingReceiptCount -= 1 305 guard pendingReceiptCount == 0, deferredFinishedEvents > 0 else { return } 306 let events = deferredFinishedEvents 307 deferredFinishedEvents = 0 308 for _ in 0 ..< events { 309 finishBackgroundEvents(identifier: sessionIdentifier) 310 } 311 } 312 313 private static func progress( 314 bytesTransferred: Int64, totalBytesExpected: Int64?, 315 fallback: RadrootsBackgroundTransferProgress 316 ) 317 -> RadrootsBackgroundTransferProgress? { 318 let safeBytesTransferred = max(bytesTransferred, fallback.bytesTransferred) 319 let safeTotalBytesExpected = 320 totalBytesExpected.flatMap { value -> Int64? in value >= safeBytesTransferred ? value : nil } 321 ?? fallback.totalBytesExpected.flatMap { value -> Int64? in 322 value >= safeBytesTransferred ? value : nil 323 } 324 return try? RadrootsBackgroundTransferProgress( 325 bytesTransferred: safeBytesTransferred, totalBytesExpected: safeTotalBytesExpected 326 ) 327 } 328 329 private static func fileSize(at url: URL, fileManager _: FileManager) throws -> Int64 { 330 let values = try url.resourceValues(forKeys: [.fileSizeKey]) 331 return Int64(values.fileSize ?? 0) 332 } 333 334 private static func moveReplacingItem( 335 from source: URL, to destination: URL, fileManager: FileManager 336 ) throws { 337 guard fileManager.fileExists(atPath: destination.path) else { 338 try fileManager.moveItem(at: source, to: destination) 339 return 340 } 341 let backup = destination.deletingLastPathComponent().appendingPathComponent( 342 ".radroots-transfer-backup-\(UUID().uuidString.lowercased())" 343 ) 344 try fileManager.moveItem(at: destination, to: backup) 345 do { 346 try fileManager.moveItem(at: source, to: destination) 347 try fileManager.removeItem(at: backup) 348 } catch { 349 if fileManager.fileExists(atPath: destination.path) { 350 try? fileManager.removeItem(at: destination) 351 } 352 if fileManager.fileExists(atPath: backup.path) { 353 try? fileManager.moveItem(at: backup, to: destination) 354 } 355 throw error 356 } 357 } 358 359 private static func validatedResponse( 360 for request: RadrootsBackgroundTransferRequest, httpResult: RadrootsBackgroundHTTPResult 361 ) throws 362 -> RadrootsBackgroundTransferResponse { 363 guard let statusCode = httpResult.statusCode else { 364 throw RadrootsTransferResponseError(failure: .responseMissing) 365 } 366 if request.responsePolicy == .discard { 367 return try RadrootsBackgroundTransferResponse( 368 statusCode: statusCode, mediaType: nil, contentEncoding: nil, body: nil 369 ) 370 } 371 guard let body = httpResult.body else { 372 throw RadrootsTransferResponseError(failure: .responseMissing) 373 } 374 guard let mediaType = httpResult.mediaType, 375 request.responsePolicy.acceptedMediaTypes.contains(mediaType) 376 else { 377 throw RadrootsTransferResponseError(failure: .responseMediaType) 378 } 379 guard body.count <= request.responsePolicy.maximumBodyBytes else { 380 throw RadrootsTransferResponseError(failure: .responseTooLarge) 381 } 382 guard httpResult.contentEncoding == nil || httpResult.contentEncoding == "identity" else { 383 throw RadrootsTransferResponseError(failure: .responseContentEncoding) 384 } 385 return try RadrootsBackgroundTransferResponse( 386 statusCode: statusCode, mediaType: mediaType, 387 contentEncoding: httpResult.contentEncoding, body: body 388 ) 389 } 390 391 private static func removeStagedDownload( 392 _ result: RadrootsStagedBackgroundDownloadResult?, fileManager: FileManager 393 ) { 394 guard case let .file(url) = result, fileManager.fileExists(atPath: url.path) else { return } 395 try? fileManager.removeItem(at: url) 396 } 397 }