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:
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"}]}