field_ios

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

commit f670f54ca0391a975238f329a59e518d4625c69a
parent c7f20313fcfe98ed4d966ce843ec19fa4f88e7f9
Author: triesap <tyson@radroots.org>
Date:   Mon, 10 Aug 2026 19:59:28 +0000

refactor: bound iOS runtime lifecycle work

Diffstat:
MRadroots/App/RadrootsAppDelegate.swift | 6------
MRadroots/App/RadrootsAppModel.swift | 45++++++++++++++++++++++++++++++++-------------
MRadroots/Runtime/RadrootsRuntimeClient.swift | 678+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
MRadroots/Runtime/RadrootsRuntimeModels.swift | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
MRadroots/State/RadrootsAddStore.swift | 105++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
MRadroots/State/RadrootsSessionStore.swift | 56++++++++++++++++++++++++++++++++++++--------------------
MRadroots/State/RadrootsSupportingStores.swift | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
MRadroots/State/RadrootsTodayStore.swift | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
MRadrootsTests/RadrootsRuntimeClientTests.swift | 257++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mapi/RadrootsApp.symbols.json | 2+-
10 files changed, 1150 insertions(+), 253 deletions(-)

diff --git a/Radroots/App/RadrootsAppDelegate.swift b/Radroots/App/RadrootsAppDelegate.swift @@ -30,10 +30,4 @@ public final class RadrootsAppDelegate: NSObject, UIApplicationDelegate { ) } } - - public func applicationWillTerminate(_: UIApplication) { - Task { - await RadrootsLifecycleBridge.shared.requestShutdown() - } - } } diff --git a/Radroots/App/RadrootsAppModel.swift b/Radroots/App/RadrootsAppModel.swift @@ -18,6 +18,7 @@ final class RadrootsAppModel: ObservableObject { private var generation: UInt64 = 0 private var lifecycleRegistered = false private var sessionOperationsInFlight = 0 + private var resumePending = false private let isShellUITest: Bool init( @@ -25,11 +26,12 @@ final class RadrootsAppModel: ObservableObject { runtimeClient: RadrootsRuntimeClient? = nil, lifecycleCoordinator requestedLifecycleCoordinator: RadrootsLifecycleCoordinator? = nil ) { - let lifecycleCoordinator = requestedLifecycleCoordinator - ?? Bundle.main.bundleIdentifier.flatMap { - try? RadrootsLifecycleCoordinator.production(bundleIdentifier: $0) - } - ?? RadrootsLifecycleCoordinator.disabled() + let lifecycleCoordinator = + requestedLifecycleCoordinator + ?? Bundle.main.bundleIdentifier.flatMap { + try? RadrootsLifecycleCoordinator.production(bundleIdentifier: $0) + } + ?? RadrootsLifecycleCoordinator.disabled() self.lifecycleCoordinator = lifecycleCoordinator diagnosticsStore = RadrootsDiagnosticsStore(coordinator: lifecycleCoordinator) #if DEBUG @@ -137,9 +139,11 @@ final class RadrootsAppModel: ObservableObject { func resume() async { await ensureLifecycleRegistration() guard sessionOperationsInFlight == 0 else { + resumePending = true await lifecycleCoordinator.record("ios.lifecycle.resume_coalesced") return } + resumePending = false if case .running = phase { await todayStore?.start() await addStore?.start() @@ -150,10 +154,13 @@ final class RadrootsAppModel: ObservableObject { } func suspend() async { + generation &+= 1 + resumePending = false todayStore?.stop() addStore?.suspend() searchStore?.stop() meStore?.stop() + await sessionStore?.suspend() await lifecycleCoordinator.record("ios.lifecycle.background") } @@ -185,7 +192,6 @@ final class RadrootsAppModel: ObservableObject { ) async { guard !isShellUITest else { return } sessionOperationsInFlight += 1 - defer { sessionOperationsInFlight -= 1 } generation &+= 1 let requestedGeneration = generation if showsStarting { @@ -193,21 +199,26 @@ final class RadrootsAppModel: ObservableObject { } guard let sessionStore else { phase = .failed( - bootstrapFailure ?? .local( - operation: "app.bootstrap", - code: "ios.app.bootstrap_failed", - safeMessage: "Radroots could not start." - ) + bootstrapFailure + ?? .local( + operation: "app.bootstrap", + code: "ios.app.bootstrap_failed", + safeMessage: "Radroots could not start." + ) ) await lifecycleCoordinator.record( "ios.lifecycle.operation_failed", level: .error, fields: ["operation": name, "code": "bootstrap_failed"] ) + await finishSessionOperation() return } let result = await operation(sessionStore) - guard generation == requestedGeneration else { return } + guard generation == requestedGeneration else { + await finishSessionOperation() + return + } if case let .running(snapshot) = result { todayStore?.configure(snapshot: snapshot) addStore?.configure(snapshot: snapshot) @@ -218,6 +229,14 @@ final class RadrootsAppModel: ObservableObject { level: Self.isFailure(result) ? .warning : .info, fields: ["operation": name, "phase": Self.phaseCode(result)] ) + await finishSessionOperation() + } + + private func finishSessionOperation() async { + sessionOperationsInFlight -= 1 + guard sessionOperationsInFlight == 0, resumePending else { return } + resumePending = false + await resume() } private func ensureLifecycleRegistration() async { @@ -269,7 +288,7 @@ final class RadrootsAppModel: ObservableObject { } #if DEBUG - private extension RadrootsRuntimeSnapshot { + fileprivate extension RadrootsRuntimeSnapshot { static let shellUITest = RadrootsRuntimeSnapshot( identity: RadrootsRuntimeIdentity( publicKeyHex: String(repeating: "ab", count: 32), diff --git a/Radroots/Runtime/RadrootsRuntimeClient.swift b/Radroots/Runtime/RadrootsRuntimeClient.swift @@ -112,7 +112,9 @@ extension RadrootsRuntimeBackend { throw addUnsupported() } - func uploadAddMediaIntent(input _: RadrootsBlossomUploadIntent) async throws -> RadrootsDraftStatus { + func uploadAddMediaIntent(input _: RadrootsBlossomUploadIntent) async throws + -> RadrootsDraftStatus + { throw addUnsupported() } @@ -153,20 +155,177 @@ struct RadrootsRuntimeBackendStart: Sendable { let snapshot: RadrootsRuntimeSnapshot } -typealias RadrootsRuntimeBackendFactory = @Sendable ( - RadrootsRuntimeLaunchConfiguration -) async throws -> RadrootsRuntimeBackendStart +typealias RadrootsRuntimeBackendFactory = + @Sendable ( + RadrootsRuntimeLaunchConfiguration + ) async throws -> RadrootsRuntimeBackendStart + +private enum RadrootsRuntimeBoundedOutcome<Value: Sendable>: Sendable { + case completed(Result<Value, RadrootsRuntimeFailure>) + case timedOut + case cancelled +} + +private final class RadrootsRuntimeBoundedTask<Value: Sendable>: @unchecked Sendable { + typealias Outcome = RadrootsRuntimeBoundedOutcome<Value> + + private final class State: @unchecked Sendable { + private let lock = NSLock() + private var outcome: Outcome? + private var continuations: [UUID: CheckedContinuation<Outcome, Never>] = [:] + private var cancelledWaiters: Set<UUID> = [] + private var operationTask: Task<Void, Never>? + private var timeoutTask: Task<Void, Never>? + + func install(operationTask: Task<Void, Never>, timeoutTask: Task<Void, Never>) { + let shouldCancel = lock.withLock { () -> Bool in + guard outcome == nil else { return true } + self.operationTask = operationTask + self.timeoutTask = timeoutTask + return false + } + if shouldCancel { + timeoutTask.cancel() + } + } + + func value(cancelsOperationWhenWaiterCancelled: Bool) async -> Outcome { + let waiterID = UUID() + return await withTaskCancellationHandler { + await withCheckedContinuation { requestedContinuation in + let immediate = lock.withLock { () -> Outcome? in + if let outcome { + return outcome + } + if cancelledWaiters.remove(waiterID) != nil { + return .cancelled + } + continuations[waiterID] = requestedContinuation + return nil + } + if let immediate { + requestedContinuation.resume(returning: immediate) + } + } + } onCancel: { + if cancelsOperationWhenWaiterCancelled { + cancel() + } else { + cancelWaiter(waiterID) + } + } + } + + func cancel() { + guard resolve(.cancelled) else { return } + let tasks = lock.withLock { (operationTask, timeoutTask) } + tasks.0?.cancel() + tasks.1?.cancel() + } + + func expire() { + guard resolve(.timedOut) else { return } + let taskToCancel: Task<Void, Never>? = lock.withLock { self.operationTask } + taskToCancel?.cancel() + } + + func finishOperation() { + lock.withLock { operationTask = nil } + } + + private func cancelWaiter(_ waiterID: UUID) { + let continuation = lock.withLock { () -> CheckedContinuation<Outcome, Never>? in + guard outcome == nil else { return nil } + guard let continuation = continuations.removeValue(forKey: waiterID) else { + cancelledWaiters.insert(waiterID) + return nil + } + return continuation + } + continuation?.resume(returning: .cancelled) + } + + @discardableResult + func resolve(_ requestedOutcome: Outcome) -> Bool { + var pendingContinuations: [CheckedContinuation<Outcome, Never>] = [] + var timeoutToCancel: Task<Void, Never>? + let accepted = lock.withLock { () -> Bool in + guard outcome == nil else { return false } + outcome = requestedOutcome + pendingContinuations = Array(continuations.values) + continuations.removeAll(keepingCapacity: false) + cancelledWaiters.removeAll(keepingCapacity: false) + timeoutToCancel = timeoutTask + return true + } + if accepted { + timeoutToCancel?.cancel() + for continuation in pendingContinuations { + continuation.resume(returning: requestedOutcome) + } + } + return accepted + } + } + + private let state: State + + init( + deadlineNanoseconds: UInt64, + operation: @escaping @Sendable () async -> Result<Value, RadrootsRuntimeFailure>, + onAbandonedResult: + @escaping @Sendable ( + Result<Value, RadrootsRuntimeFailure> + ) async -> Void = { _ in } + ) { + let state = State() + self.state = state + let operationTask = Task { [state] in + let result = await operation() + if !state.resolve(.completed(result)) { + await onAbandonedResult(result) + } + state.finishOperation() + } + + let timeoutTask = Task { [state] in + do { + try await Task.sleep(nanoseconds: deadlineNanoseconds) + } catch { + return + } + state.expire() + } + state.install(operationTask: operationTask, timeoutTask: timeoutTask) + } + + func value(cancelsOperationWhenWaiterCancelled: Bool = true) async -> Outcome { + await state.value( + cancelsOperationWhenWaiterCancelled: cancelsOperationWhenWaiterCancelled + ) + } + + func cancel() { + state.cancel() + } +} actor RadrootsRuntimeClient { private struct StartupOperation: Sendable { - let generation: UInt64 + let identity: RadrootsRuntimeOperationIdentity let configuration: RadrootsRuntimeLaunchConfiguration - let task: Task<Result<RadrootsRuntimeBackendStart, RadrootsRuntimeFailure>, Never> + let task: RadrootsRuntimeBoundedTask<RadrootsRuntimeBackendStart> } private struct ShutdownOperation: Sendable { - let generation: UInt64 - let task: Task<Result<RadrootsRuntimeShutdownReceipt, RadrootsRuntimeFailure>, Never> + let identity: RadrootsRuntimeOperationIdentity + let backend: (any RadrootsRuntimeBackend)? + let task: RadrootsRuntimeBoundedTask<RadrootsRuntimeShutdownReceipt> + } + + private struct ActiveOperation: Sendable { + let identity: RadrootsRuntimeOperationIdentity + let cancel: @Sendable () -> Void } private struct Subscription { @@ -176,16 +335,25 @@ actor RadrootsRuntimeClient { } private let factory: RadrootsRuntimeBackendFactory + private let deadlines: RadrootsRuntimeDeadlinePolicy private var generation: UInt64 = 0 + private var operationSequence: UInt64 = 0 private var lifecycleState: RadrootsRuntimeLifecycle = .stopped private var configuration: RadrootsRuntimeLaunchConfiguration? private var backend: (any RadrootsRuntimeBackend)? + private var quarantinedBackend: (any RadrootsRuntimeBackend)? private var startupOperation: StartupOperation? private var shutdownOperation: ShutdownOperation? + private var activeOperations: [UInt64: ActiveOperation] = [:] private var subscriptions: [UUID: Subscription] = [:] + private var lateShutdownSuccesses: Set<RadrootsRuntimeOperationIdentity> = [] - init(factory: @escaping RadrootsRuntimeBackendFactory) { + init( + factory: @escaping RadrootsRuntimeBackendFactory, + deadlines: RadrootsRuntimeDeadlinePolicy = .production + ) { self.factory = factory + self.deadlines = deadlines } func lifecycle() -> RadrootsRuntimeLifecycle { @@ -195,15 +363,32 @@ actor RadrootsRuntimeClient { func start( configuration requestedConfiguration: RadrootsRuntimeLaunchConfiguration ) async throws -> RadrootsRuntimeSnapshot { + try await start(configuration: requestedConfiguration, kind: .startup) + } + + func reconfigure( + configuration requestedConfiguration: RadrootsRuntimeLaunchConfiguration + ) async throws -> RadrootsRuntimeSnapshot { + try await start(configuration: requestedConfiguration, kind: .reconfiguration) + } + + private func start( + configuration requestedConfiguration: RadrootsRuntimeLaunchConfiguration, + kind: RadrootsRuntimeOperationKind + ) async throws -> RadrootsRuntimeSnapshot { if let shutdownOperation { _ = try await finishShutdown(shutdownOperation) } - if let backend, + if quarantinedBackend != nil { + _ = try await finishShutdown(beginShutdown()) + } + + if backend != nil, configuration == requestedConfiguration, case .running = lifecycleState { - return try await backendSnapshot(backend) + return try await snapshot() } if let startupOperation, @@ -221,16 +406,37 @@ actor RadrootsRuntimeClient { let operationGeneration = generation lifecycleState = .starting(generation: operationGeneration) + let identity = nextIdentity(kind: kind) let factory = factory - let task = Task<Result<RadrootsRuntimeBackendStart, RadrootsRuntimeFailure>, Never> { - do { - return try await .success(factory(requestedConfiguration)) - } catch { - return .failure(Self.failure(from: error, operation: "runtime.start")) + let cleanupDeadline = deadlines.shutdownNanoseconds + let task = RadrootsRuntimeBoundedTask<RadrootsRuntimeBackendStart>( + deadlineNanoseconds: deadlines.startupNanoseconds, + operation: { + do { + return try await .success(factory(requestedConfiguration)) + } catch { + return .failure(Self.failure(from: error, operation: identity.rawValue)) + } + }, + onAbandonedResult: { result in + guard case let .success(started) = result else { return } + let cleanup = RadrootsRuntimeBoundedTask<RadrootsRuntimeShutdownReceipt>( + deadlineNanoseconds: cleanupDeadline, + operation: { + do { + return try await .success(started.backend.shutdown()) + } catch { + return .failure( + Self.failure(from: error, operation: "runtime.abandoned_startup") + ) + } + } + ) + _ = await cleanup.value() } - } + ) let operation = StartupOperation( - generation: operationGeneration, + identity: identity, configuration: requestedConfiguration, task: task ) @@ -246,18 +452,26 @@ actor RadrootsRuntimeClient { } func snapshot() async throws -> RadrootsRuntimeSnapshot { - guard let backend, case .running = lifecycleState else { - throw RadrootsRuntimeClientError.notRunning + do { + return try await runtimeOperation("runtime.status") { backend in + try await backend.snapshot() + } + } catch let error as RadrootsRuntimeClientError { + throw error + } catch { + throw RadrootsRuntimeClientError.status( + Self.failure(from: error, operation: "runtime.status") + ) } - return try await backendSnapshot(backend) } func todayPage(request: RadrootsTodayPageRequest) async throws -> RadrootsTodayPage { - guard let backend, case .running = lifecycleState else { - throw RadrootsRuntimeClientError.notRunning - } do { - return try await backend.todayPage(request: request) + return try await runtimeOperation("runtime.today.page") { backend in + try await backend.todayPage(request: request) + } + } catch let error as RadrootsRuntimeClientError { + throw error } catch { throw RadrootsRuntimeClientError.today( Self.failure(from: error, operation: "runtime.today.page") @@ -270,15 +484,16 @@ actor RadrootsRuntimeClient { nowUnixSeconds: UInt64, update: RadrootsTodayProjectionUpdate = .incremental ) async throws -> RadrootsTodayRefreshReceipt { - guard let backend, case .running = lifecycleState else { - throw RadrootsRuntimeClientError.notRunning - } do { - return try await backend.refreshToday( - context: context, - nowUnixSeconds: nowUnixSeconds, - update: update - ) + return try await runtimeOperation("runtime.today.refresh") { backend in + try await backend.refreshToday( + context: context, + nowUnixSeconds: nowUnixSeconds, + update: update + ) + } + } catch let error as RadrootsRuntimeClientError { + throw error } catch { throw RadrootsRuntimeClientError.today( Self.failure(from: error, operation: "runtime.today.refresh") @@ -417,6 +632,7 @@ actor RadrootsRuntimeClient { let id = UUID() let subscriptionGeneration = generation + let identity = nextIdentity(kind: .subscription) let pair = AsyncStream.makeStream( of: RadrootsRuntimeChange.self, bufferingPolicy: .bufferingNewest(bufferCapacity) @@ -432,32 +648,96 @@ actor RadrootsRuntimeClient { token: nil ) - do { - let token = try await backend.subscribe(bufferCapacity: bufferCapacity) { [weak self] change in - await self?.receive( - change, - subscriptionID: id, - generation: subscriptionGeneration + let subscriptionDeadline = deadlines.subscriptionNanoseconds + let task = RadrootsRuntimeBoundedTask<any RadrootsRuntimeSubscriptionToken>( + deadlineNanoseconds: subscriptionDeadline, + operation: { + do { + return try await .success( + backend.subscribe(bufferCapacity: bufferCapacity) { [weak self] change in + await self?.receive( + change, + subscriptionID: id, + generation: subscriptionGeneration + ) + } + ) + } catch { + return .failure(Self.failure(from: error, operation: identity.rawValue)) + } + }, + onAbandonedResult: { result in + guard case let .success(token) = result else { return } + let cancellation = RadrootsRuntimeBoundedTask<Void>( + deadlineNanoseconds: subscriptionDeadline, + operation: { + await token.cancel() + return .success(()) + } ) + _ = await cancellation.value() } - guard generation == subscriptionGeneration, - var subscription = subscriptions[id] - else { - await token.cancel() - pair.continuation.finish() - throw RadrootsRuntimeClientError.superseded + ) + activeOperations[identity.sequence] = ActiveOperation( + identity: identity, + cancel: { task.cancel() } + ) + let outcome = await task.value() + removeActiveOperation(identity) + + guard generation == subscriptionGeneration, + case .running = lifecycleState, + var subscription = subscriptions[id] + else { + subscriptions.removeValue(forKey: id)?.continuation.finish() + if case let .completed(.success(token)) = outcome { + cancelTokenDetached(token) } + throw RadrootsRuntimeClientError.superseded + } + + switch outcome { + case let .completed(.success(token)): subscription.token = token subscriptions[id] = subscription return pair.stream - } catch let error as RadrootsRuntimeClientError { + case let .completed(.failure(failure)): subscriptions.removeValue(forKey: id)?.continuation.finish() - throw error - } catch { + throw RadrootsRuntimeClientError.subscription(failure) + case .timedOut: subscriptions.removeValue(forKey: id)?.continuation.finish() throw RadrootsRuntimeClientError.subscription( - Self.failure(from: error, operation: "runtime.subscribe") + Self.deadlineFailure(identity: identity) ) + case .cancelled: + subscriptions.removeValue(forKey: id)?.continuation.finish() + throw RadrootsRuntimeClientError.subscription( + Self.cancellationFailure(identity: identity) + ) + } + } + + func suspend() { + for operation in activeOperations.values { + operation.cancel() + } + activeOperations.removeAll() + + let activeSubscriptions = Array(subscriptions.values) + subscriptions.removeAll() + for subscription in activeSubscriptions { + subscription.continuation.finish() + if let token = subscription.token { + cancelTokenDetached(token) + } + } + + if let startupOperation { + generation &+= 1 + startupOperation.task.cancel() + self.startupOperation = nil + configuration = nil + lifecycleState = .stopped } } @@ -465,7 +745,10 @@ actor RadrootsRuntimeClient { if let shutdownOperation { return try await finishShutdown(shutdownOperation) } - guard startupOperation != nil || backend != nil || !subscriptions.isEmpty else { + guard + startupOperation != nil || backend != nil || quarantinedBackend != nil + || !subscriptions.isEmpty || !activeOperations.isEmpty + else { lifecycleState = .stopped return .alreadyStopped } @@ -475,32 +758,46 @@ actor RadrootsRuntimeClient { private func finishStartup( _ operation: StartupOperation ) async throws -> RadrootsRuntimeSnapshot { - let result = await operation.task.value - guard generation == operation.generation else { + let outcome = await operation.task.value(cancelsOperationWhenWaiterCancelled: false) + guard generation == operation.identity.generation else { throw RadrootsRuntimeClientError.superseded } - if let active = startupOperation, - active.generation == operation.generation + if case .cancelled = outcome, + startupOperation?.identity == operation.identity { + throw RadrootsRuntimeClientError.startup( + Self.cancellationFailure(identity: operation.identity) + ) + } + + if startupOperation?.identity == operation.identity { startupOperation = nil - } else if let backend, + } else if case let .completed(.success(started)) = outcome, configuration == operation.configuration, case .running = lifecycleState { - return try await backendSnapshot(backend) + return started.snapshot } else { throw RadrootsRuntimeClientError.superseded } - switch result { - case let .success(started): + switch outcome { + case let .completed(.success(started)): backend = started.backend configuration = operation.configuration - lifecycleState = .running(generation: operation.generation) + lifecycleState = .running(generation: operation.identity.generation) return started.snapshot - case let .failure(failure): - lifecycleState = .failed(generation: operation.generation, failure: failure) + case let .completed(.failure(failure)): + lifecycleState = .failed(generation: operation.identity.generation, failure: failure) + throw RadrootsRuntimeClientError.startup(failure) + case .timedOut: + let failure = Self.deadlineFailure(identity: operation.identity) + lifecycleState = .failed(generation: operation.identity.generation, failure: failure) + throw RadrootsRuntimeClientError.startup(failure) + case .cancelled: + let failure = Self.cancellationFailure(identity: operation.identity) + lifecycleState = .failed(generation: operation.identity.generation, failure: failure) throw RadrootsRuntimeClientError.startup(failure) } } @@ -509,45 +806,58 @@ actor RadrootsRuntimeClient { generation &+= 1 let operationGeneration = generation let pendingStartup = startupOperation - let activeBackend = backend + let activeBackend = backend ?? quarantinedBackend let activeSubscriptions = Array(subscriptions.values) + let activeRuntimeOperations = Array(activeOperations.values) startupOperation = nil backend = nil + quarantinedBackend = nil configuration = nil subscriptions.removeAll() + activeOperations.removeAll() lifecycleState = .stopping(generation: operationGeneration) - let task = Task<Result<RadrootsRuntimeShutdownReceipt, RadrootsRuntimeFailure>, Never> { - for subscription in activeSubscriptions { - subscription.continuation.finish() - await subscription.token?.cancel() - } + pendingStartup?.task.cancel() + for operation in activeRuntimeOperations { + operation.cancel() + } + for subscription in activeSubscriptions { + subscription.continuation.finish() + } - let backendToClose: (any RadrootsRuntimeBackend)? - if let activeBackend { - backendToClose = activeBackend - } else if let pendingStartup { - switch await pendingStartup.task.value { - case let .success(started): - backendToClose = started.backend - case let .failure(failure): - return .failure(failure) + let identity = nextIdentity(kind: .shutdown) + let cancellationDeadline = deadlines.subscriptionNanoseconds + let task = RadrootsRuntimeBoundedTask<RadrootsRuntimeShutdownReceipt>( + deadlineNanoseconds: deadlines.shutdownNanoseconds, + operation: { + let cancellations = activeSubscriptions.compactMap(\.token).map { token in + RadrootsRuntimeBoundedTask<Void>( + deadlineNanoseconds: cancellationDeadline, + operation: { + await token.cancel() + return .success(()) + } + ) + } + for cancellation in cancellations { + _ = await cancellation.value() } - } else { - backendToClose = nil - } - guard let backendToClose else { - return .success(.alreadyStopped) - } - do { - return try await .success(backendToClose.shutdown()) - } catch { - return .failure(Self.failure(from: error, operation: "runtime.shutdown")) + guard let activeBackend else { + return .success(.alreadyStopped) + } + do { + return try await .success(activeBackend.shutdown()) + } catch { + return .failure(Self.failure(from: error, operation: identity.rawValue)) + } + }, + onAbandonedResult: { [weak self] result in + await self?.settleLateShutdown(identity: identity, result: result) } - } - let operation = ShutdownOperation(generation: operationGeneration, task: task) + ) + let operation = ShutdownOperation(identity: identity, backend: activeBackend, task: task) shutdownOperation = operation return operation } @@ -555,46 +865,101 @@ actor RadrootsRuntimeClient { private func finishShutdown( _ operation: ShutdownOperation ) async throws -> RadrootsRuntimeShutdownReceipt { - let result = await operation.task.value - if shutdownOperation?.generation == operation.generation { + let outcome = await operation.task.value(cancelsOperationWhenWaiterCancelled: false) + if case .cancelled = outcome, + shutdownOperation?.identity == operation.identity, + generation == operation.identity.generation + { + throw RadrootsRuntimeClientError.shutdown( + Self.cancellationFailure(identity: operation.identity) + ) + } + if shutdownOperation?.identity == operation.identity { shutdownOperation = nil - switch result { - case .success: - lifecycleState = .stopped - case let .failure(failure): - lifecycleState = .failed(generation: operation.generation, failure: failure) - } } - switch result { - case let .success(receipt): + let lateSucceeded = lateShutdownSuccesses.remove(operation.identity) != nil + guard generation == operation.identity.generation else { + throw RadrootsRuntimeClientError.superseded + } + + switch outcome { + case let .completed(.success(receipt)): + quarantinedBackend = nil + lifecycleState = .stopped return receipt - case let .failure(failure): + case let .completed(.failure(failure)): + quarantinedBackend = operation.backend + lifecycleState = .failed(generation: operation.identity.generation, failure: failure) + throw RadrootsRuntimeClientError.shutdown(failure) + case .timedOut: + let failure = Self.deadlineFailure(identity: operation.identity) + quarantinedBackend = lateSucceeded ? nil : operation.backend + lifecycleState = + lateSucceeded + ? .stopped + : .failed(generation: operation.identity.generation, failure: failure) + throw RadrootsRuntimeClientError.shutdown(failure) + case .cancelled: + let failure = Self.cancellationFailure(identity: operation.identity) + quarantinedBackend = lateSucceeded ? nil : operation.backend + lifecycleState = + lateSucceeded + ? .stopped + : .failed(generation: operation.identity.generation, failure: failure) throw RadrootsRuntimeClientError.shutdown(failure) } } - private func backendSnapshot( - _ backend: any RadrootsRuntimeBackend - ) async throws -> RadrootsRuntimeSnapshot { - do { - return try await backend.snapshot() - } catch { - throw RadrootsRuntimeClientError.status( - Self.failure(from: error, operation: "runtime.status") - ) + private func runtimeOperation<T: Sendable>( + _ operation: String, + _ body: @escaping @Sendable (any RadrootsRuntimeBackend) async throws -> T + ) async throws -> T { + guard let backend, case .running = lifecycleState else { + throw RadrootsRuntimeClientError.notRunning + } + let operationGeneration = generation + let identity = nextIdentity(kind: .operation) + let task = RadrootsRuntimeBoundedTask<T>( + deadlineNanoseconds: deadlines.operationNanoseconds, + operation: { + do { + return try await .success(body(backend)) + } catch { + return .failure(Self.failure(from: error, operation: operation)) + } + } + ) + activeOperations[identity.sequence] = ActiveOperation( + identity: identity, + cancel: { task.cancel() } + ) + let outcome = await task.value() + removeActiveOperation(identity) + + guard generation == operationGeneration, case .running = lifecycleState else { + throw RadrootsRuntimeClientError.superseded + } + switch outcome { + case let .completed(.success(value)): + return value + case let .completed(.failure(failure)): + throw failure + case .timedOut: + throw Self.deadlineFailure(identity: identity) + case .cancelled: + throw Self.cancellationFailure(identity: identity) } } private func addOperation<T: Sendable>( _ operation: String, - _ body: @Sendable (any RadrootsRuntimeBackend) async throws -> T + _ body: @escaping @Sendable (any RadrootsRuntimeBackend) async throws -> T ) async throws -> T { - guard let backend, case .running = lifecycleState else { - throw RadrootsRuntimeClientError.notRunning - } do { - return try await body(backend) + return try await runtimeOperation(operation, body) + } catch let error as RadrootsRuntimeClientError { + throw error } catch { throw RadrootsRuntimeClientError.add( Self.failure(from: error, operation: operation) @@ -604,13 +969,12 @@ actor RadrootsRuntimeClient { private func supportOperation<T: Sendable>( _ operation: String, - _ body: @Sendable (any RadrootsRuntimeBackend) async throws -> T + _ body: @escaping @Sendable (any RadrootsRuntimeBackend) async throws -> T ) async throws -> T { - guard let backend, case .running = lifecycleState else { - throw RadrootsRuntimeClientError.notRunning - } do { - return try await body(backend) + return try await runtimeOperation(operation, body) + } catch let error as RadrootsRuntimeClientError { + throw error } catch { throw RadrootsRuntimeClientError.support( Self.failure(from: error, operation: operation) @@ -624,6 +988,7 @@ actor RadrootsRuntimeClient { generation subscriptionGeneration: UInt64 ) { guard generation == subscriptionGeneration, + case .running = lifecycleState, let subscription = subscriptions[subscriptionID], subscription.generation == subscriptionGeneration else { @@ -632,7 +997,7 @@ actor RadrootsRuntimeClient { subscription.continuation.yield(change) } - private func cancelSubscription(id: UUID, generation subscriptionGeneration: UInt64) async { + private func cancelSubscription(id: UUID, generation subscriptionGeneration: UInt64) { guard let subscription = subscriptions[id], subscription.generation == subscriptionGeneration else { @@ -640,13 +1005,86 @@ actor RadrootsRuntimeClient { } subscriptions.removeValue(forKey: id) subscription.continuation.finish() - await subscription.token?.cancel() + if let token = subscription.token { + cancelTokenDetached(token) + } + } + + private func cancelTokenDetached(_ token: any RadrootsRuntimeSubscriptionToken) { + let deadline = deadlines.subscriptionNanoseconds + Task { + let cancellation = RadrootsRuntimeBoundedTask<Void>( + deadlineNanoseconds: deadline, + operation: { + await token.cancel() + return .success(()) + } + ) + _ = await cancellation.value() + } + } + + private func settleLateShutdown( + identity: RadrootsRuntimeOperationIdentity, + result: Result<RadrootsRuntimeShutdownReceipt, RadrootsRuntimeFailure> + ) { + guard generation == identity.generation else { return } + guard case .success = result else { return } + if shutdownOperation?.identity == identity { + lateShutdownSuccesses.insert(identity) + } else { + quarantinedBackend = nil + lifecycleState = .stopped + } + } + + private func nextIdentity( + kind: RadrootsRuntimeOperationKind + ) -> RadrootsRuntimeOperationIdentity { + operationSequence &+= 1 + return RadrootsRuntimeOperationIdentity( + generation: generation, + sequence: operationSequence, + kind: kind + ) + } + + private func removeActiveOperation(_ identity: RadrootsRuntimeOperationIdentity) { + guard activeOperations[identity.sequence]?.identity == identity else { return } + activeOperations.removeValue(forKey: identity.sequence) + } + + private static func deadlineFailure( + identity: RadrootsRuntimeOperationIdentity + ) -> RadrootsRuntimeFailure { + .local( + operation: identity.rawValue, + code: "ios.runtime.deadline_exceeded", + safeMessage: "The Radroots runtime operation did not finish in time." + ) + } + + private static func cancellationFailure( + identity: RadrootsRuntimeOperationIdentity + ) -> RadrootsRuntimeFailure { + .local( + operation: identity.rawValue, + code: "ios.runtime.cancelled", + safeMessage: "The Radroots runtime operation was cancelled." + ) } private static func failure(from error: Error, operation: String) -> RadrootsRuntimeFailure { if let failure = error as? RadrootsRuntimeFailure { return failure } + if error is CancellationError { + return .local( + operation: operation, + code: "ios.runtime.cancelled", + safeMessage: "The Radroots runtime operation was cancelled." + ) + } if case let RadrootsRuntimeClientError.startup(failure) = error { return failure } diff --git a/Radroots/Runtime/RadrootsRuntimeModels.swift b/Radroots/Runtime/RadrootsRuntimeModels.swift @@ -199,6 +199,71 @@ struct RadrootsRuntimeShutdownReceipt: Sendable, Equatable { static let alreadyStopped = Self(state: "closed", alreadyClosed: true) } +enum RadrootsRuntimeOperationKind: String, Sendable, Equatable, Hashable { + case startup + case operation + case reconfiguration + case subscription + case shutdown +} + +struct RadrootsRuntimeOperationIdentity: Sendable, Equatable, Hashable { + let generation: UInt64 + let sequence: UInt64 + let kind: RadrootsRuntimeOperationKind + + var rawValue: String { + "ios-runtime-\(generation)-\(sequence)-\(kind.rawValue)" + } +} + +struct RadrootsRuntimeDeadlinePolicy: Sendable, Equatable { + let startupNanoseconds: UInt64 + let operationNanoseconds: UInt64 + let subscriptionNanoseconds: UInt64 + let shutdownNanoseconds: UInt64 + + init( + startupNanoseconds: UInt64 = 30_000_000_000, + operationNanoseconds: UInt64 = 30_000_000_000, + subscriptionNanoseconds: UInt64 = 10_000_000_000, + shutdownNanoseconds: UInt64 = 10_000_000_000 + ) { + precondition(startupNanoseconds > 0 && startupNanoseconds <= 120_000_000_000) + precondition(operationNanoseconds > 0 && operationNanoseconds <= 120_000_000_000) + precondition(subscriptionNanoseconds > 0 && subscriptionNanoseconds <= 60_000_000_000) + precondition(shutdownNanoseconds > 0 && shutdownNanoseconds <= 60_000_000_000) + self.startupNanoseconds = startupNanoseconds + self.operationNanoseconds = operationNanoseconds + self.subscriptionNanoseconds = subscriptionNanoseconds + self.shutdownNanoseconds = shutdownNanoseconds + } + + static let production = Self() +} + +enum RadrootsRuntimeObservationState: Sendable, Equatable { + case inactive + case subscribing(attempt: UInt32) + case active + case retrying(attempt: UInt32, message: String) + case stopped +} + +enum RadrootsRuntimeObservationBackoff { + static func sleep(attempt: UInt32) async throws { + let exponent = min(attempt.saturatingSubtractingOne, 7) + let delay = min(UInt64(250_000_000) << exponent, 30_000_000_000) + try await Task.sleep(nanoseconds: delay) + } +} + +private extension UInt32 { + var saturatingSubtractingOne: UInt32 { + self == 0 ? 0 : self - 1 + } +} + struct RadrootsRuntimeFailure: Error, Sendable, Equatable { let schemaVersion: UInt16 let code: String @@ -413,17 +478,18 @@ struct RadrootsTodayCard: Sendable, Equatable, Hashable, Identifiable { let localOperationState: String? var authorName: String { - authorProfile?.preferredName ?? RadrootsProfileSummary( - authorPublicKey: authorPublicKey, - name: nil, - displayName: nil, - about: nil, - picture: nil, - banner: nil, - nip05: nil, - website: nil, - lightningAddress: nil - ).preferredName + authorProfile?.preferredName + ?? RadrootsProfileSummary( + authorPublicKey: authorPublicKey, + name: nil, + displayName: nil, + about: nil, + picture: nil, + banner: nil, + nip05: nil, + website: nil, + lightningAddress: nil + ).preferredName } var priceSummary: String? { diff --git a/Radroots/State/RadrootsAddStore.swift b/Radroots/State/RadrootsAddStore.swift @@ -21,14 +21,17 @@ final class RadrootsAddStore: ObservableObject { @Published private(set) var isWorking = false @Published private(set) var message: String? @Published private(set) var lastFailureCode: String? + @Published private(set) var observationState: RadrootsRuntimeObservationState = .inactive private let runtimeClient: RadrootsRuntimeClient private let media: (any RadrootsAddMediaHandling)? private let now: @Sendable () -> UInt64 + private let observationDelay: @Sendable (UInt32) async throws -> Void private var generation: UInt64 = 0 private var operationGeneration: UInt64? private var observationTask: Task<Void, Never>? private var isStarted = false + private var observationGeneration: UInt64 = 0 init( runtimeClient: RadrootsRuntimeClient, @@ -36,11 +39,14 @@ final class RadrootsAddStore: ObservableObject { initialType: RadrootsAddCommandType = .createUpdate, now: @escaping @Sendable () -> UInt64 = { UInt64(Date().timeIntervalSince1970) - } + }, + observationDelay: @escaping @Sendable (UInt32) async throws -> Void = + RadrootsRuntimeObservationBackoff.sleep ) { self.runtimeClient = runtimeClient self.media = media self.now = now + self.observationDelay = observationDelay form = .empty(initialType) } @@ -101,15 +107,19 @@ final class RadrootsAddStore: ObservableObject { func stop() { generation &+= 1 isStarted = false + observationGeneration &+= 1 observationTask?.cancel() observationTask = nil + observationState = .stopped isWorking = false } func suspend() { isStarted = false + observationGeneration &+= 1 observationTask?.cancel() observationTask = nil + observationState = .stopped } func selectType(_ type: RadrootsAddCommandType) { @@ -209,11 +219,12 @@ final class RadrootsAddStore: ObservableObject { func submit() async { await perform { - var status: RadrootsDraftStatus = if let active = self.activeDraft, !active.state.isEditable { - active - } else { - try await self.saveCurrentForm() - } + var status: RadrootsDraftStatus = + if let active = self.activeDraft, !active.state.isEditable { + active + } else { + try await self.saveCurrentForm() + } if !status.media.isEmpty, status.media.contains(where: { $0.stage != .verified }) @@ -231,9 +242,10 @@ final class RadrootsAddStore: ObservableObject { } catch { if Self.failure(for: error)?.code == "writable_relay_unavailable" { self.accept(status) - self.message = status.media.isEmpty - ? "Draft saved. Configure a writable relay to publish." - : "Photo verified and draft saved. Configure a writable relay to publish." + self.message = + status.media.isEmpty + ? "Draft saved. Configure a writable relay to publish." + : "Photo verified and draft saved. Configure a writable relay to publish." return } throw error @@ -260,7 +272,9 @@ final class RadrootsAddStore: ObservableObject { guard let draft = draft ?? activeDraft else { return } await perform { var current = try await self.runtimeClient.draftStatus(id: draft.id) - if current.state == .draft || current.state == .mediaPreparing || current.state == .readyToSign { + if current.state == .draft || current.state == .mediaPreparing + || current.state == .readyToSign + { self.activeDraft = current if let form = current.form { self.form = form @@ -317,7 +331,8 @@ final class RadrootsAddStore: ObservableObject { } self.activeDraft = nil self.form = Self.revisedForm(card) - self.message = "Retraction: \(retraction.honestSummary). Review the separate revised copy before publishing." + self.message = + "Retraction: \(retraction.honestSummary). Review the separate revised copy before publishing." } } @@ -340,7 +355,9 @@ final class RadrootsAddStore: ObservableObject { return status } - private func uploadPendingMedia(_ initial: RadrootsDraftStatus) async throws -> RadrootsDraftStatus { + private func uploadPendingMedia(_ initial: RadrootsDraftStatus) async throws + -> RadrootsDraftStatus + { var status = initial guard let form = status.form else { return status } let opened = try await openedMedia(form.media) @@ -370,7 +387,9 @@ final class RadrootsAddStore: ObservableObject { return status } - private func openedMedia(_ values: [RadrootsPreparedMedia]? = nil) async throws -> RadrootsOpenedMedia { + private func openedMedia(_ values: [RadrootsPreparedMedia]? = nil) async throws + -> RadrootsOpenedMedia + { let values = values ?? form.media guard !values.isEmpty else { return RadrootsOpenedMedia(handles: [], files: []) } guard let media else { @@ -409,25 +428,52 @@ final class RadrootsAddStore: ObservableObject { } private func startObservation() { - observationTask = Task { [weak self, runtimeClient] in + observationGeneration &+= 1 + let requestedGeneration = observationGeneration + observationTask = Task { [weak self] in + await self?.observe(generation: requestedGeneration) + } + } + + private func observe(generation: UInt64) async { + var attempt: UInt32 = 0 + while isStarted, observationGeneration == generation, !Task.isCancelled { + observationState = .subscribing(attempt: attempt &+ 1) + var failureMessage = "Runtime observation ended unexpectedly." do { let changes = try await runtimeClient.changes(bufferCapacity: 16) + observationState = .active for await change in changes { guard !Task.isCancelled else { break } + attempt = 0 if change.kind == .drafts || change.kind == .media { - await self?.reloadDrafts() + await reloadDrafts() } - if change.kind == .media { - await self?.refreshBlossomSnapshot() - } - if change.kind == .settings { - await self?.refreshBlossomSnapshot() + if change.kind == .media || change.kind == .settings { + await refreshBlossomSnapshot() } } } catch { - // Durable draft operations remain available without observation. + failureMessage = + (error as? LocalizedError)?.errorDescription + ?? "Runtime observation is temporarily unavailable." } + guard isStarted, observationGeneration == generation, !Task.isCancelled else { break } + attempt = attempt == .max ? .max : attempt + 1 + observationState = .retrying(attempt: attempt, message: failureMessage) + do { + try await observationDelay(attempt) + } catch { + break + } + } + guard observationGeneration == generation else { return } + observationTask = nil + isStarted = false + if case .retrying = observationState { + return } + observationState = .stopped } private func accept(_ status: RadrootsDraftStatus) { @@ -488,7 +534,8 @@ final class RadrootsAddStore: ObservableObject { if let failure = failure(for: error) { return failure.safeMessage } - return (error as? LocalizedError)?.errorDescription ?? "The Add operation could not be completed." + return (error as? LocalizedError)?.errorDescription + ?? "The Add operation could not be completed." } private static func failure(for error: Error) -> RadrootsRuntimeFailure? { @@ -500,11 +547,12 @@ final class RadrootsAddStore: ObservableObject { private static func retractionInput(_ card: RadrootsTodayCard) -> RadrootsRetractionDraftInput { let command = commandType(for: card.type) - let kind: UInt32 = switch card.type { - case .update, .photoUpdate, .ask: 1 - case .event: card.contractID == "radroots.calendar.date_event.v1" ? 31922 : 31923 - case .foodAvailability: 30402 - } + let kind: UInt32 = + switch card.type { + case .update, .photoUpdate, .ask: 1 + case .event: card.contractID == "radroots.calendar.date_event.v1" ? 31922 : 31923 + case .foodAvailability: 30402 + } return RadrootsRetractionDraftInput( commandType: command, targetCardID: card.id, @@ -540,7 +588,8 @@ final class RadrootsAddStore: ObservableObject { } } else if command == .createFoodAvailability { form.summary = card.foodSummary ?? card.content - form.foodPublishedAtUnixSeconds = card.foodPublishedAtUnixSeconds ?? card.effectiveAtUnixSeconds + form.foodPublishedAtUnixSeconds = + card.foodPublishedAtUnixSeconds ?? card.effectiveAtUnixSeconds form.foodStatus = card.foodStatus ?? card.lifecycle.rawValue } return form diff --git a/Radroots/State/RadrootsSessionStore.swift b/Radroots/State/RadrootsSessionStore.swift @@ -72,9 +72,11 @@ actor RadrootsSessionStore { throw RadrootsConfigurationError.missing("bundle_identifier") } let qualification = try RadrootsRemoteQualificationEnvironment.current() - let servicePrefix = try qualification?.keychainServicePrefix ?? requiredString( - "RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX", bundle: bundle - ) + let servicePrefix = + try qualification?.keychainServicePrefix + ?? requiredString( + "RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX", bundle: bundle + ) let bootstrap = try RadrootsConfigurationBootstrap( runtimeMode: qualification == nil ? requiredString("RADROOTS_FIELD_IOS_RUNTIME_MODE", bundle: bundle) @@ -87,7 +89,8 @@ actor RadrootsSessionStore { bundleIdentifier: bundleIdentifier, appMetadata: RadrootsRuntimeAppMetadata( bundleIdentifier: bundleIdentifier, - version: bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0", + version: bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + ?? "0", buildNumber: bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "0", buildSHA: normalizedOptional( bundle.object(forInfoDictionaryKey: "GIT_SHA") as? String @@ -128,6 +131,14 @@ actor RadrootsSessionStore { await start(acceptingReconfiguration: true) } + func suspend() async { + generation &+= 1 + await runtimeClient.suspend() + if case .starting = phase { + phase = .stopped + } + } + private func start(acceptingReconfiguration: Bool) async -> RadrootsSessionPhase { generation &+= 1 let requestedGeneration = generation @@ -273,21 +284,25 @@ actor RadrootsSessionStore { ) let sourceGeneration = try await configurationStore.sourceGeneration() let signer = try await identityStore.signer(for: identity) - let snapshot = try await runtimeClient.start( - configuration: RadrootsRuntimeLaunchConfiguration( - applicationSupportDirectory: mobileStore.applicationSupportDirectory.path, - publicKeyHex: publicKeyHex, - sourceGenerationHex: sourceGeneration.generationHex, - sourceGenerationCreatedAtUnixMilliseconds: sourceGeneration.createdAtUnixMilliseconds, - protectedData: protectedData.isAvailable() ? .available : .unavailable, - networkProfile: configuration.profile.runtimeValue, - writableRelays: configuration.writableRelays, - blossom: configuration.blossom, - app: configuration.appMetadata, - signerGeneration: signerGeneration, - signer: signer - ) + let launchConfiguration = RadrootsRuntimeLaunchConfiguration( + applicationSupportDirectory: mobileStore.applicationSupportDirectory.path, + publicKeyHex: publicKeyHex, + sourceGenerationHex: sourceGeneration.generationHex, + sourceGenerationCreatedAtUnixMilliseconds: sourceGeneration.createdAtUnixMilliseconds, + protectedData: protectedData.isAvailable() ? .available : .unavailable, + networkProfile: configuration.profile.runtimeValue, + writableRelays: configuration.writableRelays, + blossom: configuration.blossom, + app: configuration.appMetadata, + signerGeneration: signerGeneration, + signer: signer ) + let snapshot = + if configuration.activationState == .reconfigurationRequired { + try await runtimeClient.reconfigure(configuration: launchConfiguration) + } else { + try await runtimeClient.start(configuration: launchConfiguration) + } guard generation == requestedGeneration else { _ = try? await runtimeClient.stop() throw RadrootsRuntimeClientError.superseded @@ -310,8 +325,9 @@ actor RadrootsSessionStore { private func failIdentityOperation(_ error: Error) -> RadrootsSessionPhase { generation &+= 1 - let message = (error as? LocalizedError)?.errorDescription - ?? "The local identity operation could not be completed." + let message = + (error as? LocalizedError)?.errorDescription + ?? "The local identity operation could not be completed." phase = .failed( .local( operation: "identity.operation", diff --git a/Radroots/State/RadrootsSupportingStores.swift b/Radroots/State/RadrootsSupportingStores.swift @@ -102,20 +102,26 @@ final class RadrootsSearchStore: ObservableObject { final class RadrootsMeStore: ObservableObject { @Published private(set) var snapshot: RadrootsMeSnapshot? @Published private(set) var state: RadrootsSupportingLoadState = .idle + @Published private(set) var observationState: RadrootsRuntimeObservationState = .inactive private let runtimeClient: RadrootsRuntimeClient private let now: @Sendable () -> UInt64 + private let observationDelay: @Sendable (UInt32) async throws -> Void private var context: RadrootsLocalNetwork? private var generation: UInt64 = 0 private var observationTask: Task<Void, Never>? private var isStarted = false + private var observationGeneration: UInt64 = 0 init( runtimeClient: RadrootsRuntimeClient, - now: @escaping @Sendable () -> UInt64 = { UInt64(Date().timeIntervalSince1970) } + now: @escaping @Sendable () -> UInt64 = { UInt64(Date().timeIntervalSince1970) }, + observationDelay: @escaping @Sendable (UInt32) async throws -> Void = + RadrootsRuntimeObservationBackoff.sleep ) { self.runtimeClient = runtimeClient self.now = now + self.observationDelay = observationDelay } deinit { @@ -133,22 +139,10 @@ final class RadrootsMeStore: ObservableObject { func start() async { if !isStarted { isStarted = true - observationTask = Task { [weak self, runtimeClient] in - do { - let changes = try await runtimeClient.changes(bufferCapacity: 8) - for await change in changes { - guard !Task.isCancelled else { break } - switch change.kind { - case .today, .identity, .profile, .media, .drafts: - await self?.reload() - case .initial, .settings, .relay, .lifecycle: - continue - } - } - } catch { - // Manual loading remains available from the durable projection. - } - self?.observationDidFinish() + observationGeneration &+= 1 + let requestedGeneration = observationGeneration + observationTask = Task { [weak self] in + await self?.observe(generation: requestedGeneration) } } await reload() @@ -182,13 +176,55 @@ final class RadrootsMeStore: ObservableObject { func stop() { generation &+= 1 isStarted = false + observationGeneration &+= 1 observationTask?.cancel() observationTask = nil + observationState = .stopped + } + + private func observe(generation: UInt64) async { + var attempt: UInt32 = 0 + while isStarted, observationGeneration == generation, !Task.isCancelled { + observationState = .subscribing(attempt: attempt &+ 1) + var failureMessage = "Runtime observation ended unexpectedly." + do { + let changes = try await runtimeClient.changes(bufferCapacity: 8) + observationState = .active + for await change in changes { + guard !Task.isCancelled else { break } + attempt = 0 + switch change.kind { + case .today, .identity, .profile, .media, .drafts: + await reload() + case .initial, .settings, .relay, .lifecycle: + continue + } + } + } catch { + failureMessage = + (error as? LocalizedError)?.errorDescription + ?? "Runtime observation is temporarily unavailable." + } + guard isStarted, observationGeneration == generation, !Task.isCancelled else { break } + attempt = attempt == .max ? .max : attempt + 1 + observationState = .retrying(attempt: attempt, message: failureMessage) + do { + try await observationDelay(attempt) + } catch { + break + } + } + observationDidFinish(generation: generation) } - private func observationDidFinish() { + private func observationDidFinish(generation: UInt64) { + guard observationGeneration == generation else { return } observationTask = nil isStarted = false + if case .retrying = observationState { + return + } + observationState = .stopped } private static func message(for error: Error) -> String { diff --git a/Radroots/State/RadrootsTodayStore.swift b/Radroots/State/RadrootsTodayStore.swift @@ -16,16 +16,19 @@ final class RadrootsTodayStore: ObservableObject { @Published private(set) var cards: [RadrootsTodayCard] = [] @Published private(set) var state: RadrootsTodayLoadState = .idle @Published private(set) var isLoadingNextPage = false + @Published private(set) var observationState: RadrootsRuntimeObservationState = .inactive private let runtimeClient: RadrootsRuntimeClient private let pageSize: UInt16 private let now: @Sendable () -> UInt64 + private let observationDelay: @Sendable (UInt32) async throws -> Void private var frozenAsOfUnixSeconds: UInt64? private var nextCursor: String? private var requestGeneration: UInt64 = 0 private var observationTask: Task<Void, Never>? private var reloadTask: Task<Void, Never>? private var isStarted = false + private var observationGeneration: UInt64 = 0 init( runtimeClient: RadrootsRuntimeClient, @@ -34,12 +37,15 @@ final class RadrootsTodayStore: ObservableObject { pageSize: UInt16 = 20, now: @escaping @Sendable () -> UInt64 = { UInt64(Date().timeIntervalSince1970) - } + }, + observationDelay: @escaping @Sendable (UInt32) async throws -> Void = + RadrootsRuntimeObservationBackoff.sleep ) { self.runtimeClient = runtimeClient self.contexts = Self.unique(contexts) self.pageSize = min(max(pageSize, 1), 100) self.now = now + self.observationDelay = observationDelay if let selectedContextID, self.contexts.contains(where: { $0.id == selectedContextID }) { @@ -72,31 +78,21 @@ final class RadrootsTodayStore: ObservableObject { func start() async { guard !isStarted else { return } isStarted = true - observationTask = Task { [weak self, runtimeClient] in - do { - let changes = try await runtimeClient.changes(bufferCapacity: 16) - for await change in changes { - guard !Task.isCancelled else { break } - switch change.kind { - case .today, .drafts, .media, .identity, .profile: - await self?.reload(refreshProjection: false) - case .initial, .settings, .relay, .lifecycle: - continue - } - } - } catch { - // Loading remains available from the durable local projection. - } - self?.observationDidFinish() + observationGeneration &+= 1 + let generation = observationGeneration + observationTask = Task { [weak self] in + await self?.observe(generation: generation) } await reload() } func stop() { isStarted = false + observationGeneration &+= 1 requestGeneration &+= 1 observationTask?.cancel() observationTask = nil + observationState = .stopped reloadTask?.cancel() reloadTask = nil isLoadingNextPage = false @@ -120,9 +116,10 @@ final class RadrootsTodayStore: ObservableObject { let updatedContexts = Self.unique(updatedContexts) guard !updatedContexts.isEmpty else { return } contexts = updatedContexts - selectedContextID = selectedID.flatMap { requested in - updatedContexts.contains(where: { $0.id == requested }) ? requested : nil - } ?? updatedContexts.first?.id + selectedContextID = + selectedID.flatMap { requested in + updatedContexts.contains(where: { $0.id == requested }) ? requested : nil + } ?? updatedContexts.first?.id requestGeneration &+= 1 reloadTask?.cancel() reloadTask = Task { [weak self] in @@ -226,9 +223,49 @@ final class RadrootsTodayStore: ObservableObject { return contexts.filter { identifiers.insert($0.id).inserted } } - private func observationDidFinish() { + private func observe(generation: UInt64) async { + var attempt: UInt32 = 0 + while isStarted, observationGeneration == generation, !Task.isCancelled { + observationState = .subscribing(attempt: attempt &+ 1) + var failureMessage = "Runtime observation ended unexpectedly." + do { + let changes = try await runtimeClient.changes(bufferCapacity: 16) + observationState = .active + for await change in changes { + guard !Task.isCancelled else { break } + attempt = 0 + switch change.kind { + case .today, .drafts, .media, .identity, .profile: + await reload(refreshProjection: false) + case .initial, .settings, .relay, .lifecycle: + continue + } + } + } catch { + failureMessage = + (error as? LocalizedError)?.errorDescription + ?? "Runtime observation is temporarily unavailable." + } + guard isStarted, observationGeneration == generation, !Task.isCancelled else { break } + attempt = attempt == .max ? .max : attempt + 1 + observationState = .retrying(attempt: attempt, message: failureMessage) + do { + try await observationDelay(attempt) + } catch { + break + } + } + observationDidFinish(generation: generation) + } + + private func observationDidFinish(generation: UInt64) { + guard observationGeneration == generation else { return } observationTask = nil isStarted = false + if case .retrying = observationState { + return + } + observationState = .stopped } private static func unique(_ cards: [RadrootsTodayCard]) -> [RadrootsTodayCard] { @@ -237,13 +274,14 @@ final class RadrootsTodayStore: ObservableObject { } private static func failureState(_ error: Error) -> RadrootsTodayLoadState { - let failure: RadrootsRuntimeFailure? = if case let RadrootsRuntimeClientError.today(value) = error { - value - } else if case let RadrootsRuntimeClientError.status(value) = error { - value - } else { - error as? RadrootsRuntimeFailure - } + let failure: RadrootsRuntimeFailure? = + if case let RadrootsRuntimeClientError.today(value) = error { + value + } else if case let RadrootsRuntimeClientError.status(value) = error { + value + } else { + error as? RadrootsRuntimeFailure + } let message = failure?.safeMessage ?? "Today could not be loaded." guard let failure else { return .failed(message: message) } let category = failure.category.lowercased() diff --git a/RadrootsTests/RadrootsRuntimeClientTests.swift b/RadrootsTests/RadrootsRuntimeClientTests.swift @@ -37,7 +37,9 @@ final class RadrootsRuntimeClientTests: XCTestCase { var firstIterator = first.makeAsyncIterator() var secondIterator = second.makeAsyncIterator() - let firstValues = await [firstIterator.next(), firstIterator.next()].compactMap { $0?.generation } + let firstValues = await [firstIterator.next(), firstIterator.next()].compactMap { + $0?.generation + } let secondValues = await [ secondIterator.next(), secondIterator.next(), @@ -62,7 +64,8 @@ final class RadrootsRuntimeClientTests: XCTestCase { let client = RadrootsRuntimeClient(factory: harness.start) _ = try await client.start(configuration: makeConfiguration(generation: "03")) - let results = await withTaskGroup(of: Result<RadrootsRuntimeShutdownReceipt, Error>.self) { group in + let results = await withTaskGroup(of: Result<RadrootsRuntimeShutdownReceipt, Error>.self) { + group in for _ in 0 ..< 32 { group.addTask { do { @@ -139,6 +142,204 @@ final class RadrootsRuntimeClientTests: XCTestCase { _ = try await client.stop() } + func testStartupDeadlineReturnsPromptlyAndClosesLateBackend() async throws { + let harness = RuntimeHarness(startDelayNanoseconds: 60_000_000) + let client = RadrootsRuntimeClient( + factory: harness.start, + deadlines: makeDeadlines(startup: 5_000_000) + ) + + do { + _ = try await client.start(configuration: makeConfiguration(generation: "07")) + XCTFail("A startup that exceeds its deadline must fail") + } catch let RadrootsRuntimeClientError.startup(failure) { + XCTAssertEqual(failure.code, "ios.runtime.deadline_exceeded") + XCTAssertTrue(failure.operationID?.hasSuffix("-startup") == true) + } + + try await Task.sleep(nanoseconds: 80_000_000) + let shutdownCount = await harness.shutdownCount() + XCTAssertEqual(shutdownCount, 1) + } + + func testStopDoesNotWaitForCancellationIgnoringStartup() async throws { + let harness = RuntimeHarness(startDelayNanoseconds: 100_000_000) + let client = RadrootsRuntimeClient(factory: harness.start) + let configuration = makeConfiguration(generation: "08") + let startup = Task { + try await client.start(configuration: configuration) + } + try await Task.sleep(nanoseconds: 2_000_000) + + let clock = ContinuousClock() + let startedAt = clock.now + let receipt = try await client.stop() + + XCTAssertEqual(receipt, .alreadyStopped) + XCTAssertLessThan(startedAt.duration(to: clock.now), .milliseconds(50)) + do { + _ = try await startup.value + XCTFail("The cancelled startup must not claim backend ownership") + } catch { + XCTAssertEqual(error as? RadrootsRuntimeClientError, .superseded) + } + try await Task.sleep(nanoseconds: 120_000_000) + let shutdownCount = await harness.shutdownCount() + XCTAssertEqual(shutdownCount, 1) + } + + func testCancellingOneStartupWaiterDoesNotCancelSharedStartup() async throws { + let harness = RuntimeHarness(startDelayNanoseconds: 40_000_000) + let client = RadrootsRuntimeClient(factory: harness.start) + let configuration = makeConfiguration(generation: "0c") + let cancelledWaiter = Task { + try await client.start(configuration: configuration) + } + let survivingWaiter = Task { + try await client.start(configuration: configuration) + } + try await Task.sleep(nanoseconds: 2_000_000) + cancelledWaiter.cancel() + + do { + _ = try await cancelledWaiter.value + XCTFail("The cancelled waiter must return promptly") + } catch let RadrootsRuntimeClientError.startup(failure) { + XCTAssertEqual(failure.code, "ios.runtime.cancelled") + } + let snapshot = try await survivingWaiter.value + XCTAssertEqual(snapshot.identity.publicKeyHex, configuration.publicKeyHex) + let startCount = await harness.startCount() + XCTAssertEqual(startCount, 1) + _ = try await client.stop() + } + + func testOperationDeadlineAndLateValueCannotEscape() async throws { + let harness = RuntimeHarness(snapshotDelayNanoseconds: 60_000_000) + let client = RadrootsRuntimeClient( + factory: harness.start, + deadlines: makeDeadlines(operation: 5_000_000) + ) + _ = try await client.start(configuration: makeConfiguration(generation: "09")) + + do { + _ = try await client.snapshot() + XCTFail("A runtime operation that exceeds its deadline must fail") + } catch let RadrootsRuntimeClientError.status(failure) { + XCTAssertEqual(failure.code, "ios.runtime.deadline_exceeded") + XCTAssertTrue(failure.operationID?.hasSuffix("-operation") == true) + } + + _ = try await client.stop() + } + + func testSuspendCancelsTrackedOperationWithoutClosingRuntime() async throws { + let harness = RuntimeHarness(snapshotDelayNanoseconds: 60_000_000) + let client = RadrootsRuntimeClient(factory: harness.start) + let configuration = makeConfiguration(generation: "0d") + _ = try await client.start(configuration: configuration) + let pendingSnapshot = Task { + try await client.snapshot() + } + try await Task.sleep(nanoseconds: 2_000_000) + + await client.suspend() + + do { + _ = try await pendingSnapshot.value + XCTFail("Suspension must cancel tracked presentation work") + } catch let RadrootsRuntimeClientError.status(failure) { + XCTAssertEqual(failure.code, "ios.runtime.cancelled") + } + let lifecycle = await client.lifecycle() + XCTAssertEqual(lifecycle, .running(generation: 1)) + try await Task.sleep(nanoseconds: 80_000_000) + let recoveredSnapshot = try await client.snapshot() + XCTAssertEqual(recoveredSnapshot.identity.publicKeyHex, configuration.publicKeyHex) + _ = try await client.stop() + } + + func testShutdownDeadlineQuarantinesBackendUntilLateSuccess() async throws { + let harness = RuntimeHarness(shutdownDelayNanoseconds: 60_000_000) + let client = RadrootsRuntimeClient( + factory: harness.start, + deadlines: makeDeadlines(shutdown: 5_000_000) + ) + _ = try await client.start(configuration: makeConfiguration(generation: "0a")) + + do { + _ = try await client.stop() + XCTFail("A shutdown that exceeds its deadline must fail") + } catch let RadrootsRuntimeClientError.shutdown(failure) { + XCTAssertEqual(failure.code, "ios.runtime.deadline_exceeded") + } + if case .failed = await client.lifecycle() { + // Expected while the detached backend remains quarantined. + } else { + XCTFail("The timed-out backend must remain quarantined") + } + + try await Task.sleep(nanoseconds: 80_000_000) + let finalLifecycle = await client.lifecycle() + let finalReceipt = try await client.stop() + XCTAssertEqual(finalLifecycle, .stopped) + XCTAssertEqual(finalReceipt, .alreadyStopped) + } + + @MainActor + func testObservationFailureIsVisibleAndRecovers() async throws { + let harness = RuntimeHarness(subscriptionFailures: 1) + let client = RadrootsRuntimeClient(factory: harness.start) + let snapshot = try await client.start(configuration: makeConfiguration(generation: "0b")) + let store = RadrootsTodayStore( + runtimeClient: client, + contexts: [.defaultContext(snapshot: snapshot)], + observationDelay: { _ in try await Task.sleep(nanoseconds: 20_000_000) } + ) + + await store.start() + try await waitUntil { + if case .retrying = store.observationState { + return true + } + return false + } + try await waitUntil { store.observationState == .active } + + let subscriptionAttempts = await harness.subscriptionAttemptCount() + XCTAssertGreaterThanOrEqual(subscriptionAttempts, 2) + store.stop() + XCTAssertEqual(store.observationState, .stopped) + _ = try await client.stop() + } + + private func makeDeadlines( + startup: UInt64 = 1_000_000_000, + operation: UInt64 = 1_000_000_000, + subscription: UInt64 = 1_000_000_000, + shutdown: UInt64 = 1_000_000_000 + ) -> RadrootsRuntimeDeadlinePolicy { + RadrootsRuntimeDeadlinePolicy( + startupNanoseconds: startup, + operationNanoseconds: operation, + subscriptionNanoseconds: subscription, + shutdownNanoseconds: shutdown + ) + } + + @MainActor + private func waitUntil( + _ predicate: @escaping @MainActor () -> Bool + ) async throws { + for _ in 0 ..< 200 { + if predicate() { + return + } + try await Task.sleep(nanoseconds: 1_000_000) + } + XCTAssertTrue(predicate()) + } + private func makeConfiguration(generation: String) -> RadrootsRuntimeLaunchConfiguration { RadrootsRuntimeLaunchConfiguration( applicationSupportDirectory: "/tmp/radroots-runtime-tests-\(generation)", @@ -187,17 +388,27 @@ private struct TestRuntimeSigner: RadrootsRuntimeSigner { private actor RuntimeHarness { private let startDelayNanoseconds: UInt64 + private let snapshotDelayNanoseconds: UInt64 + private let shutdownDelayNanoseconds: UInt64 private let shutdownFailure: RadrootsRuntimeFailure? + private var subscriptionFailures: Int private var starts = 0 private var shutdowns = 0 private var cancels = 0 + private var subscriptionAttempts = 0 private var receivers: [UUID: @Sendable (RadrootsRuntimeChange) async -> Void] = [:] init( startDelayNanoseconds: UInt64 = 0, + snapshotDelayNanoseconds: UInt64 = 0, + shutdownDelayNanoseconds: UInt64 = 10_000_000, + subscriptionFailures: Int = 0, shutdownFailure: RadrootsRuntimeFailure? = nil ) { self.startDelayNanoseconds = startDelayNanoseconds + self.snapshotDelayNanoseconds = snapshotDelayNanoseconds + self.shutdownDelayNanoseconds = shutdownDelayNanoseconds + self.subscriptionFailures = subscriptionFailures self.shutdownFailure = shutdownFailure } @@ -206,11 +417,15 @@ private actor RuntimeHarness { ) async throws -> RadrootsRuntimeBackendStart { starts += 1 if startDelayNanoseconds > 0 { - try? await Task.sleep(nanoseconds: startDelayNanoseconds) + await Task.detached { [startDelayNanoseconds] in + try? await Task.sleep(nanoseconds: startDelayNanoseconds) + }.value } let backend = TestRuntimeBackend( harness: self, publicKeyHex: configuration.publicKeyHex, + snapshotDelayNanoseconds: snapshotDelayNanoseconds, + shutdownDelayNanoseconds: shutdownDelayNanoseconds, shutdownFailure: shutdownFailure ) return await RadrootsRuntimeBackendStart( @@ -221,7 +436,16 @@ private actor RuntimeHarness { func addReceiver( _ receive: @escaping @Sendable (RadrootsRuntimeChange) async -> Void - ) -> UUID { + ) throws -> UUID { + subscriptionAttempts += 1 + if subscriptionFailures > 0 { + subscriptionFailures -= 1 + throw RadrootsRuntimeFailure.local( + operation: "test.subscribe", + code: "test.subscribe_retryable", + safeMessage: "The test subscription is temporarily unavailable." + ) + } let id = UUID() receivers[id] = receive return id @@ -254,20 +478,30 @@ private actor RuntimeHarness { func cancelCount() -> Int { cancels } + + func subscriptionAttemptCount() -> Int { + subscriptionAttempts + } } private actor TestRuntimeBackend: RadrootsRuntimeBackend { private let harness: RuntimeHarness private let snapshotValueStored: RadrootsRuntimeSnapshot + private let snapshotDelayNanoseconds: UInt64 + private let shutdownDelayNanoseconds: UInt64 private let shutdownFailure: RadrootsRuntimeFailure? private var closed = false init( harness: RuntimeHarness, publicKeyHex: String, + snapshotDelayNanoseconds: UInt64, + shutdownDelayNanoseconds: UInt64, shutdownFailure: RadrootsRuntimeFailure? ) { self.harness = harness + self.snapshotDelayNanoseconds = snapshotDelayNanoseconds + self.shutdownDelayNanoseconds = shutdownDelayNanoseconds self.shutdownFailure = shutdownFailure snapshotValueStored = RadrootsRuntimeSnapshot( identity: RadrootsRuntimeIdentity( @@ -287,7 +521,12 @@ private actor TestRuntimeBackend: RadrootsRuntimeBackend { snapshotValueStored } - func snapshot() throws -> RadrootsRuntimeSnapshot { + func snapshot() async throws -> RadrootsRuntimeSnapshot { + if snapshotDelayNanoseconds > 0 { + await Task.detached { [snapshotDelayNanoseconds] in + try? await Task.sleep(nanoseconds: snapshotDelayNanoseconds) + }.value + } if closed { throw RadrootsRuntimeFailure.local( operation: "test.snapshot", @@ -321,14 +560,16 @@ private actor TestRuntimeBackend: RadrootsRuntimeBackend { func subscribe( bufferCapacity _: Int, receive: @escaping @Sendable (RadrootsRuntimeChange) async -> Void - ) async -> any RadrootsRuntimeSubscriptionToken { - let id = await harness.addReceiver(receive) + ) async throws -> any RadrootsRuntimeSubscriptionToken { + let id = try await harness.addReceiver(receive) return TestSubscriptionToken(harness: harness, id: id) } func shutdown() async throws -> RadrootsRuntimeShutdownReceipt { await harness.recordShutdown() - try? await Task.sleep(nanoseconds: 10_000_000) + await Task.detached { [shutdownDelayNanoseconds] in + try? await Task.sleep(nanoseconds: shutdownDelayNanoseconds) + }.value if let shutdownFailure { throw shutdownFailure } diff --git a/api/RadrootsApp.symbols.json b/api/RadrootsApp.symbols.json @@ -1 +1 @@ -{"module":{"name":"RadrootsApp","platform":{"architecture":"arm64","environment":"simulator","operatingSystem":{"minimumVersion":{"major":18,"minor":0},"name":"ios"},"vendor":"apple"}},"relationships":[{"kind":"conformsTo","source":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate","target":"c:objc(pl)NSObject"},{"kind":"conformsTo","source":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate","target":"c:objc(pl)UIApplicationDelegate"},{"kind":"conformsTo","source":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate","target":"s:SH"},{"kind":"conformsTo","source":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate","target":"s:SQ"},{"kind":"conformsTo","source":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate","target":"s:s16SendableMetatypeP"},{"kind":"conformsTo","source":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate","target":"s:s23CustomStringConvertibleP"},{"kind":"conformsTo","source":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate","target":"s:s28CustomDebugStringConvertibleP"},{"kind":"conformsTo","source":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate","target":"s:s7CVarArgP"},{"kind":"conformsTo","source":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate","target":"s:s8SendableP"},{"kind":"conformsTo","source":"s:11RadrootsApp0aB4ViewV","target":"s:7SwiftUI4ViewP"},{"kind":"conformsTo","source":"s:11RadrootsApp0aB4ViewV","target":"s:s16SendableMetatypeP"},{"kind":"conformsTo","source":"s:11RadrootsApp0aB4ViewV","target":"s:s8SendableP"},{"kind":"conformsTo","source":"s:11RadrootsApp0aB7ReleaseO","target":"s:s16SendableMetatypeP"},{"kind":"conformsTo","source":"s:11RadrootsApp0aB7ReleaseO","target":"s:s8SendableP"},{"kind":"inheritsFrom","source":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate","target":"c:objc(cs)NSObject"},{"kind":"memberOf","source":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate(im)application:didFinishLaunchingWithOptions:","target":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate"},{"kind":"memberOf","source":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate(im)application:handleEventsForBackgroundURLSession:completionHandler:","target":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate"},{"kind":"memberOf","source":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate(im)applicationWillTerminate:","target":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate"},{"kind":"memberOf","source":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate(im)init","target":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate"},{"kind":"memberOf","source":"s:11RadrootsApp0aB4ViewV4bodyQrvp","target":"s:11RadrootsApp0aB4ViewV"},{"kind":"memberOf","source":"s:11RadrootsApp0aB4ViewVACycfc","target":"s:11RadrootsApp0aB4ViewV"},{"kind":"memberOf","source":"s:11RadrootsApp0aB7ReleaseO7versionSSvpZ","target":"s:11RadrootsApp0aB7ReleaseO"},{"kind":"overrides","source":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate(im)init","target":"c:objc(cs)NSObject(im)init"}],"schema":"radroots.swift-api-snapshot.v1","symbols":[{"access":"public","declaration":[{"kind":"attribute","spelling":"@"},{"kind":"attribute","preciseIdentifier":"s:ScM","spelling":"MainActor"},{"kind":"text","spelling":" "},{"kind":"keyword","spelling":"final"},{"kind":"text","spelling":" "},{"kind":"keyword","spelling":"class"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"RadrootsAppDelegate"}],"kind":"swift.class","path":["RadrootsAppDelegate"],"precise":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate"},{"access":"public","declaration":[{"kind":"attribute","spelling":"@"},{"kind":"attribute","preciseIdentifier":"s:ScM","spelling":"MainActor"},{"kind":"text","spelling":" "},{"kind":"keyword","spelling":"func"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"application"},{"kind":"text","spelling":"("},{"kind":"externalParam","spelling":"_"},{"kind":"text","spelling":" "},{"kind":"internalParam","spelling":"application"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","preciseIdentifier":"c:objc(cs)UIApplication","spelling":"UIApplication"},{"kind":"text","spelling":", "},{"kind":"externalParam","spelling":"didFinishLaunchingWithOptions"},{"kind":"text","spelling":" "},{"kind":"internalParam","spelling":"_"},{"kind":"text","spelling":": ["},{"kind":"typeIdentifier","preciseIdentifier":"c:objc(cs)UIApplication","spelling":"UIApplication"},{"kind":"text","spelling":"."},{"kind":"typeIdentifier","preciseIdentifier":"c:@T@UIApplicationLaunchOptionsKey","spelling":"LaunchOptionsKey"},{"kind":"text","spelling":" : "},{"kind":"keyword","spelling":"Any"},{"kind":"text","spelling":"]? = nil) -> "},{"kind":"typeIdentifier","preciseIdentifier":"s:Sb","spelling":"Bool"}],"kind":"swift.method","path":["RadrootsAppDelegate","application(_:didFinishLaunchingWithOptions:)"],"precise":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate(im)application:didFinishLaunchingWithOptions:"},{"access":"public","declaration":[{"kind":"attribute","spelling":"@"},{"kind":"attribute","preciseIdentifier":"s:ScM","spelling":"MainActor"},{"kind":"text","spelling":" "},{"kind":"keyword","spelling":"func"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"application"},{"kind":"text","spelling":"("},{"kind":"externalParam","spelling":"_"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","preciseIdentifier":"c:objc(cs)UIApplication","spelling":"UIApplication"},{"kind":"text","spelling":", "},{"kind":"externalParam","spelling":"handleEventsForBackgroundURLSession"},{"kind":"text","spelling":" "},{"kind":"internalParam","spelling":"identifier"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","preciseIdentifier":"s:SS","spelling":"String"},{"kind":"text","spelling":", "},{"kind":"externalParam","spelling":"completionHandler"},{"kind":"text","spelling":": "},{"kind":"attribute","spelling":"@escaping "},{"kind":"text","spelling":"() -> "},{"kind":"typeIdentifier","preciseIdentifier":"s:s4Voida","spelling":"Void"},{"kind":"text","spelling":")"}],"kind":"swift.method","path":["RadrootsAppDelegate","application(_:handleEventsForBackgroundURLSession:completionHandler:)"],"precise":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate(im)application:handleEventsForBackgroundURLSession:completionHandler:"},{"access":"public","declaration":[{"kind":"attribute","spelling":"@"},{"kind":"attribute","preciseIdentifier":"s:ScM","spelling":"MainActor"},{"kind":"text","spelling":" "},{"kind":"keyword","spelling":"func"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"applicationWillTerminate"},{"kind":"text","spelling":"("},{"kind":"externalParam","spelling":"_"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","preciseIdentifier":"c:objc(cs)UIApplication","spelling":"UIApplication"},{"kind":"text","spelling":")"}],"kind":"swift.method","path":["RadrootsAppDelegate","applicationWillTerminate(_:)"],"precise":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate(im)applicationWillTerminate:"},{"access":"public","declaration":[{"kind":"attribute","spelling":"@"},{"kind":"attribute","preciseIdentifier":"s:ScM","spelling":"MainActor"},{"kind":"text","spelling":" "},{"kind":"keyword","spelling":"override"},{"kind":"text","spelling":" "},{"kind":"keyword","spelling":"init"},{"kind":"text","spelling":"()"}],"kind":"swift.init","path":["RadrootsAppDelegate","init()"],"precise":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate(im)init"},{"access":"public","declaration":[{"kind":"attribute","spelling":"@"},{"kind":"attribute","preciseIdentifier":"s:ScM","spelling":"MainActor"},{"kind":"text","spelling":" "},{"kind":"keyword","spelling":"struct"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"RadrootsAppView"}],"kind":"swift.struct","path":["RadrootsAppView"],"precise":"s:11RadrootsApp0aB4ViewV"},{"access":"public","declaration":[{"kind":"attribute","spelling":"@"},{"kind":"attribute","preciseIdentifier":"s:ScM","spelling":"MainActor"},{"kind":"text","spelling":" "},{"kind":"keyword","spelling":"var"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"body"},{"kind":"text","spelling":": "},{"kind":"keyword","spelling":"some"},{"kind":"text","spelling":" "},{"kind":"typeIdentifier","preciseIdentifier":"s:7SwiftUI4ViewP","spelling":"View"},{"kind":"text","spelling":" { "},{"kind":"keyword","spelling":"get"},{"kind":"text","spelling":" }"}],"kind":"swift.property","path":["RadrootsAppView","body"],"precise":"s:11RadrootsApp0aB4ViewV4bodyQrvp"},{"access":"public","declaration":[{"kind":"attribute","spelling":"@"},{"kind":"attribute","preciseIdentifier":"s:ScM","spelling":"MainActor"},{"kind":"text","spelling":" "},{"kind":"keyword","spelling":"init"},{"kind":"text","spelling":"()"}],"kind":"swift.init","path":["RadrootsAppView","init()"],"precise":"s:11RadrootsApp0aB4ViewVACycfc"},{"access":"public","declaration":[{"kind":"keyword","spelling":"enum"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"RadrootsAppRelease"}],"kind":"swift.enum","path":["RadrootsAppRelease"],"precise":"s:11RadrootsApp0aB7ReleaseO"},{"access":"public","declaration":[{"kind":"keyword","spelling":"static"},{"kind":"text","spelling":" "},{"kind":"keyword","spelling":"let"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"version"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","preciseIdentifier":"s:SS","spelling":"String"}],"kind":"swift.type.property","path":["RadrootsAppRelease","version"],"precise":"s:11RadrootsApp0aB7ReleaseO7versionSSvpZ"}]} +{"module":{"name":"RadrootsApp","platform":{"architecture":"arm64","environment":"simulator","operatingSystem":{"minimumVersion":{"major":18,"minor":0},"name":"ios"},"vendor":"apple"}},"relationships":[{"kind":"conformsTo","source":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate","target":"c:objc(pl)NSObject"},{"kind":"conformsTo","source":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate","target":"c:objc(pl)UIApplicationDelegate"},{"kind":"conformsTo","source":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate","target":"s:SH"},{"kind":"conformsTo","source":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate","target":"s:SQ"},{"kind":"conformsTo","source":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate","target":"s:s16SendableMetatypeP"},{"kind":"conformsTo","source":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate","target":"s:s23CustomStringConvertibleP"},{"kind":"conformsTo","source":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate","target":"s:s28CustomDebugStringConvertibleP"},{"kind":"conformsTo","source":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate","target":"s:s7CVarArgP"},{"kind":"conformsTo","source":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate","target":"s:s8SendableP"},{"kind":"conformsTo","source":"s:11RadrootsApp0aB4ViewV","target":"s:7SwiftUI4ViewP"},{"kind":"conformsTo","source":"s:11RadrootsApp0aB4ViewV","target":"s:s16SendableMetatypeP"},{"kind":"conformsTo","source":"s:11RadrootsApp0aB4ViewV","target":"s:s8SendableP"},{"kind":"conformsTo","source":"s:11RadrootsApp0aB7ReleaseO","target":"s:s16SendableMetatypeP"},{"kind":"conformsTo","source":"s:11RadrootsApp0aB7ReleaseO","target":"s:s8SendableP"},{"kind":"inheritsFrom","source":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate","target":"c:objc(cs)NSObject"},{"kind":"memberOf","source":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate(im)application:didFinishLaunchingWithOptions:","target":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate"},{"kind":"memberOf","source":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate(im)application:handleEventsForBackgroundURLSession:completionHandler:","target":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate"},{"kind":"memberOf","source":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate(im)init","target":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate"},{"kind":"memberOf","source":"s:11RadrootsApp0aB4ViewV4bodyQrvp","target":"s:11RadrootsApp0aB4ViewV"},{"kind":"memberOf","source":"s:11RadrootsApp0aB4ViewVACycfc","target":"s:11RadrootsApp0aB4ViewV"},{"kind":"memberOf","source":"s:11RadrootsApp0aB7ReleaseO7versionSSvpZ","target":"s:11RadrootsApp0aB7ReleaseO"},{"kind":"overrides","source":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate(im)init","target":"c:objc(cs)NSObject(im)init"}],"schema":"radroots.swift-api-snapshot.v1","symbols":[{"access":"public","declaration":[{"kind":"attribute","spelling":"@"},{"kind":"attribute","preciseIdentifier":"s:ScM","spelling":"MainActor"},{"kind":"text","spelling":" "},{"kind":"keyword","spelling":"final"},{"kind":"text","spelling":" "},{"kind":"keyword","spelling":"class"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"RadrootsAppDelegate"}],"kind":"swift.class","path":["RadrootsAppDelegate"],"precise":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate"},{"access":"public","declaration":[{"kind":"attribute","spelling":"@"},{"kind":"attribute","preciseIdentifier":"s:ScM","spelling":"MainActor"},{"kind":"text","spelling":" "},{"kind":"keyword","spelling":"func"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"application"},{"kind":"text","spelling":"("},{"kind":"externalParam","spelling":"_"},{"kind":"text","spelling":" "},{"kind":"internalParam","spelling":"application"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","preciseIdentifier":"c:objc(cs)UIApplication","spelling":"UIApplication"},{"kind":"text","spelling":", "},{"kind":"externalParam","spelling":"didFinishLaunchingWithOptions"},{"kind":"text","spelling":" "},{"kind":"internalParam","spelling":"_"},{"kind":"text","spelling":": ["},{"kind":"typeIdentifier","preciseIdentifier":"c:objc(cs)UIApplication","spelling":"UIApplication"},{"kind":"text","spelling":"."},{"kind":"typeIdentifier","preciseIdentifier":"c:@T@UIApplicationLaunchOptionsKey","spelling":"LaunchOptionsKey"},{"kind":"text","spelling":" : "},{"kind":"keyword","spelling":"Any"},{"kind":"text","spelling":"]? = nil) -> "},{"kind":"typeIdentifier","preciseIdentifier":"s:Sb","spelling":"Bool"}],"kind":"swift.method","path":["RadrootsAppDelegate","application(_:didFinishLaunchingWithOptions:)"],"precise":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate(im)application:didFinishLaunchingWithOptions:"},{"access":"public","declaration":[{"kind":"attribute","spelling":"@"},{"kind":"attribute","preciseIdentifier":"s:ScM","spelling":"MainActor"},{"kind":"text","spelling":" "},{"kind":"keyword","spelling":"func"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"application"},{"kind":"text","spelling":"("},{"kind":"externalParam","spelling":"_"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","preciseIdentifier":"c:objc(cs)UIApplication","spelling":"UIApplication"},{"kind":"text","spelling":", "},{"kind":"externalParam","spelling":"handleEventsForBackgroundURLSession"},{"kind":"text","spelling":" "},{"kind":"internalParam","spelling":"identifier"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","preciseIdentifier":"s:SS","spelling":"String"},{"kind":"text","spelling":", "},{"kind":"externalParam","spelling":"completionHandler"},{"kind":"text","spelling":": "},{"kind":"attribute","spelling":"@escaping "},{"kind":"text","spelling":"() -> "},{"kind":"typeIdentifier","preciseIdentifier":"s:s4Voida","spelling":"Void"},{"kind":"text","spelling":")"}],"kind":"swift.method","path":["RadrootsAppDelegate","application(_:handleEventsForBackgroundURLSession:completionHandler:)"],"precise":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate(im)application:handleEventsForBackgroundURLSession:completionHandler:"},{"access":"public","declaration":[{"kind":"attribute","spelling":"@"},{"kind":"attribute","preciseIdentifier":"s:ScM","spelling":"MainActor"},{"kind":"text","spelling":" "},{"kind":"keyword","spelling":"override"},{"kind":"text","spelling":" "},{"kind":"keyword","spelling":"init"},{"kind":"text","spelling":"()"}],"kind":"swift.init","path":["RadrootsAppDelegate","init()"],"precise":"c:@M@RadrootsApp@objc(cs)RadrootsAppDelegate(im)init"},{"access":"public","declaration":[{"kind":"attribute","spelling":"@"},{"kind":"attribute","preciseIdentifier":"s:ScM","spelling":"MainActor"},{"kind":"text","spelling":" "},{"kind":"keyword","spelling":"struct"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"RadrootsAppView"}],"kind":"swift.struct","path":["RadrootsAppView"],"precise":"s:11RadrootsApp0aB4ViewV"},{"access":"public","declaration":[{"kind":"attribute","spelling":"@"},{"kind":"attribute","preciseIdentifier":"s:ScM","spelling":"MainActor"},{"kind":"text","spelling":" "},{"kind":"keyword","spelling":"var"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"body"},{"kind":"text","spelling":": "},{"kind":"keyword","spelling":"some"},{"kind":"text","spelling":" "},{"kind":"typeIdentifier","preciseIdentifier":"s:7SwiftUI4ViewP","spelling":"View"},{"kind":"text","spelling":" { "},{"kind":"keyword","spelling":"get"},{"kind":"text","spelling":" }"}],"kind":"swift.property","path":["RadrootsAppView","body"],"precise":"s:11RadrootsApp0aB4ViewV4bodyQrvp"},{"access":"public","declaration":[{"kind":"attribute","spelling":"@"},{"kind":"attribute","preciseIdentifier":"s:ScM","spelling":"MainActor"},{"kind":"text","spelling":" "},{"kind":"keyword","spelling":"init"},{"kind":"text","spelling":"()"}],"kind":"swift.init","path":["RadrootsAppView","init()"],"precise":"s:11RadrootsApp0aB4ViewVACycfc"},{"access":"public","declaration":[{"kind":"keyword","spelling":"enum"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"RadrootsAppRelease"}],"kind":"swift.enum","path":["RadrootsAppRelease"],"precise":"s:11RadrootsApp0aB7ReleaseO"},{"access":"public","declaration":[{"kind":"keyword","spelling":"static"},{"kind":"text","spelling":" "},{"kind":"keyword","spelling":"let"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"version"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","preciseIdentifier":"s:SS","spelling":"String"}],"kind":"swift.type.property","path":["RadrootsAppRelease","version"],"precise":"s:11RadrootsApp0aB7ReleaseO7versionSSvpZ"}]}