FieldBackgroundExecution.swift (17002B)
1 import Foundation 2 import RadrootsKit 3 4 struct FieldBackgroundTaskIdentifiers: Equatable, Sendable { 5 let refresh: RadrootsBackgroundTaskIdentifier 6 let processing: RadrootsBackgroundTaskIdentifier 7 let transferSessionIdentifier: String 8 9 init(bundleIdentifier: String) throws { 10 let normalized = try FieldBackgroundTaskIdentifiers.normalizedBundleIdentifier(bundleIdentifier) 11 self.refresh = try RadrootsBackgroundTaskIdentifier("\(normalized).background.refresh") 12 self.processing = try RadrootsBackgroundTaskIdentifier("\(normalized).background.processing") 13 self.transferSessionIdentifier = try RadrootsBackgroundTransferValidation.normalizedIdentifier( 14 "\(normalized).background.transfer" 15 ) 16 } 17 18 private static func normalizedBundleIdentifier(_ value: String) throws -> String { 19 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() 20 guard !trimmed.isEmpty else { 21 throw FieldLocalStateError.missingBundleIdentifier 22 } 23 return trimmed 24 } 25 } 26 27 struct FieldBackgroundExecutionHandlers: Sendable { 28 let refresh: @Sendable () async -> Bool 29 let processing: @Sendable () async -> Bool 30 } 31 32 actor FieldBackgroundExecution { 33 private static let stagedBlobRetention: TimeInterval = 24 * 60 * 60 34 35 private let identifiers: FieldBackgroundTaskIdentifiers 36 private let scheduler: any RadrootsBackgroundTaskScheduler 37 private let transfer: any RadrootsBackgroundTransfer 38 private let roots: RadrootsAppleFileRoots 39 private let telemetry: FieldTelemetry 40 private let now: @Sendable () -> Date 41 private let registerHandlers: @Sendable (FieldBackgroundExecutionHandlers) async throws -> Void 42 private var runtimeService: FieldRuntimeService? 43 private var identityUnlocked = false 44 private var hasRegisteredHandlers = false 45 46 init( 47 identifiers: FieldBackgroundTaskIdentifiers, 48 scheduler: any RadrootsBackgroundTaskScheduler, 49 transfer: any RadrootsBackgroundTransfer, 50 roots: RadrootsAppleFileRoots, 51 telemetry: FieldTelemetry, 52 now: @escaping @Sendable () -> Date = Date.init, 53 registerHandlers: @escaping @Sendable (FieldBackgroundExecutionHandlers) async throws -> Void 54 ) { 55 self.identifiers = identifiers 56 self.scheduler = scheduler 57 self.transfer = transfer 58 self.roots = roots 59 self.telemetry = telemetry 60 self.now = now 61 self.registerHandlers = registerHandlers 62 } 63 64 static func configured( 65 bundleIdentifier: String, 66 telemetry: FieldTelemetry 67 ) throws -> FieldBackgroundExecution { 68 let identifiers = try FieldBackgroundTaskIdentifiers(bundleIdentifier: bundleIdentifier) 69 let roots = try FieldLocalState.roots(bundleIdentifier: bundleIdentifier) 70 #if DEBUG 71 if FieldUITestHarness.isRequested { 72 let scheduler = FieldUITestBackgroundTaskScheduler() 73 let transfer = FieldUITestBackgroundTransfer() 74 let now: @Sendable () -> Date 75 if FieldBackgroundExecutionUITestProbe.isRequested { 76 now = { Date.distantFuture } 77 } else { 78 now = Date.init 79 } 80 return FieldBackgroundExecution( 81 identifiers: identifiers, 82 scheduler: scheduler, 83 transfer: transfer, 84 roots: roots, 85 telemetry: telemetry, 86 now: now, 87 registerHandlers: { _ in } 88 ) 89 } 90 #endif 91 let scheduler = RadrootsAppleBackgroundTaskScheduler() 92 let transfer = try RadrootsAppleBackgroundTransfer( 93 roots: roots, 94 sessionIdentifier: identifiers.transferSessionIdentifier 95 ) 96 return FieldBackgroundExecution( 97 identifiers: identifiers, 98 scheduler: scheduler, 99 transfer: transfer, 100 roots: roots, 101 telemetry: telemetry, 102 registerHandlers: { handlers in 103 let refreshRegistered = try await scheduler.register( 104 RadrootsAppleBackgroundTaskRegistration( 105 identifier: identifiers.refresh, 106 kind: .appRefresh, 107 handler: handlers.refresh 108 ) 109 ) 110 let processingRegistered = try await scheduler.register( 111 RadrootsAppleBackgroundTaskRegistration( 112 identifier: identifiers.processing, 113 kind: .processing, 114 handler: handlers.processing 115 ) 116 ) 117 guard refreshRegistered && processingRegistered else { 118 throw RadrootsBackgroundTaskError.schedulerFailure( 119 "background task handler registration was rejected" 120 ) 121 } 122 } 123 ) 124 } 125 126 func start() async throws { 127 if !hasRegisteredHandlers { 128 do { 129 try await registerHandlers( 130 FieldBackgroundExecutionHandlers( 131 refresh: { [weak self] in 132 await self?.performMaintenance(reason: "refresh_task") ?? false 133 }, 134 processing: { [weak self] in 135 await self?.performMaintenance(reason: "processing_task") ?? false 136 } 137 ) 138 ) 139 hasRegisteredHandlers = true 140 telemetry.backgroundExecution(operation: "handler_registration", outcome: "success", taskCount: 2) 141 } catch { 142 hasRegisteredHandlers = false 143 telemetry.backgroundExecution( 144 operation: "handler_registration", 145 outcome: FieldTelemetry.backgroundExecutionOutcome(for: error) 146 ) 147 throw error 148 } 149 } 150 _ = try await schedulePermittedTasks(reason: "startup") 151 } 152 153 func updateRuntimeState(service: FieldRuntimeService?, identityUnlocked: Bool) { 154 self.runtimeService = service 155 self.identityUnlocked = identityUnlocked 156 } 157 158 @discardableResult 159 func schedulePermittedTasks(reason: String) async throws -> [RadrootsBackgroundTaskSnapshot] { 160 do { 161 guard hasRegisteredHandlers else { 162 throw RadrootsBackgroundTaskError.schedulerFailure( 163 "background task handlers are not registered" 164 ) 165 } 166 let refresh = try RadrootsBackgroundTaskRequest( 167 identifier: identifiers.refresh, 168 kind: .appRefresh, 169 earliestBeginDate: now().addingTimeInterval(15 * 60) 170 ) 171 let processing = try RadrootsBackgroundTaskRequest( 172 identifier: identifiers.processing, 173 kind: .processing, 174 earliestBeginDate: now().addingTimeInterval(60 * 60) 175 ) 176 let snapshots = [ 177 try await scheduler.submit(refresh), 178 try await scheduler.submit(processing) 179 ] 180 telemetry.backgroundExecution(operation: "schedule", outcome: "success", taskCount: snapshots.count, reason: reason) 181 return snapshots 182 } catch { 183 telemetry.backgroundExecution( 184 operation: "schedule", 185 outcome: FieldTelemetry.backgroundExecutionOutcome(for: error), 186 reason: reason 187 ) 188 throw error 189 } 190 } 191 192 func cancelAll() async { 193 do { 194 try await scheduler.cancelAll() 195 telemetry.backgroundExecution(operation: "cancel_all", outcome: "success") 196 } catch { 197 telemetry.backgroundExecution(operation: "cancel_all", outcome: FieldTelemetry.backgroundExecutionOutcome(for: error)) 198 } 199 } 200 201 func pendingTaskSnapshots() async -> [RadrootsBackgroundTaskSnapshot] { 202 do { 203 return try await scheduler.pendingTasks() 204 } catch { 205 telemetry.backgroundExecution(operation: "pending_tasks", outcome: FieldTelemetry.backgroundExecutionOutcome(for: error)) 206 return [] 207 } 208 } 209 210 func transferSnapshots() async -> [RadrootsBackgroundTransferSnapshot] { 211 do { 212 return try await transfer.snapshots() 213 } catch { 214 telemetry.backgroundExecution(operation: "transfer_snapshots", outcome: FieldTelemetry.backgroundExecutionOutcome(for: error)) 215 return [] 216 } 217 } 218 219 func handleEventsForBackgroundURLSession( 220 identifier: String, 221 completionHandler: @escaping @Sendable () -> Void 222 ) async { 223 await transfer.handleEventsForBackgroundURLSession( 224 identifier: identifier, 225 completionHandler: completionHandler 226 ) 227 telemetry.backgroundExecution(operation: "background_url_session_events", outcome: "success") 228 } 229 230 func uiTestProbeValue() async -> String? { 231 guard FieldBackgroundExecutionUITestProbe.isRequested else { 232 return nil 233 } 234 do { 235 return try await buildUITestProbeValue() 236 } catch { 237 return FieldBackgroundExecutionUITestProbe.failureValue( 238 outcome: FieldTelemetry.backgroundExecutionOutcome(for: error) 239 ) 240 } 241 } 242 243 @discardableResult 244 func performMaintenance(reason: String) async -> Bool { 245 let transferCount = await inspectTransferSnapshots(reason: reason) 246 let sweptCount = sweepExpiredStagedBlobs(reason: reason) 247 let relaySucceeded = await refreshRelaysIfAllowed(reason: reason) 248 let succeeded = transferCount != nil && sweptCount != nil && relaySucceeded 249 telemetry.backgroundExecution( 250 operation: "maintenance", 251 outcome: succeeded ? "success" : "partial_failure", 252 stagedBlobCount: sweptCount, 253 transferCount: transferCount, 254 identityUnlocked: identityUnlocked, 255 reason: reason 256 ) 257 return succeeded 258 } 259 260 private func buildUITestProbeValue() async throws -> String { 261 let registered = hasRegisteredHandlers 262 let scheduledTaskCount = await fakeSubmittedRequestCount() 263 let pendingBeforeMaintenance = try await scheduler.pendingTasks().count 264 let transferSnapshotCount = try await seedUITestTransferSnapshot() 265 let stagedBlobRemoved = try await seedUITestStagedBlobAndRunMaintenance() 266 let pendingBeforeCancel = try await scheduler.pendingTasks().count 267 await cancelAll() 268 let pendingAfterCancel = try await scheduler.pendingTasks().count 269 let cancellationObserved = await fakeCancelAllCount() > 0 270 try? await Task.sleep(nanoseconds: 100_000_000) 271 let events = await telemetry.recordedEventsForUITest() 272 return FieldBackgroundExecutionUITestProbe.value( 273 registered: registered, 274 scheduledTaskCount: scheduledTaskCount, 275 pendingBeforeMaintenance: pendingBeforeMaintenance, 276 pendingBeforeCancel: pendingBeforeCancel, 277 pendingAfterCancel: pendingAfterCancel, 278 cancellationObserved: cancellationObserved, 279 stagedBlobRemoved: stagedBlobRemoved, 280 transferSnapshotCount: transferSnapshotCount, 281 events: events 282 ) 283 } 284 285 private func seedUITestTransferSnapshot() async throws -> Int { 286 #if DEBUG 287 guard let fakeTransfer = transfer as? FieldUITestBackgroundTransfer else { 288 return try await transfer.snapshots().count 289 } 290 let request = try RadrootsBackgroundTransferRequest( 291 remoteURL: URL(string: "https://radroots.org/field-ios-background-probe")!, 292 method: .get, 293 operation: .download( 294 destination: .file( 295 RadrootsFileReference( 296 scope: .cache, 297 relativePath: "ui_tests/background_execution/probe-download.bin" 298 ) 299 ) 300 ), 301 metadata: ["purpose": "background_execution_probe"] 302 ) 303 _ = try await fakeTransfer.enqueue(request) 304 return try await fakeTransfer.snapshots().count 305 #else 306 return try await transfer.snapshots().count 307 #endif 308 } 309 310 private func seedUITestStagedBlobAndRunMaintenance() async throws -> Bool { 311 let fileAccess = RadrootsAppleFileAccess(roots: roots) 312 let blob = try fileAccess.stageBlob( 313 Data("field-ios-background-probe".utf8), 314 mediaType: "text/plain", 315 filenameHint: "background-probe.txt" 316 ) 317 _ = await performMaintenance(reason: "ui_test_probe") 318 do { 319 _ = try fileAccess.readStagedBlob(blob) 320 return false 321 } catch RadrootsAppleFileError.notFound { 322 return true 323 } catch { 324 throw error 325 } 326 } 327 328 private func fakeSubmittedRequestCount() async -> Int { 329 #if DEBUG 330 guard let fakeScheduler = scheduler as? FieldUITestBackgroundTaskScheduler else { 331 return (try? await scheduler.pendingTasks().count) ?? 0 332 } 333 return await fakeScheduler.submittedRequestCount 334 #else 335 return (try? await scheduler.pendingTasks().count) ?? 0 336 #endif 337 } 338 339 private func fakeCancelAllCount() async -> Int { 340 #if DEBUG 341 guard let fakeScheduler = scheduler as? FieldUITestBackgroundTaskScheduler else { 342 return 0 343 } 344 return await fakeScheduler.cancelAllCount 345 #else 346 return 0 347 #endif 348 } 349 350 private func inspectTransferSnapshots(reason: String) async -> Int? { 351 do { 352 let snapshots = try await transfer.snapshots() 353 telemetry.backgroundExecution( 354 operation: "transfer_inspect", 355 outcome: "success", 356 transferCount: snapshots.count, 357 reason: reason 358 ) 359 return snapshots.count 360 } catch { 361 telemetry.backgroundExecution( 362 operation: "transfer_inspect", 363 outcome: FieldTelemetry.backgroundExecutionOutcome(for: error), 364 reason: reason 365 ) 366 return nil 367 } 368 } 369 370 private func sweepExpiredStagedBlobs(reason: String) -> Int? { 371 do { 372 let fileAccess = RadrootsAppleFileAccess(roots: roots) 373 let swept = try fileAccess.sweepStagedBlobs( 374 olderThan: now().addingTimeInterval(-Self.stagedBlobRetention) 375 ) 376 telemetry.backgroundExecution( 377 operation: "staged_blob_sweep", 378 outcome: "success", 379 stagedBlobCount: swept.count, 380 reason: reason 381 ) 382 return swept.count 383 } catch { 384 telemetry.backgroundExecution( 385 operation: "staged_blob_sweep", 386 outcome: FieldTelemetry.backgroundExecutionOutcome(for: error), 387 reason: reason 388 ) 389 return nil 390 } 391 } 392 393 private func refreshRelaysIfAllowed(reason: String) async -> Bool { 394 guard identityUnlocked else { 395 telemetry.backgroundExecution( 396 operation: "relay_refresh", 397 outcome: "skipped_locked", 398 identityUnlocked: false, 399 reason: reason 400 ) 401 return true 402 } 403 guard let runtimeService else { 404 telemetry.backgroundExecution( 405 operation: "relay_refresh", 406 outcome: "skipped_runtime_unavailable", 407 identityUnlocked: true, 408 reason: reason 409 ) 410 return true 411 } 412 do { 413 let relaySettings = try RelaySettings.effectiveSnapshot(bundleIdentifier: roots.appIdentifier) 414 try await runtimeService.nostrSetDefaultRelays(relaySettings.relays) 415 try await runtimeService.nostrConnectIfKeyPresent() 416 let status = await runtimeService.nostrConnectionStatus() 417 telemetry.backgroundExecution( 418 operation: "relay_refresh", 419 outcome: "success", 420 relayConnectedCount: status.connected, 421 relayConnectingCount: status.connecting, 422 identityUnlocked: true, 423 reason: reason 424 ) 425 return true 426 } catch { 427 telemetry.backgroundExecution( 428 operation: "relay_refresh", 429 outcome: FieldTelemetry.backgroundExecutionOutcome(for: error), 430 identityUnlocked: true, 431 reason: reason 432 ) 433 return false 434 } 435 } 436 437 }