TeraLifecycleCoordinator.swift (11393B)
1 import Combine 2 import Foundation 3 import RadrootsKit 4 import UIKit 5 6 actor TeraBackgroundEventRouter { 7 typealias Handler = 8 @Sendable ( 9 _ identifier: String, 10 _ completion: @escaping @Sendable () -> Void 11 ) async -> Void 12 13 static let shared = TeraBackgroundEventRouter() 14 private static let maximumPendingEvents = 8 15 private static let pendingTimeoutNanoseconds: UInt64 = 15_000_000_000 16 17 private struct PendingEvent: Sendable { 18 let token: UUID 19 let identifier: String 20 let completion: TeraCompletionOnce 21 let timeout: Task<Void, Never> 22 } 23 24 private var expectedIdentifier: String? 25 private var handler: Handler? 26 private var pending: [PendingEvent] = [] 27 28 func attach(identifier: String, handler: @escaping Handler) async { 29 expectedIdentifier = identifier 30 self.handler = handler 31 let events = pending 32 pending.removeAll(keepingCapacity: true) 33 for event in events { 34 event.timeout.cancel() 35 guard event.identifier == identifier else { 36 event.completion.complete() 37 continue 38 } 39 await handler(event.identifier) { 40 event.completion.complete() 41 } 42 } 43 } 44 45 func handle(identifier: String, completion: TeraCompletionOnce) async { 46 if let expectedIdentifier, identifier != expectedIdentifier { 47 completion.complete() 48 return 49 } 50 if let handler { 51 await handler(identifier) { 52 completion.complete() 53 } 54 return 55 } 56 57 while pending.count >= Self.maximumPendingEvents { 58 let oldest = pending.removeFirst() 59 oldest.timeout.cancel() 60 oldest.completion.complete() 61 } 62 let token = UUID() 63 let timeout = Task { [weak self] in 64 do { 65 try await Task.sleep(nanoseconds: Self.pendingTimeoutNanoseconds) 66 } catch { 67 return 68 } 69 await self?.expire(token: token) 70 } 71 pending.append( 72 PendingEvent( 73 token: token, 74 identifier: identifier, 75 completion: completion, 76 timeout: timeout 77 ) 78 ) 79 } 80 81 func detachAndCompletePending() { 82 handler = nil 83 expectedIdentifier = nil 84 let events = pending 85 pending.removeAll() 86 for event in events { 87 event.timeout.cancel() 88 event.completion.complete() 89 } 90 } 91 92 private func expire(token: UUID) { 93 guard let index = pending.firstIndex(where: { $0.token == token }) else { return } 94 let event = pending.remove(at: index) 95 event.completion.complete() 96 } 97 } 98 99 struct TeraDiagnosticRecord: Codable, Sendable, Equatable { 100 let name: String 101 let category: String 102 let level: String 103 let fields: [String: String] 104 let occurredAtUnixMilliseconds: Int64 105 } 106 107 actor TeraDiagnosticsBuffer: RadrootsTelemetry { 108 private struct Entry: Sendable { 109 let event: RadrootsTelemetryEvent 110 let occurredAtUnixMilliseconds: Int64 111 } 112 113 private let capacity: Int 114 private var events: [Entry] = [] 115 116 init(capacity: Int = 128) { 117 self.capacity = min(max(capacity, 16), 256) 118 } 119 120 func record(_ event: RadrootsTelemetryEvent) { 121 guard let event = TeraDiagnosticPolicy.sanitized(event) else { return } 122 guard let occurredAtUnixMilliseconds = try? TeraClock.signedUnixMilliseconds( 123 from: event.occurredAt 124 ) else { 125 return 126 } 127 events.append( 128 Entry( 129 event: event, 130 occurredAtUnixMilliseconds: occurredAtUnixMilliseconds 131 ) 132 ) 133 if events.count > capacity { 134 events.removeFirst(events.count - capacity) 135 } 136 } 137 138 func records() -> [TeraDiagnosticRecord] { 139 events.map { entry in 140 let event = entry.event 141 return TeraDiagnosticRecord( 142 name: event.name, 143 category: event.category, 144 level: event.level.rawValue, 145 fields: event.fields.reduce(into: [String: String]()) { values, field in 146 if values[field.key] == nil { 147 values[field.key] = field.value.renderedValue 148 } 149 }, 150 occurredAtUnixMilliseconds: entry.occurredAtUnixMilliseconds 151 ) 152 } 153 } 154 } 155 156 private struct TeraDiagnosticsDocument: Codable, Sendable { 157 let schema: String 158 let appVersion: String 159 let appBuild: String 160 let runtimeCrate: String 161 let runtimeVersion: String 162 let runtimePhase: String 163 let relayProfile: String? 164 let relayState: String? 165 let relayCount: Int 166 let records: [TeraDiagnosticRecord] 167 } 168 169 struct TeraProductionLifecycleServices: Sendable { 170 let coordinator: TeraLifecycleCoordinator 171 let backgroundTransfer: any RadrootsBackgroundTransfer 172 } 173 174 actor TeraLifecycleCoordinator { 175 private static let maximumExportBytes = 256 * 1024 176 177 private let telemetry: any RadrootsTelemetry 178 private let buffer: TeraDiagnosticsBuffer 179 private let fileAccess: RadrootsAppleFileAccess? 180 private let transfer: (any RadrootsBackgroundTransfer)? 181 private let transferIdentifier: String? 182 private var mediaMaintenance: Task<TeraMediaCleanupResult, Never>? 183 private var backgroundEventsAttached = false 184 185 init( 186 telemetry: any RadrootsTelemetry, 187 buffer: TeraDiagnosticsBuffer, 188 fileAccess: RadrootsAppleFileAccess?, 189 transfer: (any RadrootsBackgroundTransfer)?, 190 transferIdentifier: String? 191 ) { 192 self.telemetry = TeraSanitizedTelemetry(sink: telemetry) 193 self.buffer = buffer 194 self.fileAccess = fileAccess 195 self.transfer = transfer 196 self.transferIdentifier = transferIdentifier 197 } 198 199 static func production(bundleIdentifier: String) throws -> TeraLifecycleCoordinator { 200 try productionServices(bundleIdentifier: bundleIdentifier).coordinator 201 } 202 203 static func productionServices( 204 bundleIdentifier: String 205 ) throws -> TeraProductionLifecycleServices { 206 let roots = try TeraRemoteQualificationEnvironment.applicationFileRoots( 207 appIdentifier: bundleIdentifier 208 ) 209 let fileAccess = RadrootsAppleFileAccess(roots: roots) 210 let buffer = TeraDiagnosticsBuffer() 211 let logger = RadrootsAppleLoggerTelemetry(subsystem: bundleIdentifier) 212 let telemetry = RadrootsMultiplexTelemetry([ 213 logger, 214 buffer, 215 ]) 216 let identifier = try RadrootsBackgroundTransferValidation.normalizedIdentifier( 217 TeraRemoteQualificationEnvironment.backgroundTransferIdentifier( 218 appIdentifier: bundleIdentifier 219 ) 220 ) 221 let transfer = try RadrootsAppleBackgroundTransfer( 222 roots: roots, 223 sessionIdentifier: identifier 224 ) 225 let coordinator = TeraLifecycleCoordinator( 226 telemetry: telemetry, 227 buffer: buffer, 228 fileAccess: fileAccess, 229 transfer: transfer, 230 transferIdentifier: identifier 231 ) 232 return TeraProductionLifecycleServices( 233 coordinator: coordinator, 234 backgroundTransfer: transfer 235 ) 236 } 237 238 static func disabled() -> TeraLifecycleCoordinator { 239 let buffer = TeraDiagnosticsBuffer() 240 return TeraLifecycleCoordinator( 241 telemetry: buffer, 242 buffer: buffer, 243 fileAccess: nil, 244 transfer: nil, 245 transferIdentifier: nil 246 ) 247 } 248 249 static func testing(roots: RadrootsAppleFileRoots, capacity: Int = 128) 250 -> TeraLifecycleCoordinator 251 { 252 let buffer = TeraDiagnosticsBuffer(capacity: capacity) 253 return TeraLifecycleCoordinator( 254 telemetry: buffer, 255 buffer: buffer, 256 fileAccess: RadrootsAppleFileAccess(roots: roots), 257 transfer: nil, 258 transferIdentifier: nil 259 ) 260 } 261 262 @discardableResult 263 func attachBackgroundEvents() async -> Bool { 264 guard !backgroundEventsAttached, 265 let transfer, 266 let transferIdentifier, 267 let roots = fileAccess?.roots 268 else { 269 return true 270 } 271 guard await MainActor.run(body: { UIApplication.shared.isProtectedDataAvailable }) else { return false } 272 if mediaMaintenance == nil { 273 // Explicit host-owned startup work; concurrent lifecycle callers await the 274 // same bounded pass. Cancellation of a waiter does not abandon its fence. 275 mediaMaintenance = Task { await TeraMediaCleanup.run(roots: roots) } 276 } 277 _ = await mediaMaintenance?.value 278 guard await MainActor.run(body: { UIApplication.shared.isProtectedDataAvailable }) else { return false } 279 guard !backgroundEventsAttached else { return true } 280 guard let mediaUse = try? TeraMediaProcessUse.admit(root: roots.dataRoot) else { return false } 281 defer { withExtendedLifetime(mediaUse) {} } 282 backgroundEventsAttached = true 283 await TeraBackgroundEventRouter.shared.attach(identifier: transferIdentifier) { 284 identifier, 285 completion in 286 await transfer.handleEventsForBackgroundURLSession( 287 identifier: identifier, 288 completionHandler: completion 289 ) 290 } 291 return true 292 } 293 294 func record( 295 _ name: String, 296 level: RadrootsTelemetryLevel = .info, 297 fields: [String: String] = [:] 298 ) async { 299 let values = fields.prefix(8).sorted(by: { $0.key < $1.key }).compactMap { key, value in 300 try? RadrootsTelemetryField.string(key, value) 301 } 302 guard 303 let event = try? RadrootsTelemetryEvent( 304 name: name, 305 category: "ios_lifecycle", 306 level: level, 307 fields: values 308 ) 309 else { 310 return 311 } 312 await telemetry.record(event) 313 } 314 315 func prepareDiagnostics( 316 snapshot: TeraRuntimeSnapshot, 317 appVersion: String, 318 appBuild: String, 319 phase: String 320 ) async throws -> RadrootsPreparedExportDocument { 321 guard let fileAccess else { 322 throw TeraRuntimeFailure.local( 323 operation: "diagnostics.prepare", 324 code: "ios.diagnostics.unavailable", 325 safeMessage: "Diagnostics export is unavailable." 326 ) 327 } 328 let mediaUse = try TeraMediaProcessUse.admit(root: fileAccess.roots.dataRoot) 329 defer { withExtendedLifetime(mediaUse) {} } 330 let records = await buffer.records() 331 let document = TeraDiagnosticsDocument( 332 schema: "radroots.ios.diagnostics.v1", 333 appVersion: TeraDiagnosticPolicy.version(appVersion), 334 appBuild: TeraDiagnosticPolicy.build(appBuild), 335 runtimeCrate: TeraDiagnosticPolicy.code(snapshot.crateName, allowed: ["tera_ffi"]), 336 runtimeVersion: TeraDiagnosticPolicy.version(snapshot.crateVersion), 337 runtimePhase: TeraDiagnosticPolicy.code(phase, allowed: TeraDiagnosticPolicy.phases), 338 relayProfile: snapshot.relay.map { TeraDiagnosticPolicy.code($0.profile, allowed: ["public", "simulator_local", "simulator", "device_development", "unknown"]) }, 339 relayState: snapshot.relay.map { TeraDiagnosticPolicy.code($0.state, allowed: ["configured", "connecting", "read_only", "writable", "degraded", "offline", "failed", "closed", "unknown"]) }, 340 relayCount: snapshot.relay?.relays.count ?? 0, 341 records: records 342 ) 343 let encoder = JSONEncoder() 344 encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 345 let data = try encoder.encode(document) 346 guard data.count <= Self.maximumExportBytes else { 347 throw TeraRuntimeFailure.local( 348 operation: "diagnostics.prepare", 349 code: "ios.diagnostics.too_large", 350 safeMessage: "The bounded diagnostics report could not be prepared." 351 ) 352 } 353 return try fileAccess.prepareExport( 354 RadrootsExportDocumentRequest( 355 source: .inlineData(data), 356 suggestedFilename: "tera-diagnostics.json", 357 mediaType: "application/json", 358 sizeBytes: UInt64(data.count) 359 ) 360 ) 361 } 362 363 func releaseDiagnostics(_ export: RadrootsPreparedExportDocument) { 364 guard let fileAccess else { return } 365 try? fileAccess.releasePreparedExport(export) 366 } 367 }