field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

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 }