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 f04ab0620cb3c5a2a09ac4c34885602d14c23c79
parent 8b5fc11468326046cbc537dda25eda6be69f1701
Author: triesap <tyson@radroots.org>
Date:   Wed,  9 Sep 2026 09:22:08 +0000

runtime: make shutdown resumable and fail closed

- Close runtime admission before draining work through its existing owner.
- Retain native shutdown and failed cleanup across cancelled caller waits.
- Preserve terminal storage failures and exact shared error contracts.
- Verify callback drainage, repeated close, and original failure regressions.

Diffstat:
MPackage.swift | 2++
MTera.xcodeproj/project.pbxproj | 4++++
MTera/App/TeraAppModel.swift | 37++++++++++++++++++++++---------------
ATera/Runtime/TeraLifecycleBridge.swift | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTera/Runtime/TeraLifecycleCoordinator.swift | 35-----------------------------------
MTera/Runtime/TeraRuntimeBoundedTask.swift | 46+++++++++++++++++++++++++++++++++++++++++++---
MTera/Runtime/TeraRuntimeClient.swift | 190+++++++++++++++++++++++++++++++++----------------------------------------------
MTera/Runtime/TeraRuntimeResourceCreation.swift | 2+-
MTera/Runtime/TeraRuntimeResourceTask.swift | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
ATera/Runtime/TeraRuntimeShutdownTask.swift | 45+++++++++++++++++++++++++++++++++++++++++++++
MTeraFFI/provenance.json | 54+++++++++++++++++++++++++++---------------------------
MTeraFFI/source.lock | 4++--
MTeraFFI/source/aarch64-apple-darwin.json | 86++++++++++++++++++++++++++++++++++++++++++-------------------------------------
MTeraFFI/source/aarch64-apple-ios-sim.json | 86++++++++++++++++++++++++++++++++++++++++++-------------------------------------
MTeraFFI/source/aarch64-apple-ios.json | 86++++++++++++++++++++++++++++++++++++++++++-------------------------------------
MTeraTests/TeraLifecycleTests.swift | 2+-
MTeraTests/TeraRuntimeBoundedTaskTests.swift | 18+++++++++++++++++-
MTeraTests/TeraRuntimeClientTests.swift | 8+++-----
MTeraTests/TeraRuntimeResourceFixtures.swift | 24++++++++++++++++++++++--
MTeraTests/TeraRuntimeResourceLifecycleTests.swift | 7+++++--
ATeraTests/TeraRuntimeShutdownTests.swift | 185+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_core/src/runtime/lifecycle.rs | 229+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/tera_core/src/runtime/mod.rs | 33++++++++++++++++++++++++++++-----
Mcore/crates/tera_core/src/runtime/product_surface/outbox.rs | 33+++++++++++++++++++++++++++++++++
Mcore/crates/tera_core/src/runtime/product_surface/outbox/mutation_admission_tests.rs | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/tera_core/src/runtime/product_surface/settings.rs | 6++++++
Mcore/crates/tera_core/src/runtime/product_surface/today.rs | 18++++++++++++++++++
Mcore/crates/tera_core/src/runtime/sdk.rs | 12++++++++++++
Mcore/crates/tera_ffi/Cargo.toml | 1+
Mcore/crates/tera_ffi/src/error.rs | 7+++++++
Mcore/crates/tera_ffi/src/runtime.rs | 5++---
Mcore/crates/tera_ffi/src/subscription.rs | 168++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mcore/crates/tera_ffi/tests/recovery_contract.rs | 28++++++++++++++++++++++++++++
Mcore/crates/tera_ffi/tests/runtime_lifecycle.rs | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mproject.yml | 1+
Mrelease/provenance.json | 6+++---
Mtest-fixtures/legacy-identifiers.v1.json | 8++++++++
Mtest-fixtures/swiftlint-maintainability-baseline.v1.json | 4++--
38 files changed, 1399 insertions(+), 373 deletions(-)

diff --git a/Package.swift b/Package.swift @@ -59,11 +59,13 @@ let package = Package( "Runtime/TeraErrorRecovery.swift", "Runtime/TeraGeneratedRuntimeBackend.swift", "Runtime/TeraLifecycleCoordinator.swift", + "Runtime/TeraLifecycleBridge.swift", "Runtime/TeraRuntimeBoundedTask.swift", "Runtime/TeraRuntimeClient.swift", "Runtime/TeraRuntimeModels.swift", "Runtime/TeraRuntimeResourceCreation.swift", "Runtime/TeraRuntimeResourceTask.swift", + "Runtime/TeraRuntimeShutdownTask.swift", "Runtime/TeraSessionGeneration.swift", "Runtime/TeraUserMessageClassifier.swift", "Runtime/TeraUserMessages.swift", diff --git a/Tera.xcodeproj/project.pbxproj b/Tera.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 2C6DDB502DFA11C2CBCA49C4 /* TeraApp in Frameworks */ = {isa = PBXBuildFile; productRef = A1979630FE6BC343F0E59F24 /* TeraApp */; }; 31CBA09815D972CCE187009F /* TeraSessionResourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4383B870D20178E633610113 /* TeraSessionResourceTests.swift */; }; 38EF7FA8874E82B28110A232 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 6967B3EAC4CF1A410B26DD1A /* PrivacyInfo.xcprivacy */; }; + 39B47EE698E9D7CC716A372F /* TeraRuntimeShutdownTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91E82E561F73A1BE6BBF09B7 /* TeraRuntimeShutdownTests.swift */; }; 4734A842C366608EC1BD31C5 /* TeraUserMessagesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46DDEE781FDD92A292FD847B /* TeraUserMessagesTests.swift */; }; 64D49ADFD3FDD54CBA509DC5 /* TeraTodayStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 794D475C70BA8ED994D1CA66 /* TeraTodayStoreTests.swift */; }; 671EF50DB4EFBC25AABF26DC /* TeraLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C890BDE885536BC2AB215393 /* TeraLifecycleTests.swift */; }; @@ -80,6 +81,7 @@ 794D475C70BA8ED994D1CA66 /* TeraTodayStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraTodayStoreTests.swift; sourceTree = "<group>"; }; 7C0836F5D5F71F9A54795C57 /* TeraSessionGenerationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraSessionGenerationTests.swift; sourceTree = "<group>"; }; 88ED360C84532DA7088BFC6E /* TeraRuntimeClientFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRuntimeClientFixtures.swift; sourceTree = "<group>"; }; + 91E82E561F73A1BE6BBF09B7 /* TeraRuntimeShutdownTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRuntimeShutdownTests.swift; sourceTree = "<group>"; }; 93A3358AFC23C567DB9CB02F /* TeraErrorRecoveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraErrorRecoveryTests.swift; sourceTree = "<group>"; }; 98E969BCFF7CED33A1D4A040 /* TeraStateMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraStateMigrationTests.swift; sourceTree = "<group>"; }; 999BDCEF144A19F35D048D15 /* TeraTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = TeraTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -174,6 +176,7 @@ F0A51F9AF2E0803264E07FBB /* TeraRuntimeResourceFixtures.swift */, 226F5C2370522AAFAD9D1C3D /* TeraRuntimeResourceLifecycleTests.swift */, C4D01C023E258AC3B3B1C27D /* TeraRuntimeResourceTaskTests.swift */, + 91E82E561F73A1BE6BBF09B7 /* TeraRuntimeShutdownTests.swift */, 7C0836F5D5F71F9A54795C57 /* TeraSessionGenerationTests.swift */, 4383B870D20178E633610113 /* TeraSessionResourceTests.swift */, 98E969BCFF7CED33A1D4A040 /* TeraStateMigrationTests.swift */, @@ -404,6 +407,7 @@ 6B0BE8E92892EF60B8E996A5 /* TeraRuntimeResourceFixtures.swift in Sources */, 9B37084901EC52EB44969304 /* TeraRuntimeResourceLifecycleTests.swift in Sources */, F8C87782857DC930D47E6A90 /* TeraRuntimeResourceTaskTests.swift in Sources */, + 39B47EE698E9D7CC716A372F /* TeraRuntimeShutdownTests.swift in Sources */, 79D06C6BF2B55C0C4104FB91 /* TeraSessionGenerationTests.swift in Sources */, 31CBA09815D972CCE187009F /* TeraSessionResourceTests.swift in Sources */, 852FF7514FF7F682A246FB47 /* TeraStateMigrationTests.swift in Sources */, diff --git a/Tera/App/TeraAppModel.swift b/Tera/App/TeraAppModel.swift @@ -223,12 +223,6 @@ final class TeraAppModel: ObservableObject { } } - func shutdown() async { - await lifecycleCoordinator.record("ios.lifecycle.shutdown_requested", level: .notice) - await stop() - await TeraBackgroundEventRouter.shared.detachAndCompletePending() - } - private func run( name: String, showsStarting: Bool = false, @@ -282,15 +276,6 @@ final class TeraAppModel: ObservableObject { await resume() } - private func ensureLifecycleRegistration() async { - guard !lifecycleRegistered else { return } - lifecycleRegistered = true - await lifecycleCoordinator.attachBackgroundEvents() - await TeraLifecycleBridge.shared.register { @Sendable [weak self] in - await self?.shutdown() - } - } - private func stopPresentationWork() { productStores?.stop() } @@ -356,3 +341,25 @@ final class TeraAppModel: ObservableObject { ) } #endif + +extension TeraAppModel { + @discardableResult + func shutdown() async -> Bool { + await lifecycleCoordinator.record("ios.lifecycle.shutdown_requested", level: .notice) + await stop() + await TeraBackgroundEventRouter.shared.detachAndCompletePending() + if case .stopped = phase { + return true + } + return isShellUITest + } + + private func ensureLifecycleRegistration() async { + guard !lifecycleRegistered else { return } + lifecycleRegistered = true + await lifecycleCoordinator.attachBackgroundEvents() + await TeraLifecycleBridge.shared.register { @Sendable [weak self] in + await self?.shutdown() ?? true + } + } +} diff --git a/Tera/Runtime/TeraLifecycleBridge.swift b/Tera/Runtime/TeraLifecycleBridge.swift @@ -0,0 +1,61 @@ +import Foundation + +final class TeraCompletionOnce: @unchecked Sendable { + private let lock = NSLock() + private var completion: (() -> Void)? + + init(_ completion: @escaping () -> Void) { + self.completion = completion + } + + func complete() { + let action = lock.withLock { + let action = completion + completion = nil + return action + } + action?() + } +} + +actor TeraLifecycleBridge { + static let shared = TeraLifecycleBridge() + + private var registration = UUID() + private var shutdown: (@Sendable () async -> Bool)? + private struct Attempt { + let id: UUID + let registration: UUID + let task: Task<Bool, Never> + } + + private var active: Attempt? + + func register(shutdown: @escaping @Sendable () async -> Bool) { + registration = UUID() + self.shutdown = shutdown + } + + func requestShutdown() async { + let id = registration + let attempt: Attempt + if let active, active.registration == id { + attempt = active + } else if let shutdown { + attempt = Attempt(id: UUID(), registration: id, task: Task { await shutdown() }) + active = attempt + } else { + return + } + let completed = await attempt.task.value + if active?.id == attempt.id { + active = nil + } + if registration == id, completed { + shutdown = nil + } + if registration == id { + await TeraBackgroundEventRouter.shared.detachAndCompletePending() + } + } +} diff --git a/Tera/Runtime/TeraLifecycleCoordinator.swift b/Tera/Runtime/TeraLifecycleCoordinator.swift @@ -2,24 +2,6 @@ import Combine import Foundation import RadrootsKit -final class TeraCompletionOnce: @unchecked Sendable { - private let lock = NSLock() - private var completion: (() -> Void)? - - init(_ completion: @escaping () -> Void) { - self.completion = completion - } - - func complete() { - let action = lock.withLock { - let action = completion - completion = nil - return action - } - action?() - } -} - actor TeraBackgroundEventRouter { typealias Handler = @Sendable ( @@ -113,23 +95,6 @@ actor TeraBackgroundEventRouter { } } -actor TeraLifecycleBridge { - static let shared = TeraLifecycleBridge() - - private var shutdown: (@Sendable () async -> Void)? - - func register(shutdown: @escaping @Sendable () async -> Void) { - self.shutdown = shutdown - } - - func requestShutdown() async { - let action = shutdown - shutdown = nil - await action?() - await TeraBackgroundEventRouter.shared.detachAndCompletePending() - } -} - struct TeraDiagnosticRecord: Codable, Sendable, Equatable { let name: String let category: String diff --git a/Tera/Runtime/TeraRuntimeBoundedTask.swift b/Tera/Runtime/TeraRuntimeBoundedTask.swift @@ -18,6 +18,8 @@ final class TeraRuntimeBoundedTask<Value: Sendable>: @unchecked Sendable { private var cancelledWaiters: Set<UUID> = [] private var operationTask: Task<Void, Never>? private var timeoutTask: Task<Void, Never>? + private var settledResult: Result<Value, TeraRuntimeFailure>? + private var settlementWaiters: [CheckedContinuation<Result<Value, TeraRuntimeFailure>, Never>] = [] func install(operationTask: Task<Void, Never>, timeoutTask: Task<Void, Never>) { let terminal = lock.withLock { () -> Outcome? in @@ -78,8 +80,36 @@ final class TeraRuntimeBoundedTask<Value: Sendable>: @unchecked Sendable { taskToCancel?.cancel() } - func finishOperation() { - lock.withLock { operationTask = nil } + func finishOperation(_ result: Result<Value, TeraRuntimeFailure>) { + let waiters = lock.withLock { + operationTask = nil + settledResult = result + let pending = settlementWaiters + settlementWaiters.removeAll() + return pending + } + for waiter in waiters { + waiter.resume(returning: result) + } + } + + func settlement() -> Result<Value, TeraRuntimeFailure>? { + lock.withLock { settledResult } + } + + func settle() async -> Result<Value, TeraRuntimeFailure> { + await withCheckedContinuation { continuation in + let immediate = lock.withLock { () -> Result<Value, TeraRuntimeFailure>? in + if let settledResult { + return settledResult + } + settlementWaiters.append(continuation) + return nil + } + if let immediate { + continuation.resume(returning: immediate) + } + } } private func cancelWaiter(_ waiterID: UUID) { @@ -134,7 +164,7 @@ final class TeraRuntimeBoundedTask<Value: Sendable>: @unchecked Sendable { if !state.resolve(.completed(result)) { await onAbandonedResult(result) } - state.finishOperation() + state.finishOperation(result) } let timeoutTask = Task { [state] in @@ -157,4 +187,14 @@ final class TeraRuntimeBoundedTask<Value: Sendable>: @unchecked Sendable { func cancel() { state.cancel() } + + /// Only the shutdown owner awaits actual completion. A caller deadline or + /// cancellation resolves `value`, but cannot prove that the operation ended. + func settle() async -> Result<Value, TeraRuntimeFailure> { + await state.settle() + } + + func settlement() -> Result<Value, TeraRuntimeFailure>? { + state.settlement() + } } diff --git a/Tera/Runtime/TeraRuntimeClient.swift b/Tera/Runtime/TeraRuntimeClient.swift @@ -298,13 +298,14 @@ actor TeraRuntimeClient { private struct ShutdownOperation: Sendable { let identity: TeraRuntimeOperationIdentity - let backend: (any TeraRuntimeBackend)? - let task: TeraRuntimeBoundedTask<TeraRuntimeShutdownReceipt> + let work: TeraRuntimeShutdownWork + let task: TeraRuntimeShutdownTask } private struct ActiveOperation: Sendable { let identity: TeraRuntimeOperationIdentity let cancel: @Sendable () -> Void + let drain: @Sendable () async throws -> Void } private struct Subscription { @@ -320,12 +321,12 @@ actor TeraRuntimeClient { private var lifecycleState: TeraRuntimeLifecycle = .stopped private var configuration: TeraRuntimeLaunchConfiguration? private var backend: (any TeraRuntimeBackend)? - private var quarantinedBackend: (any TeraRuntimeBackend)? + private var retryShutdownWork: TeraRuntimeShutdownWork? + private var abandonedStartups: [UInt64: TeraRuntimeResourceTask<TeraRuntimeBackendStart>] = [:] private var startupOperation: StartupOperation? private var shutdownOperation: ShutdownOperation? private var activeOperations: [UInt64: ActiveOperation] = [:] private var subscriptions: [UUID: Subscription] = [:] - private var lateShutdownSuccesses: Set<TeraRuntimeOperationIdentity> = [] init( factory: @escaping TeraRuntimeBackendFactory, @@ -359,7 +360,7 @@ actor TeraRuntimeClient { _ = try await finishShutdown(shutdownOperation) } - if quarantinedBackend != nil { + if retryShutdownWork != nil || !abandonedStartups.isEmpty { _ = try await finishShutdown(beginShutdown()) } @@ -737,12 +738,11 @@ actor TeraRuntimeClient { ) { [weak self] change in await self?.receive(change, subscriptionID: id, generation: subscriptionGeneration) } - activeOperations[identity.sequence] = ActiveOperation( - identity: identity, - cancel: { task.cancel() } - ) + trackCreation(task, identity: identity) let outcome = await task.value() - removeActiveOperation(identity) + if case .completed = outcome { + removeActiveOperation(identity) + } guard generation == subscriptionGeneration, case .running = lifecycleState, @@ -788,7 +788,6 @@ actor TeraRuntimeClient { for operation in activeOperations.values { operation.cancel() } - activeOperations.removeAll() let activeSubscriptions = Array(subscriptions.values) subscriptions.removeAll() @@ -801,7 +800,7 @@ actor TeraRuntimeClient { if let startupOperation { generation = generation.invalidated() - startupOperation.task.cancel() + abandonStartup(startupOperation) self.startupOperation = nil configuration = nil lifecycleState = .stopped @@ -813,7 +812,7 @@ actor TeraRuntimeClient { return try await finishShutdown(shutdownOperation) } guard - startupOperation != nil || backend != nil || quarantinedBackend != nil + startupOperation != nil || backend != nil || retryShutdownWork != nil || !abandonedStartups.isEmpty || !subscriptions.isEmpty || !activeOperations.isEmpty else { lifecycleState = .stopped @@ -861,10 +860,12 @@ actor TeraRuntimeClient { lifecycleState = .failed(generation: operation.identity.generation, failure: failure) throw TeraRuntimeClientError.startup(failure) case .timedOut: + abandonStartup(operation) let failure = Self.deadlineFailure(identity: operation.identity) lifecycleState = .failed(generation: operation.identity.generation, failure: failure) throw TeraRuntimeClientError.startup(failure) case .cancelled: + abandonStartup(operation) let failure = Self.cancellationFailure(identity: operation.identity) lifecycleState = .failed(generation: operation.identity.generation, failure: failure) throw TeraRuntimeClientError.startup(failure) @@ -878,118 +879,81 @@ actor TeraRuntimeClient { startupOperation = pending return } - pending.task.cancel() + abandonStartup(pending) startupOperation = nil configuration = nil lifecycleState = .stopped } + private func trackCreation( + _ task: TeraRuntimeResourceTask<some Sendable>, identity: TeraRuntimeOperationIdentity + ) { + activeOperations[identity.sequence] = ActiveOperation( + identity: identity, + cancel: { task.cancel() }, + drain: { try await task.finishAbandonment() } + ) + } + + private func abandonStartup(_ operation: StartupOperation) { + operation.task.cancel() + abandonedStartups[operation.identity.sequence] = operation.task + } + private func beginShutdown() -> ShutdownOperation { generation = generation.invalidated() - let operationGeneration = generation - let pendingStartup = startupOperation - let activeBackend = backend ?? quarantinedBackend + if let startupOperation { + abandonStartup(startupOperation) + } let activeSubscriptions = Array(subscriptions.values) let activeRuntimeOperations = Array(activeOperations.values) - + let startups = Array(abandonedStartups.values) + let activeBackend = backend startupOperation = nil backend = nil - quarantinedBackend = nil configuration = nil subscriptions.removeAll() activeOperations.removeAll() - lifecycleState = .stopping(generation: operationGeneration) + abandonedStartups.removeAll() + lifecycleState = .stopping(generation: generation) - pendingStartup?.task.cancel() for operation in activeRuntimeOperations { operation.cancel() } for subscription in activeSubscriptions { subscription.continuation.finish() } - + let drains: [@Sendable () async throws -> Void] = + startups.map { startup in { @Sendable in try await startup.finishAbandonment() } } + + activeRuntimeOperations.map(\.drain) + + activeSubscriptions.compactMap(\.token).map { token in { @Sendable in await token.cancel() } } + let work = retryShutdownWork ?? TeraRuntimeShutdownWork(backend: activeBackend, drains: drains) + retryShutdownWork = work let identity = nextIdentity(kind: .shutdown) - let cancellationDeadline = deadlines.subscriptionNanoseconds - let task = TeraRuntimeBoundedTask<TeraRuntimeShutdownReceipt>( - deadlineNanoseconds: deadlines.shutdownNanoseconds, - operation: { - let cancellations = activeSubscriptions.compactMap(\.token).map { token in - TeraRuntimeBoundedTask<Void>( - deadlineNanoseconds: cancellationDeadline, - operation: { - await token.cancel() - return .success(()) - } - ) - } - for cancellation in cancellations { - _ = await cancellation.value() - } - - 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(identity: identity, backend: activeBackend, task: task) + let task = TeraRuntimeShutdownTask(work: work, deadline: deadlines.shutdownNanoseconds) { [weak self] result in + await self?.settleShutdown(identity: identity, result: result) + } + let operation = ShutdownOperation(identity: identity, work: work, task: task) shutdownOperation = operation return operation } - private func finishShutdown( - _ operation: ShutdownOperation - ) async throws -> TeraRuntimeShutdownReceipt { - let outcome = await operation.task.value(cancelsOperationWhenWaiterCancelled: false) - if case .cancelled = outcome, - shutdownOperation?.identity == operation.identity, - generation == operation.identity.generation - { - throw TeraRuntimeClientError.shutdown( - Self.cancellationFailure(identity: operation.identity) - ) - } - if shutdownOperation?.identity == operation.identity { - shutdownOperation = nil - } - - let lateSucceeded = lateShutdownSuccesses.remove(operation.identity) != nil - guard generation == operation.identity.generation else { - throw TeraRuntimeClientError.superseded - } - + private func finishShutdown(_ operation: ShutdownOperation) async throws -> TeraRuntimeShutdownReceipt { + let outcome = await operation.task.value() + guard generation == operation.identity.generation else { throw TeraRuntimeClientError.superseded } switch outcome { case let .completed(.success(receipt)): - quarantinedBackend = nil - lifecycleState = .stopped return receipt case let .completed(.failure(failure)): - quarantinedBackend = operation.backend - lifecycleState = .failed(generation: operation.identity.generation, failure: failure) throw TeraRuntimeClientError.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) + if shutdownOperation?.identity == operation.identity { + lifecycleState = .failed(generation: operation.identity.generation, failure: failure) + } throw TeraRuntimeClientError.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 TeraRuntimeClientError.shutdown(failure) + throw TeraRuntimeClientError.shutdown(Self.cancellationFailure(identity: operation.identity)) } } @@ -1004,20 +968,23 @@ actor TeraRuntimeClient { let identity = nextIdentity(kind: .operation) let task = TeraRuntimeBoundedTask<T>( deadlineNanoseconds: deadlines.operationNanoseconds, - operation: { + operation: { [weak self] in + let result: Result<T, TeraRuntimeFailure> do { - return try await .success(body(backend)) + result = try await .success(body(backend)) } catch { - return .failure(Self.failure(from: error, operation: operation)) + result = .failure(Self.failure(from: error, operation: operation)) } + await self?.removeActiveOperation(identity) + return result } ) activeOperations[identity.sequence] = ActiveOperation( identity: identity, - cancel: { task.cancel() } + cancel: { task.cancel() }, + drain: { _ = await task.settle() } ) let outcome = await task.value() - removeActiveOperation(identity) guard generation == operationGeneration, case .running = lifecycleState else { throw TeraRuntimeClientError.superseded @@ -1093,30 +1060,29 @@ actor TeraRuntimeClient { } private func cancelTokenDetached(_ token: any TeraRuntimeSubscriptionToken) { - let deadline = deadlines.subscriptionNanoseconds - Task { - let cancellation = TeraRuntimeBoundedTask<Void>( - deadlineNanoseconds: deadline, - operation: { - await token.cancel() - return .success(()) - } - ) - _ = await cancellation.value() + let identity = nextIdentity(kind: .subscription) + let task = TeraRuntimeBoundedTask<Void>(deadlineNanoseconds: deadlines.subscriptionNanoseconds) { [weak self] in + await token.cancel() + await self?.removeActiveOperation(identity) + return .success(()) } + activeOperations[identity.sequence] = ActiveOperation( + identity: identity, cancel: { task.cancel() }, drain: { _ = await task.settle() } + ) } - private func settleLateShutdown( + private func settleShutdown( identity: TeraRuntimeOperationIdentity, result: Result<TeraRuntimeShutdownReceipt, TeraRuntimeFailure> ) { - guard generation == identity.generation else { return } - guard case .success = result else { return } - if shutdownOperation?.identity == identity { - lateShutdownSuccesses.insert(identity) - } else { - quarantinedBackend = nil + guard generation == identity.generation, shutdownOperation?.identity == identity else { return } + shutdownOperation = nil + switch result { + case .success: + retryShutdownWork = nil lifecycleState = .stopped + case let .failure(failure): + lifecycleState = .failed(generation: identity.generation, failure: failure) } } diff --git a/Tera/Runtime/TeraRuntimeResourceCreation.swift b/Tera/Runtime/TeraRuntimeResourceCreation.swift @@ -16,7 +16,7 @@ enum TeraRuntimeResourceCreation { } }, cleanup: { started in - _ = try? await started.backend.shutdown() + _ = try await started.backend.shutdown() } ) } diff --git a/Tera/Runtime/TeraRuntimeResourceTask.swift b/Tera/Runtime/TeraRuntimeResourceTask.swift @@ -17,7 +17,7 @@ final class TeraRuntimeResourceTask<Value: Sendable>: Sendable { deadlineNanoseconds: UInt64, cleanupDeadlineNanoseconds: UInt64, operation: @escaping @Sendable () async -> Result<Value, TeraRuntimeFailure>, - cleanup: @escaping @Sendable (Value) async -> Void + cleanup: @escaping @Sendable (Value) async throws -> Void ) { let ownership = Ownership(deadline: cleanupDeadlineNanoseconds, cleanup: cleanup) self.ownership = ownership @@ -54,16 +54,27 @@ final class TeraRuntimeResourceTask<Value: Sendable>: Sendable { task.cancel() } + /// Drain creation and its cleanup, retaining a failed resource for a later + /// explicit close attempt. This wait belongs to the bounded shutdown owner. + func finishAbandonment() async throws { + cancel() + if case .failure = await task.settle() { + return + } + try await ownership.finishCleanup() + } + /// Every mutable field is lock protected. Cleanup starts outside the lock, /// has the existing host deadline, and retains its resource until it returns. private final class Ownership: @unchecked Sendable { private let lock = NSLock() private var state = State.pending - private var cleanupTask: TeraRuntimeBoundedTask<Void>? + private var cleanupTask: Cleanup? + private var cleanupWaiters: [CheckedContinuation<Cleanup?, Never>] = [] private let deadline: UInt64 - private let cleanup: @Sendable (Value) async -> Void + private let cleanup: @Sendable (Value) async throws -> Void - init(deadline: UInt64, cleanup: @escaping @Sendable (Value) async -> Void) { + init(deadline: UInt64, cleanup: @escaping @Sendable (Value) async throws -> Void) { self.deadline = deadline self.cleanup = cleanup } @@ -108,11 +119,62 @@ final class TeraRuntimeResourceTask<Value: Sendable>: Sendable { } private func startCleanup(_ value: Value) { - let task = TeraRuntimeBoundedTask<Void>(deadlineNanoseconds: deadline) { [cleanup] in - await cleanup(value) - return .success(()) + let task = Cleanup(value: value, deadline: deadline, cleanup: cleanup) + let waiters = lock.withLock { + cleanupTask = task + let pending = cleanupWaiters + cleanupWaiters.removeAll() + return pending + } + for waiter in waiters { + waiter.resume(returning: task) + } + } + + func finishCleanup() async throws { + let task = await withCheckedContinuation { continuation in + let immediate = lock.withLock { () -> (Bool, Cleanup?) in + if case .adopted = state { + return (true, nil) + } + if let cleanupTask { + return (true, cleanupTask) + } + cleanupWaiters.append(continuation) + return (false, nil) + } + if immediate.0 { + continuation.resume(returning: immediate.1) + } + } + try await task?.finish() + } + } + + private actor Cleanup { + private let operation: @Sendable () async -> Result<Void, TeraRuntimeFailure> + private let deadline: UInt64 + private var task: TeraRuntimeBoundedTask<Void> + + init(value: Value, deadline: UInt64, cleanup: @escaping @Sendable (Value) async throws -> Void) { + let operation: @Sendable () async -> Result<Void, TeraRuntimeFailure> = { + do { + try await cleanup(value) + return .success(()) + } catch { + return .failure(TeraRuntimeClient.failure(from: error, operation: "runtime.resource.close")) + } + } + self.operation = operation + self.deadline = deadline + task = TeraRuntimeBoundedTask(deadlineNanoseconds: deadline, operation: operation) + } + + func finish() async throws { + if case .failure = task.settlement() { + task = TeraRuntimeBoundedTask(deadlineNanoseconds: deadline, operation: operation) } - lock.withLock { cleanupTask = task } + try await task.settle().get() } } } diff --git a/Tera/Runtime/TeraRuntimeShutdownTask.swift b/Tera/Runtime/TeraRuntimeShutdownTask.swift @@ -0,0 +1,45 @@ +import Foundation + +struct TeraRuntimeShutdownWork: Sendable { + let backend: (any TeraRuntimeBackend)? + let drains: [@Sendable () async throws -> Void] + + func close() async -> Result<TeraRuntimeShutdownReceipt, TeraRuntimeFailure> { + do { + for drain in drains { + try await drain() + } + guard let backend else { return .success(.alreadyStopped) } + return try await .success(backend.shutdown()) + } catch { + return .failure(TeraRuntimeClient.failure(from: error, operation: "runtime.shutdown")) + } + } +} + +/// One retained shutdown operation; each caller has a bounded wait. Cancelling +/// or timing out a wait never cancels this owner or starts another close. +final class TeraRuntimeShutdownTask: Sendable { + private let task: Task<Result<TeraRuntimeShutdownReceipt, TeraRuntimeFailure>, Never> + private let deadline: UInt64 + + init( + work: TeraRuntimeShutdownWork, + deadline: UInt64, + completed: @escaping @Sendable (Result<TeraRuntimeShutdownReceipt, TeraRuntimeFailure>) async -> Void + ) { + self.deadline = deadline + task = Task { + let result = await work.close() + await completed(result) + return result + } + } + + func value() async -> TeraRuntimeBoundedOutcome<TeraRuntimeShutdownReceipt> { + let wait = TeraRuntimeBoundedTask(deadlineNanoseconds: deadline) { [task] in + await task.value + } + return await wait.value() + } +} diff --git a/TeraFFI/provenance.json b/TeraFFI/provenance.json @@ -22,9 +22,9 @@ "sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c" }, { - "bytes": 71298400, + "bytes": 71240240, "path": "TeraFFI.xcframework/ios-arm64-simulator/libtera_ffi.a", - "sha256": "86132ffe7b61711ef73e6fe9b437b2d3e2a7d3d2b87d3960a5b9ef1698cc3fe1" + "sha256": "3c80cd5a77f132cd40d54a821c00cf464e964a4c7aa8087d430a005c6bf9a2ab" }, { "bytes": 69176, @@ -37,9 +37,9 @@ "sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c" }, { - "bytes": 71351712, + "bytes": 71295304, "path": "TeraFFI.xcframework/ios-arm64/libtera_ffi.a", - "sha256": "5f657fe4f72c79e8f42938b2318153d18370c49db2336a586fd00b9beece54e4" + "sha256": "455c5d158e6710322a7f3ed43b3f69953a2535ecad02c92491d82cfdee32e5c0" }, { "bytes": 41083, @@ -77,34 +77,34 @@ "sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c" }, { - "bytes": 19867712, + "bytes": 19869696, "path": "native/aarch64-apple-darwin/libtera_ffi.dylib", - "sha256": "ff044443ba203f8fb954fbc4fc986540f34163a6d8396ced1bde0cccfb452788" + "sha256": "c15d064c58108b14aa04f9df996518ab28775c933ae5c5280fe204e1cb580578" }, { - "bytes": 71298400, + "bytes": 71240240, "path": "native/aarch64-apple-ios-sim/libtera_ffi.a", - "sha256": "86132ffe7b61711ef73e6fe9b437b2d3e2a7d3d2b87d3960a5b9ef1698cc3fe1" + "sha256": "3c80cd5a77f132cd40d54a821c00cf464e964a4c7aa8087d430a005c6bf9a2ab" }, { - "bytes": 71351712, + "bytes": 71295304, "path": "native/aarch64-apple-ios/libtera_ffi.a", - "sha256": "5f657fe4f72c79e8f42938b2318153d18370c49db2336a586fd00b9beece54e4" + "sha256": "455c5d158e6710322a7f3ed43b3f69953a2535ecad02c92491d82cfdee32e5c0" }, { - "bytes": 55935, + "bytes": 56200, "path": "source/aarch64-apple-darwin.json", - "sha256": "51c9de5886c1d05ef5dab20a79cee82e6772e5956d7eaf7bc606a3e0bfcd2e34" + "sha256": "e5ef0a2937d997b6eacbcaf2165054aa3fa856b5a122cc1e58fcc9edd73a87a3" }, { - "bytes": 55779, + "bytes": 56044, "path": "source/aarch64-apple-ios-sim.json", - "sha256": "55c6730f612e3392bd44a7d761d94cca162c9fe7fef4c36248e3b61eafc7b855" + "sha256": "7f7f2c19b00868337c0e177d188477be5a25f4e01c6404aec30d03c15e1e95ea" }, { - "bytes": 55775, + "bytes": 56040, "path": "source/aarch64-apple-ios.json", - "sha256": "46c600975103c4038084f44420605930154361ed62586b70598bb05e67c22c5a" + "sha256": "56ad18f75c4e856d13df0d1b2aadf6a0f411bea1a2122658700517546e310b56" } ], "language": "swift", @@ -112,7 +112,7 @@ "schema": "radroots.artifact-manifest.v2", "source": { "repository": "https://github.com/radrootslabs/tera", - "tree": "f6bfeb00965c4cf264b5f74e488427092965762f" + "tree": "f4a8a67798881cd35e23c5817dc831c71305d61a" }, "source_records": { "aarch64-apple-darwin": "source/aarch64-apple-darwin.json", @@ -139,9 +139,9 @@ "sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c" }, { - "bytes": 71298400, + "bytes": 71240240, "path": "Tera/Frameworks/TeraFFI.xcframework/ios-arm64-simulator/libtera_ffi.a", - "sha256": "86132ffe7b61711ef73e6fe9b437b2d3e2a7d3d2b87d3960a5b9ef1698cc3fe1" + "sha256": "3c80cd5a77f132cd40d54a821c00cf464e964a4c7aa8087d430a005c6bf9a2ab" }, { "bytes": 69176, @@ -154,9 +154,9 @@ "sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c" }, { - "bytes": 71351712, + "bytes": 71295304, "path": "Tera/Frameworks/TeraFFI.xcframework/ios-arm64/libtera_ffi.a", - "sha256": "5f657fe4f72c79e8f42938b2318153d18370c49db2336a586fd00b9beece54e4" + "sha256": "455c5d158e6710322a7f3ed43b3f69953a2535ecad02c92491d82cfdee32e5c0" }, { "bytes": 490236, @@ -174,19 +174,19 @@ "sha256": "37ba223303e6268d12d8581ba9a1a77a327b132122e29284bec715cca3c19369" }, { - "bytes": 55935, + "bytes": 56200, "path": "TeraFFI/source/aarch64-apple-darwin.json", - "sha256": "51c9de5886c1d05ef5dab20a79cee82e6772e5956d7eaf7bc606a3e0bfcd2e34" + "sha256": "e5ef0a2937d997b6eacbcaf2165054aa3fa856b5a122cc1e58fcc9edd73a87a3" }, { - "bytes": 55779, + "bytes": 56044, "path": "TeraFFI/source/aarch64-apple-ios-sim.json", - "sha256": "55c6730f612e3392bd44a7d761d94cca162c9fe7fef4c36248e3b61eafc7b855" + "sha256": "7f7f2c19b00868337c0e177d188477be5a25f4e01c6404aec30d03c15e1e95ea" }, { - "bytes": 55775, + "bytes": 56040, "path": "TeraFFI/source/aarch64-apple-ios.json", - "sha256": "46c600975103c4038084f44420605930154361ed62586b70598bb05e67c22c5a" + "sha256": "56ad18f75c4e856d13df0d1b2aadf6a0f411bea1a2122658700517546e310b56" } ], "schema": "tera.installed-native-artifacts.v1" diff --git a/TeraFFI/source.lock b/TeraFFI/source.lock @@ -1,7 +1,7 @@ schema = "tera.installed-source.v1" repository = "https://github.com/radrootslabs/tera" -source_tree = "f6bfeb00965c4cf264b5f74e488427092965762f" -manifest_sha256 = "6c26d7069a9864fb5c70f1bd8633b6102d2aa9cb5774c06bf6c01e542cb3315d" +source_tree = "f4a8a67798881cd35e23c5817dc831c71305d61a" +manifest_sha256 = "f279048f5256c2ab0fad81300201bcb342cba1abf318c50d4f0c910dd1681628" source_date_epoch = 1787871027 [foundation] diff --git a/TeraFFI/source/aarch64-apple-darwin.json b/TeraFFI/source/aarch64-apple-darwin.json @@ -612,11 +612,17 @@ "mode": "100644", "sha256": "1b0e54b4ba895b5d4f849c28abb09fb9fd4d9e8caf2a968403c310ab6da6a7cc" }, + "core/crates/tera_core/src/runtime/lifecycle.rs": { + "bytes": 7135, + "git_blob": "d6e3c910a935113e365fd90b54ff02a4672bd9b7", + "mode": "100644", + "sha256": "c25bafe3b7228d8c9dc680ba30aa87d3e1f5fe1468484c920d29739de895d5f8" + }, "core/crates/tera_core/src/runtime/mod.rs": { - "bytes": 7958, - "git_blob": "2c7b83e791f679bd8b42a0bc74ea5dd8b21c902c", + "bytes": 8918, + "git_blob": "a3f1041e3ffd34212ab528fbee7c2c98e7740632", "mode": "100644", - "sha256": "1460ac137204de4cecc3b01eb2a41142b7bd9730f5a6e8a83d2dd84cb0817ec1" + "sha256": "15f635a0d02e40238a460513efaa194bc0c7d85628b8a3353391bce14a5420b6" }, "core/crates/tera_core/src/runtime/mutation_admission.rs": { "bytes": 2148, @@ -673,16 +679,16 @@ "sha256": "86271be5ec0ae8ef6906752272f5e091415b0581ed717809f50a973919275a1e" }, "core/crates/tera_core/src/runtime/product_surface/outbox.rs": { - "bytes": 169297, - "git_blob": "6daf3a457d8b6c778da035d929ba37255aef310a", + "bytes": 170884, + "git_blob": "88dfae55e8605e609a8f8811cacd1d05e2d5dc97", "mode": "100644", - "sha256": "6de6f9c86c1336b331ea652fe767eb6936fde126c2dee8365d2eceef931b8175" + "sha256": "dd14a05c8e3b7d34860f95ca86b6a5ba420ea3e39c9787d1b1a4e1888de6be0c" }, "core/crates/tera_core/src/runtime/product_surface/outbox/mutation_admission_tests.rs": { - "bytes": 10886, - "git_blob": "72891344789c379cdbe17ea97487afdfc3f62373", + "bytes": 13316, + "git_blob": "9c63f04493eda38aa11cb77bec8173004ca70865", "mode": "100644", - "sha256": "f532beab6e57b127ea644f65171fe93d617f3d584950c7322847407334aaf8dc" + "sha256": "3c048e44551d04e607597761481c24e9bbe55f6b20358047d18c8013c093fb8f" }, "core/crates/tera_core/src/runtime/product_surface/projection.rs": { "bytes": 23469, @@ -697,22 +703,22 @@ "sha256": "4e1dbc7f9e76ee4df62bedb915e0d92bd713c4408a4a5e1c9508b84e6e39cdc2" }, "core/crates/tera_core/src/runtime/product_surface/settings.rs": { - "bytes": 56812, - "git_blob": "58a13c691a8916c8d993fc0739f91fe453c761fc", + "bytes": 57107, + "git_blob": "b0ea8912d1932e975529f0e8da70d6566eb7eb51", "mode": "100644", - "sha256": "18a82705b14e35ac716f4e9ae4ac39545c7ec9a8a4ded898e8c8febfa054938b" + "sha256": "76e3bddfc237dac63293d264d68991b489585136fe377e39dbba72aa3a1181f4" }, "core/crates/tera_core/src/runtime/product_surface/today.rs": { - "bytes": 134254, - "git_blob": "233c943ee7c79af245e2540c35750aa719585870", + "bytes": 135116, + "git_blob": "f8f68924ffb05011e8e20e1e310c003738c7ae2f", "mode": "100644", - "sha256": "dcd7884d8fbda59e439551110abff68cc4069a5aae6cdc5376d57367afcd7a70" + "sha256": "d77c115493a88799fd160ba14209680db60a1c65cfc6dd692a20b285e18d1ef3" }, "core/crates/tera_core/src/runtime/sdk.rs": { - "bytes": 20071, - "git_blob": "0d9704a604b3ad539c253da6670bc706acad2599", + "bytes": 20647, + "git_blob": "5d1ac4c74cb1c31e8b94f23e4cd93c85365fafa3", "mode": "100644", - "sha256": "102d47aae3704bec56c9a4594ba5f54b66af277c9986a8ce1f24f80e7f2473f1" + "sha256": "da29c6b554d5b08de7869cde9fd1c744153d23512ee4fca7a371dd5340b45c33" }, "core/crates/tera_core/src/runtime/store.rs": { "bytes": 8998, @@ -745,10 +751,10 @@ "sha256": "a6bf46083940aa3d248796ad18c64adfcd3362a9ae4ce1167edf85305574d29a" }, "core/crates/tera_ffi/Cargo.toml": { - "bytes": 1941, - "git_blob": "44eb2d02c00ae2472ed1e0b1a9496cf79fdfc630", + "bytes": 1991, + "git_blob": "0eb2a678db01e885b3763f459bfbadedf25a9ce3", "mode": "100644", - "sha256": "146992d4a39f0c2688b5d1f49509b1bf02765f885b00a842fc698690f3f45197" + "sha256": "f96248270139fbd131bf5290bf97560b7e12140d76e4fe77a071f92d6e76e451" }, "core/crates/tera_ffi/src/dto.rs": { "bytes": 110139, @@ -757,10 +763,10 @@ "sha256": "0f19070bd3beb9dbbc871ceca44c30e590ea2d38e875339fe9dbcde749b3aa6e" }, "core/crates/tera_ffi/src/error.rs": { - "bytes": 19532, - "git_blob": "5efd4b1d83573a9d458b595074d28ba78e94e036", + "bytes": 19893, + "git_blob": "2313f32ede9e60334e68f137f86249810d349b19", "mode": "100644", - "sha256": "94298868ee6b7548b83fac0a12b7637eb9e94963168bf654e98f7c3ade2fa12f" + "sha256": "f02e95e9eb45a5d2775e9447e410d44d8e6111192772fdc9f4d4395594228f82" }, "core/crates/tera_ffi/src/lib.rs": { "bytes": 1334, @@ -793,10 +799,10 @@ "sha256": "23bcabc178b5891a50ee249098d7093ad18f00c16fa1100fdcbc209ff86797f7" }, "core/crates/tera_ffi/src/runtime.rs": { - "bytes": 41440, - "git_blob": "131e0f0035e3e9cbb6ec520e16d571ee962fabcd", + "bytes": 41387, + "git_blob": "98de223245f00addb41550cd22f34201603f09bb", "mode": "100644", - "sha256": "e7e92c3fe2c5a7663a2967b1eb2f1caee6285c80e4a07bd2bbd18a157774b9b3" + "sha256": "eee94163c970c4517cae918794f1738fe262c24950b97232c1c96ca8522fac2a" }, "core/crates/tera_ffi/src/signer.rs": { "bytes": 13853, @@ -805,10 +811,10 @@ "sha256": "4c0800499f248e61011f7ffe78968e0d8afb13ddfe2332390fb25de444f7e6b7" }, "core/crates/tera_ffi/src/subscription.rs": { - "bytes": 10301, - "git_blob": "7ade122c46c8bd36480b9e7540a013ed45ced3e7", + "bytes": 14401, + "git_blob": "39391b0105c50bc89e9ea2d34b1d7374f056c28b", "mode": "100644", - "sha256": "4c57fca52ef318b6b052aebe7ff7e32566d4546c059990d2bd141c429bfb325f" + "sha256": "eccbd6f0b549704bf53874d53da355b9702dbd5e5262bc384443340cece68f56" }, "core/crates/tera_ffi/tests/compatibility.rs": { "bytes": 7548, @@ -835,10 +841,10 @@ "sha256": "ec87c2cc7f396ef583d3a37186f40932ac4eb42c0e80973a5c25312099f2670a" }, "core/crates/tera_ffi/tests/recovery_contract.rs": { - "bytes": 6875, - "git_blob": "61ad2f9c1d96b68c3d93ab6f8933ceaa4896fd6c", + "bytes": 7971, + "git_blob": "df270d8d7d953a41b71e67c8a24514f472005c86", "mode": "100644", - "sha256": "9e85f2e0f6d27be0650e16835b57839e3351896dafcf6323b308bcc2e30d7d0b" + "sha256": "1d2e6015400b2ba03819ed01bae674817a18a2c8e626ba3dfeca24dc2fb8763c" }, "core/crates/tera_ffi/tests/runtime_delegation.rs": { "bytes": 30665, @@ -847,10 +853,10 @@ "sha256": "33843d89f28ff874c1f2ab73064e585c5497f7d62b8ec8e1732a44f9a726d819" }, "core/crates/tera_ffi/tests/runtime_lifecycle.rs": { - "bytes": 1459, - "git_blob": "ff2431237aa8b2c4e89dbfcaef637b3cf78488bd", + "bytes": 4293, + "git_blob": "43dd277cd2439a8db72eaca60e4f07e2ae21317a", "mode": "100644", - "sha256": "cc62d835fd85bca7ee7fd41af3c7358225d5e9b03de12d2465d6c0aa3fb87053" + "sha256": "613426aec508436234134b2ebb14c1d5f6339c93b108f4f92674e61810937738" }, "core/crates/tera_ffi/tests/subscription_contract.rs": { "bytes": 1997, @@ -1201,13 +1207,13 @@ "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d" }, "test-fixtures/legacy-identifiers.v1.json": { - "bytes": 112691, - "git_blob": "c182eb3f94b158ed66926f7216b994434697a0f8", + "bytes": 112911, + "git_blob": "e61b1bd3193280c5654f7053f3ebfb0f80d4b521", "mode": "100644", - "sha256": "28f66e949f0d7335a47ff7799e077a985e5450bdd4fe0315ecf757e52b00bc32" + "sha256": "c19cca2d21ba5297dd22a88b1f485af8369323d84f878cb2f43a112844311df4" } }, "policy": "staged_inputs", - "tree": "f6bfeb00965c4cf264b5f74e488427092965762f" + "tree": "f4a8a67798881cd35e23c5817dc831c71305d61a" } } diff --git a/TeraFFI/source/aarch64-apple-ios-sim.json b/TeraFFI/source/aarch64-apple-ios-sim.json @@ -608,11 +608,17 @@ "mode": "100644", "sha256": "1b0e54b4ba895b5d4f849c28abb09fb9fd4d9e8caf2a968403c310ab6da6a7cc" }, + "core/crates/tera_core/src/runtime/lifecycle.rs": { + "bytes": 7135, + "git_blob": "d6e3c910a935113e365fd90b54ff02a4672bd9b7", + "mode": "100644", + "sha256": "c25bafe3b7228d8c9dc680ba30aa87d3e1f5fe1468484c920d29739de895d5f8" + }, "core/crates/tera_core/src/runtime/mod.rs": { - "bytes": 7958, - "git_blob": "2c7b83e791f679bd8b42a0bc74ea5dd8b21c902c", + "bytes": 8918, + "git_blob": "a3f1041e3ffd34212ab528fbee7c2c98e7740632", "mode": "100644", - "sha256": "1460ac137204de4cecc3b01eb2a41142b7bd9730f5a6e8a83d2dd84cb0817ec1" + "sha256": "15f635a0d02e40238a460513efaa194bc0c7d85628b8a3353391bce14a5420b6" }, "core/crates/tera_core/src/runtime/mutation_admission.rs": { "bytes": 2148, @@ -669,16 +675,16 @@ "sha256": "86271be5ec0ae8ef6906752272f5e091415b0581ed717809f50a973919275a1e" }, "core/crates/tera_core/src/runtime/product_surface/outbox.rs": { - "bytes": 169297, - "git_blob": "6daf3a457d8b6c778da035d929ba37255aef310a", + "bytes": 170884, + "git_blob": "88dfae55e8605e609a8f8811cacd1d05e2d5dc97", "mode": "100644", - "sha256": "6de6f9c86c1336b331ea652fe767eb6936fde126c2dee8365d2eceef931b8175" + "sha256": "dd14a05c8e3b7d34860f95ca86b6a5ba420ea3e39c9787d1b1a4e1888de6be0c" }, "core/crates/tera_core/src/runtime/product_surface/outbox/mutation_admission_tests.rs": { - "bytes": 10886, - "git_blob": "72891344789c379cdbe17ea97487afdfc3f62373", + "bytes": 13316, + "git_blob": "9c63f04493eda38aa11cb77bec8173004ca70865", "mode": "100644", - "sha256": "f532beab6e57b127ea644f65171fe93d617f3d584950c7322847407334aaf8dc" + "sha256": "3c048e44551d04e607597761481c24e9bbe55f6b20358047d18c8013c093fb8f" }, "core/crates/tera_core/src/runtime/product_surface/projection.rs": { "bytes": 23469, @@ -693,22 +699,22 @@ "sha256": "4e1dbc7f9e76ee4df62bedb915e0d92bd713c4408a4a5e1c9508b84e6e39cdc2" }, "core/crates/tera_core/src/runtime/product_surface/settings.rs": { - "bytes": 56812, - "git_blob": "58a13c691a8916c8d993fc0739f91fe453c761fc", + "bytes": 57107, + "git_blob": "b0ea8912d1932e975529f0e8da70d6566eb7eb51", "mode": "100644", - "sha256": "18a82705b14e35ac716f4e9ae4ac39545c7ec9a8a4ded898e8c8febfa054938b" + "sha256": "76e3bddfc237dac63293d264d68991b489585136fe377e39dbba72aa3a1181f4" }, "core/crates/tera_core/src/runtime/product_surface/today.rs": { - "bytes": 134254, - "git_blob": "233c943ee7c79af245e2540c35750aa719585870", + "bytes": 135116, + "git_blob": "f8f68924ffb05011e8e20e1e310c003738c7ae2f", "mode": "100644", - "sha256": "dcd7884d8fbda59e439551110abff68cc4069a5aae6cdc5376d57367afcd7a70" + "sha256": "d77c115493a88799fd160ba14209680db60a1c65cfc6dd692a20b285e18d1ef3" }, "core/crates/tera_core/src/runtime/sdk.rs": { - "bytes": 20071, - "git_blob": "0d9704a604b3ad539c253da6670bc706acad2599", + "bytes": 20647, + "git_blob": "5d1ac4c74cb1c31e8b94f23e4cd93c85365fafa3", "mode": "100644", - "sha256": "102d47aae3704bec56c9a4594ba5f54b66af277c9986a8ce1f24f80e7f2473f1" + "sha256": "da29c6b554d5b08de7869cde9fd1c744153d23512ee4fca7a371dd5340b45c33" }, "core/crates/tera_core/src/runtime/store.rs": { "bytes": 8998, @@ -741,10 +747,10 @@ "sha256": "a6bf46083940aa3d248796ad18c64adfcd3362a9ae4ce1167edf85305574d29a" }, "core/crates/tera_ffi/Cargo.toml": { - "bytes": 1941, - "git_blob": "44eb2d02c00ae2472ed1e0b1a9496cf79fdfc630", + "bytes": 1991, + "git_blob": "0eb2a678db01e885b3763f459bfbadedf25a9ce3", "mode": "100644", - "sha256": "146992d4a39f0c2688b5d1f49509b1bf02765f885b00a842fc698690f3f45197" + "sha256": "f96248270139fbd131bf5290bf97560b7e12140d76e4fe77a071f92d6e76e451" }, "core/crates/tera_ffi/src/dto.rs": { "bytes": 110139, @@ -753,10 +759,10 @@ "sha256": "0f19070bd3beb9dbbc871ceca44c30e590ea2d38e875339fe9dbcde749b3aa6e" }, "core/crates/tera_ffi/src/error.rs": { - "bytes": 19532, - "git_blob": "5efd4b1d83573a9d458b595074d28ba78e94e036", + "bytes": 19893, + "git_blob": "2313f32ede9e60334e68f137f86249810d349b19", "mode": "100644", - "sha256": "94298868ee6b7548b83fac0a12b7637eb9e94963168bf654e98f7c3ade2fa12f" + "sha256": "f02e95e9eb45a5d2775e9447e410d44d8e6111192772fdc9f4d4395594228f82" }, "core/crates/tera_ffi/src/lib.rs": { "bytes": 1334, @@ -789,10 +795,10 @@ "sha256": "23bcabc178b5891a50ee249098d7093ad18f00c16fa1100fdcbc209ff86797f7" }, "core/crates/tera_ffi/src/runtime.rs": { - "bytes": 41440, - "git_blob": "131e0f0035e3e9cbb6ec520e16d571ee962fabcd", + "bytes": 41387, + "git_blob": "98de223245f00addb41550cd22f34201603f09bb", "mode": "100644", - "sha256": "e7e92c3fe2c5a7663a2967b1eb2f1caee6285c80e4a07bd2bbd18a157774b9b3" + "sha256": "eee94163c970c4517cae918794f1738fe262c24950b97232c1c96ca8522fac2a" }, "core/crates/tera_ffi/src/signer.rs": { "bytes": 13853, @@ -801,10 +807,10 @@ "sha256": "4c0800499f248e61011f7ffe78968e0d8afb13ddfe2332390fb25de444f7e6b7" }, "core/crates/tera_ffi/src/subscription.rs": { - "bytes": 10301, - "git_blob": "7ade122c46c8bd36480b9e7540a013ed45ced3e7", + "bytes": 14401, + "git_blob": "39391b0105c50bc89e9ea2d34b1d7374f056c28b", "mode": "100644", - "sha256": "4c57fca52ef318b6b052aebe7ff7e32566d4546c059990d2bd141c429bfb325f" + "sha256": "eccbd6f0b549704bf53874d53da355b9702dbd5e5262bc384443340cece68f56" }, "core/crates/tera_ffi/tests/compatibility.rs": { "bytes": 7548, @@ -831,10 +837,10 @@ "sha256": "ec87c2cc7f396ef583d3a37186f40932ac4eb42c0e80973a5c25312099f2670a" }, "core/crates/tera_ffi/tests/recovery_contract.rs": { - "bytes": 6875, - "git_blob": "61ad2f9c1d96b68c3d93ab6f8933ceaa4896fd6c", + "bytes": 7971, + "git_blob": "df270d8d7d953a41b71e67c8a24514f472005c86", "mode": "100644", - "sha256": "9e85f2e0f6d27be0650e16835b57839e3351896dafcf6323b308bcc2e30d7d0b" + "sha256": "1d2e6015400b2ba03819ed01bae674817a18a2c8e626ba3dfeca24dc2fb8763c" }, "core/crates/tera_ffi/tests/runtime_delegation.rs": { "bytes": 30665, @@ -843,10 +849,10 @@ "sha256": "33843d89f28ff874c1f2ab73064e585c5497f7d62b8ec8e1732a44f9a726d819" }, "core/crates/tera_ffi/tests/runtime_lifecycle.rs": { - "bytes": 1459, - "git_blob": "ff2431237aa8b2c4e89dbfcaef637b3cf78488bd", + "bytes": 4293, + "git_blob": "43dd277cd2439a8db72eaca60e4f07e2ae21317a", "mode": "100644", - "sha256": "cc62d835fd85bca7ee7fd41af3c7358225d5e9b03de12d2465d6c0aa3fb87053" + "sha256": "613426aec508436234134b2ebb14c1d5f6339c93b108f4f92674e61810937738" }, "core/crates/tera_ffi/tests/subscription_contract.rs": { "bytes": 1997, @@ -1197,13 +1203,13 @@ "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d" }, "test-fixtures/legacy-identifiers.v1.json": { - "bytes": 112691, - "git_blob": "c182eb3f94b158ed66926f7216b994434697a0f8", + "bytes": 112911, + "git_blob": "e61b1bd3193280c5654f7053f3ebfb0f80d4b521", "mode": "100644", - "sha256": "28f66e949f0d7335a47ff7799e077a985e5450bdd4fe0315ecf757e52b00bc32" + "sha256": "c19cca2d21ba5297dd22a88b1f485af8369323d84f878cb2f43a112844311df4" } }, "policy": "staged_inputs", - "tree": "f6bfeb00965c4cf264b5f74e488427092965762f" + "tree": "f4a8a67798881cd35e23c5817dc831c71305d61a" } } diff --git a/TeraFFI/source/aarch64-apple-ios.json b/TeraFFI/source/aarch64-apple-ios.json @@ -608,11 +608,17 @@ "mode": "100644", "sha256": "1b0e54b4ba895b5d4f849c28abb09fb9fd4d9e8caf2a968403c310ab6da6a7cc" }, + "core/crates/tera_core/src/runtime/lifecycle.rs": { + "bytes": 7135, + "git_blob": "d6e3c910a935113e365fd90b54ff02a4672bd9b7", + "mode": "100644", + "sha256": "c25bafe3b7228d8c9dc680ba30aa87d3e1f5fe1468484c920d29739de895d5f8" + }, "core/crates/tera_core/src/runtime/mod.rs": { - "bytes": 7958, - "git_blob": "2c7b83e791f679bd8b42a0bc74ea5dd8b21c902c", + "bytes": 8918, + "git_blob": "a3f1041e3ffd34212ab528fbee7c2c98e7740632", "mode": "100644", - "sha256": "1460ac137204de4cecc3b01eb2a41142b7bd9730f5a6e8a83d2dd84cb0817ec1" + "sha256": "15f635a0d02e40238a460513efaa194bc0c7d85628b8a3353391bce14a5420b6" }, "core/crates/tera_core/src/runtime/mutation_admission.rs": { "bytes": 2148, @@ -669,16 +675,16 @@ "sha256": "86271be5ec0ae8ef6906752272f5e091415b0581ed717809f50a973919275a1e" }, "core/crates/tera_core/src/runtime/product_surface/outbox.rs": { - "bytes": 169297, - "git_blob": "6daf3a457d8b6c778da035d929ba37255aef310a", + "bytes": 170884, + "git_blob": "88dfae55e8605e609a8f8811cacd1d05e2d5dc97", "mode": "100644", - "sha256": "6de6f9c86c1336b331ea652fe767eb6936fde126c2dee8365d2eceef931b8175" + "sha256": "dd14a05c8e3b7d34860f95ca86b6a5ba420ea3e39c9787d1b1a4e1888de6be0c" }, "core/crates/tera_core/src/runtime/product_surface/outbox/mutation_admission_tests.rs": { - "bytes": 10886, - "git_blob": "72891344789c379cdbe17ea97487afdfc3f62373", + "bytes": 13316, + "git_blob": "9c63f04493eda38aa11cb77bec8173004ca70865", "mode": "100644", - "sha256": "f532beab6e57b127ea644f65171fe93d617f3d584950c7322847407334aaf8dc" + "sha256": "3c048e44551d04e607597761481c24e9bbe55f6b20358047d18c8013c093fb8f" }, "core/crates/tera_core/src/runtime/product_surface/projection.rs": { "bytes": 23469, @@ -693,22 +699,22 @@ "sha256": "4e1dbc7f9e76ee4df62bedb915e0d92bd713c4408a4a5e1c9508b84e6e39cdc2" }, "core/crates/tera_core/src/runtime/product_surface/settings.rs": { - "bytes": 56812, - "git_blob": "58a13c691a8916c8d993fc0739f91fe453c761fc", + "bytes": 57107, + "git_blob": "b0ea8912d1932e975529f0e8da70d6566eb7eb51", "mode": "100644", - "sha256": "18a82705b14e35ac716f4e9ae4ac39545c7ec9a8a4ded898e8c8febfa054938b" + "sha256": "76e3bddfc237dac63293d264d68991b489585136fe377e39dbba72aa3a1181f4" }, "core/crates/tera_core/src/runtime/product_surface/today.rs": { - "bytes": 134254, - "git_blob": "233c943ee7c79af245e2540c35750aa719585870", + "bytes": 135116, + "git_blob": "f8f68924ffb05011e8e20e1e310c003738c7ae2f", "mode": "100644", - "sha256": "dcd7884d8fbda59e439551110abff68cc4069a5aae6cdc5376d57367afcd7a70" + "sha256": "d77c115493a88799fd160ba14209680db60a1c65cfc6dd692a20b285e18d1ef3" }, "core/crates/tera_core/src/runtime/sdk.rs": { - "bytes": 20071, - "git_blob": "0d9704a604b3ad539c253da6670bc706acad2599", + "bytes": 20647, + "git_blob": "5d1ac4c74cb1c31e8b94f23e4cd93c85365fafa3", "mode": "100644", - "sha256": "102d47aae3704bec56c9a4594ba5f54b66af277c9986a8ce1f24f80e7f2473f1" + "sha256": "da29c6b554d5b08de7869cde9fd1c744153d23512ee4fca7a371dd5340b45c33" }, "core/crates/tera_core/src/runtime/store.rs": { "bytes": 8998, @@ -741,10 +747,10 @@ "sha256": "a6bf46083940aa3d248796ad18c64adfcd3362a9ae4ce1167edf85305574d29a" }, "core/crates/tera_ffi/Cargo.toml": { - "bytes": 1941, - "git_blob": "44eb2d02c00ae2472ed1e0b1a9496cf79fdfc630", + "bytes": 1991, + "git_blob": "0eb2a678db01e885b3763f459bfbadedf25a9ce3", "mode": "100644", - "sha256": "146992d4a39f0c2688b5d1f49509b1bf02765f885b00a842fc698690f3f45197" + "sha256": "f96248270139fbd131bf5290bf97560b7e12140d76e4fe77a071f92d6e76e451" }, "core/crates/tera_ffi/src/dto.rs": { "bytes": 110139, @@ -753,10 +759,10 @@ "sha256": "0f19070bd3beb9dbbc871ceca44c30e590ea2d38e875339fe9dbcde749b3aa6e" }, "core/crates/tera_ffi/src/error.rs": { - "bytes": 19532, - "git_blob": "5efd4b1d83573a9d458b595074d28ba78e94e036", + "bytes": 19893, + "git_blob": "2313f32ede9e60334e68f137f86249810d349b19", "mode": "100644", - "sha256": "94298868ee6b7548b83fac0a12b7637eb9e94963168bf654e98f7c3ade2fa12f" + "sha256": "f02e95e9eb45a5d2775e9447e410d44d8e6111192772fdc9f4d4395594228f82" }, "core/crates/tera_ffi/src/lib.rs": { "bytes": 1334, @@ -789,10 +795,10 @@ "sha256": "23bcabc178b5891a50ee249098d7093ad18f00c16fa1100fdcbc209ff86797f7" }, "core/crates/tera_ffi/src/runtime.rs": { - "bytes": 41440, - "git_blob": "131e0f0035e3e9cbb6ec520e16d571ee962fabcd", + "bytes": 41387, + "git_blob": "98de223245f00addb41550cd22f34201603f09bb", "mode": "100644", - "sha256": "e7e92c3fe2c5a7663a2967b1eb2f1caee6285c80e4a07bd2bbd18a157774b9b3" + "sha256": "eee94163c970c4517cae918794f1738fe262c24950b97232c1c96ca8522fac2a" }, "core/crates/tera_ffi/src/signer.rs": { "bytes": 13853, @@ -801,10 +807,10 @@ "sha256": "4c0800499f248e61011f7ffe78968e0d8afb13ddfe2332390fb25de444f7e6b7" }, "core/crates/tera_ffi/src/subscription.rs": { - "bytes": 10301, - "git_blob": "7ade122c46c8bd36480b9e7540a013ed45ced3e7", + "bytes": 14401, + "git_blob": "39391b0105c50bc89e9ea2d34b1d7374f056c28b", "mode": "100644", - "sha256": "4c57fca52ef318b6b052aebe7ff7e32566d4546c059990d2bd141c429bfb325f" + "sha256": "eccbd6f0b549704bf53874d53da355b9702dbd5e5262bc384443340cece68f56" }, "core/crates/tera_ffi/tests/compatibility.rs": { "bytes": 7548, @@ -831,10 +837,10 @@ "sha256": "ec87c2cc7f396ef583d3a37186f40932ac4eb42c0e80973a5c25312099f2670a" }, "core/crates/tera_ffi/tests/recovery_contract.rs": { - "bytes": 6875, - "git_blob": "61ad2f9c1d96b68c3d93ab6f8933ceaa4896fd6c", + "bytes": 7971, + "git_blob": "df270d8d7d953a41b71e67c8a24514f472005c86", "mode": "100644", - "sha256": "9e85f2e0f6d27be0650e16835b57839e3351896dafcf6323b308bcc2e30d7d0b" + "sha256": "1d2e6015400b2ba03819ed01bae674817a18a2c8e626ba3dfeca24dc2fb8763c" }, "core/crates/tera_ffi/tests/runtime_delegation.rs": { "bytes": 30665, @@ -843,10 +849,10 @@ "sha256": "33843d89f28ff874c1f2ab73064e585c5497f7d62b8ec8e1732a44f9a726d819" }, "core/crates/tera_ffi/tests/runtime_lifecycle.rs": { - "bytes": 1459, - "git_blob": "ff2431237aa8b2c4e89dbfcaef637b3cf78488bd", + "bytes": 4293, + "git_blob": "43dd277cd2439a8db72eaca60e4f07e2ae21317a", "mode": "100644", - "sha256": "cc62d835fd85bca7ee7fd41af3c7358225d5e9b03de12d2465d6c0aa3fb87053" + "sha256": "613426aec508436234134b2ebb14c1d5f6339c93b108f4f92674e61810937738" }, "core/crates/tera_ffi/tests/subscription_contract.rs": { "bytes": 1997, @@ -1197,13 +1203,13 @@ "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d" }, "test-fixtures/legacy-identifiers.v1.json": { - "bytes": 112691, - "git_blob": "c182eb3f94b158ed66926f7216b994434697a0f8", + "bytes": 112911, + "git_blob": "e61b1bd3193280c5654f7053f3ebfb0f80d4b521", "mode": "100644", - "sha256": "28f66e949f0d7335a47ff7799e077a985e5450bdd4fe0315ecf757e52b00bc32" + "sha256": "c19cca2d21ba5297dd22a88b1f485af8369323d84f878cb2f43a112844311df4" } }, "policy": "staged_inputs", - "tree": "f6bfeb00965c4cf264b5f74e488427092965762f" + "tree": "f4a8a67798881cd35e23c5817dc831c71305d61a" } } diff --git a/TeraTests/TeraLifecycleTests.swift b/TeraTests/TeraLifecycleTests.swift @@ -65,7 +65,7 @@ final class TeraLifecycleTests: XCTestCase { func testLifecycleBridgeRunsRegisteredShutdownOnlyOnce() async { let bridge = TeraLifecycleBridge() let shutdowns = CompletionProbe() - await bridge.register { shutdowns.increment() } + await bridge.register { shutdowns.increment(); return true } await bridge.requestShutdown() await bridge.requestShutdown() diff --git a/TeraTests/TeraRuntimeBoundedTaskTests.swift b/TeraTests/TeraRuntimeBoundedTaskTests.swift @@ -69,6 +69,22 @@ final class TeraRuntimeBoundedTaskTests: XCTestCase { await assertOutcome(task.value(), terminal: .completion) } + func testCallerCancellationDoesNotClaimActualOperationSettlement() async { + let pause = ResourceTestPause() + let task = Bounded(deadlineNanoseconds: .max) { + await pause.wait() + return .success(42) + } + await pause.entered.wait() + task.cancel() + await assertOutcome(task.value(), terminal: .cancellation) + XCTAssertNil(task.settlement()) + await pause.resume.open() + let actual = await task.settle() + guard case .success(42) = actual else { return XCTFail("Preserve the actual late result") } + await assertOutcome(task.value(), terminal: .cancellation) + } + private func assertInstallation(terminal: Terminal, terminalFirst: Bool) async { let state = Bounded.State() let gate = BoundedTestGate() @@ -91,7 +107,7 @@ final class TeraRuntimeBoundedTaskTests: XCTestCase { await gate.open() await operation.value await timer.value - state.finishOperation() + state.finishOperation(.success(42)) } private func assertOutcome(_ outcome: Bounded.Outcome, terminal: Terminal) { diff --git a/TeraTests/TeraRuntimeClientTests.swift b/TeraTests/TeraRuntimeClientTests.swift @@ -162,7 +162,7 @@ final class TeraRuntimeClientTests: XCTestCase { XCTAssertEqual(shutdownCount, 1) } - func testStopDoesNotWaitForCancellationIgnoringStartup() async throws { + func testStopDrainsCancellationIgnoringStartup() async throws { let harness = RuntimeHarness(startDelayNanoseconds: 100_000_000) let client = TeraRuntimeClient(factory: harness.start) let configuration = makeConfiguration(generation: "08") @@ -171,19 +171,17 @@ final class TeraRuntimeClientTests: XCTestCase { } 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)) + let completedCloses = await harness.shutdownCount() + XCTAssertEqual(completedCloses, 1) do { _ = try await startup.value XCTFail("The cancelled startup must not claim backend ownership") } catch { XCTAssertEqual(error as? TeraRuntimeClientError, .superseded) } - try await Task.sleep(nanoseconds: 120_000_000) let shutdownCount = await harness.shutdownCount() XCTAssertEqual(shutdownCount, 1) } diff --git a/TeraTests/TeraRuntimeResourceFixtures.swift b/TeraTests/TeraRuntimeResourceFixtures.swift @@ -50,6 +50,9 @@ actor ResourceTestBackend: TeraRuntimeBackend { private var snapshotPause: ResourceTestPause? private var settingsPause: ResourceTestPause? private var profilePause: ResourceTestPause? + private var shutdownPause: ResourceTestPause? + private var shutdownFailure: TeraRuntimeFailure? + private var shutdownCompleted = false private(set) var profileMutations = 0 private var receive: (@Sendable (TeraRuntimeChange) async -> Void)? @@ -77,6 +80,14 @@ actor ResourceTestBackend: TeraRuntimeBackend { settingsPause = pause } + func pauseShutdown(_ pause: ResourceTestPause) { + shutdownPause = pause + } + + func failShutdownOnce(_ failure: TeraRuntimeFailure) { + shutdownFailure = failure + } + func pauseProfile(_ pause: ResourceTestPause) { profilePause = pause } @@ -140,10 +151,19 @@ actor ResourceTestBackend: TeraRuntimeBackend { )) } - func shutdown() async -> TeraRuntimeShutdownReceipt { + func shutdown() async throws -> TeraRuntimeShutdownReceipt { shutdownCount += 1 + let pause = shutdownPause + shutdownPause = nil + await pause?.wait() + if let failure = shutdownFailure { + shutdownFailure = nil + throw failure + } + let alreadyClosed = shutdownCompleted + shutdownCompleted = true await closed.open() - return TeraRuntimeShutdownReceipt(state: "closed", alreadyClosed: shutdownCount > 1) + return TeraRuntimeShutdownReceipt(state: "closed", alreadyClosed: alreadyClosed) } func mobileSettings() async -> TeraMobileSettings { diff --git a/TeraTests/TeraRuntimeResourceLifecycleTests.swift b/TeraTests/TeraRuntimeResourceLifecycleTests.swift @@ -61,7 +61,7 @@ final class TeraRuntimeResourceLifecycleTests: XCTestCase { }) let first = Task { try await client.start(configuration: firstConfig) } await pause.entered.wait() - _ = try await client.start(configuration: nextConfig) + let replacement = Task { try await client.start(configuration: nextConfig) } do { _ = try await first.value XCTFail("Old startup must be superseded") @@ -70,6 +70,7 @@ final class TeraRuntimeResourceLifecycleTests: XCTestCase { } await pause.resume.open() await assertClosed(old) + _ = try await replacement.value let snapshot = try await client.snapshot() let activeCloses = await current.shutdownCount XCTAssertEqual(snapshot.identity.publicKeyHex, nextConfig.publicKeyHex) @@ -124,9 +125,10 @@ final class TeraRuntimeResourceLifecycleTests: XCTestCase { _ = try await client.start(configuration: configuration) let creation = Task { try await client.changes() } await pause.entered.wait() + var stop: Task<TeraRuntimeShutdownReceipt, Error>? switch termination { case .cancel: creation.cancel() - case .stop: _ = try await client.stop() + case .stop: stop = Task { try await client.stop() } case .timeout: break } do { @@ -146,6 +148,7 @@ final class TeraRuntimeResourceLifecycleTests: XCTestCase { await fulfillment(of: [detached], timeout: 2) await backend.token.cancelled.open() await wait.value + _ = try await stop?.value _ = try await client.stop() let count = await backend.token.cancelCount XCTAssertEqual(count, 1) diff --git a/TeraTests/TeraRuntimeShutdownTests.swift b/TeraTests/TeraRuntimeShutdownTests.swift @@ -0,0 +1,185 @@ +@testable import TeraApp +import XCTest + +final class TeraRuntimeShutdownTests: XCTestCase { + func testTimeoutRetainsActualOperationAndRepeatedCloseDrainsIt() async throws { + let configuration = TeraRuntimeClientTests().makeConfiguration(generation: "70") + let backend = ResourceTestBackend(publicKeyHex: configuration.publicKeyHex) + let pause = ResourceTestPause() + let client = TeraRuntimeClient( + factory: { _ in await backend.start() }, + deadlines: TeraRuntimeDeadlinePolicy(shutdownNanoseconds: 1_000_000) + ) + _ = try await client.start(configuration: configuration) + await backend.pauseSnapshot(pause) + let operation = Task { try await client.snapshot() } + await pause.entered.wait() + await assertShutdownDeadline(client) + let prematureCloses = await backend.shutdownCount + XCTAssertEqual(prematureCloses, 0) + await assertAdmissionClosed(client) + await assertShutdownDeadline(client) + let duplicateCloses = await backend.shutdownCount + XCTAssertEqual(duplicateCloses, 0) + await pause.resume.open() + _ = await operation.result + await awaitClosed(backend) + _ = try await client.stop() + let closes = await backend.shutdownCount + XCTAssertEqual(closes, 1) + } + + func testCancellingCloseWaiterKeepsOneSharedNativeClose() async throws { + let configuration = TeraRuntimeClientTests().makeConfiguration(generation: "71") + let backend = ResourceTestBackend(publicKeyHex: configuration.publicKeyHex) + let pause = ResourceTestPause() + let client = TeraRuntimeClient(factory: { _ in await backend.start() }) + _ = try await client.start(configuration: configuration) + await backend.pauseShutdown(pause) + let cancelled = Task { try await client.stop() } + await pause.entered.wait() + cancelled.cancel() + do { + _ = try await cancelled.value + XCTFail("The caller should stop waiting") + } catch let TeraRuntimeClientError.shutdown(failure) { + XCTAssertEqual(failure.code, "ios.runtime.cancelled") + } + await assertAdmissionClosed(client) + let survivor = Task { try await client.stop() } + await pause.resume.open() + let receipt = try await survivor.value + XCTAssertEqual(receipt.state, "closed") + _ = try await client.stop() + let closes = await backend.shutdownCount + XCTAssertEqual(closes, 1) + } + + func testProtectedStorageFailureRetainsBackendForExplicitRetry() async throws { + let configuration = TeraRuntimeClientTests().makeConfiguration(generation: "72") + let backend = ResourceTestBackend(publicKeyHex: configuration.publicKeyHex) + let failure = TeraRuntimeFailure.local( + operation: "test.close", code: "protected_data_unavailable", safeMessage: "Protected data unavailable" + ) + let client = TeraRuntimeClient(factory: { _ in await backend.start() }) + _ = try await client.start(configuration: configuration) + await backend.failShutdownOnce(failure) + do { + _ = try await client.stop() + XCTFail("Close failure must survive") + } catch let TeraRuntimeClientError.shutdown(observed) { XCTAssertEqual(observed, failure) } + await assertAdmissionClosed(client) + let receipt = try await client.stop() + XCTAssertFalse(receipt.alreadyClosed) + let closes = await backend.shutdownCount + XCTAssertEqual(closes, 2) + } + + func testShutdownWaitsForLateStartupAndItsNativeCleanup() async throws { + let configuration = TeraRuntimeClientTests().makeConfiguration(generation: "73") + let backend = ResourceTestBackend(publicKeyHex: configuration.publicKeyHex) + let creation = ResourceTestPause() + let cleanup = ResourceTestPause() + await backend.pauseShutdown(cleanup) + let client = TeraRuntimeClient( + factory: { _ in await creation.wait(); return await backend.start() }, + deadlines: TeraRuntimeDeadlinePolicy(shutdownNanoseconds: 1_000_000) + ) + let start = Task { try await client.start(configuration: configuration) } + await creation.entered.wait() + await assertShutdownDeadline(client) + _ = await start.result + do { + _ = try await client.start(configuration: configuration) + XCTFail("A new startup cannot pass outstanding cleanup") + } catch let TeraRuntimeClientError.shutdown(failure) { + XCTAssertEqual(failure.code, "ios.runtime.deadline_exceeded") + } + await creation.resume.open() + await cleanup.entered.wait() + await assertShutdownDeadline(client) + await cleanup.resume.open() + await awaitClosed(backend) + _ = try await client.stop() + let closes = await backend.shutdownCount + XCTAssertEqual(closes, 1) + } + + func testFailedLateStartupCleanupIsRetainedAndRetried() async throws { + let pause = ResourceTestPause() + let configuration = TeraRuntimeClientTests().makeConfiguration(generation: "74") + let backend = ResourceTestBackend(publicKeyHex: configuration.publicKeyHex) + await backend.failShutdownOnce(.local(operation: "test.close", code: "protected_data_unavailable", safeMessage: "Locked")) + let client = TeraRuntimeClient(factory: { _ in await pause.wait(); return await backend.start() }) + let start = Task { try await client.start(configuration: configuration) } + await pause.entered.wait() + start.cancel() + _ = await start.result + await pause.resume.open() + // The cleanup owner may already have observed the first failure. Either + // close reports it, or this explicit request resumes that failed cleanup. + do { + _ = try await client.stop() + } catch let TeraRuntimeClientError.shutdown(failure) { + XCTAssertEqual(failure.code, "protected_data_unavailable") + _ = try await client.stop() + } + let closes = await backend.shutdownCount + XCTAssertEqual(closes, 2) + let lifecycle = await client.lifecycle() + XCTAssertEqual(lifecycle, .stopped) + } + + func testLifecycleBridgeSharesCloseAndRetainsFailedRegistration() async { + let bridge = TeraLifecycleBridge() + let attempt = ShutdownAttemptFixture() + await bridge.register { await attempt.close() } + let first = Task { await bridge.requestShutdown() } + await attempt.pause.entered.wait() + first.cancel() + let second = Task { await bridge.requestShutdown() } + await attempt.pause.resume.open() + await first.value + await second.value + await bridge.requestShutdown() + await bridge.requestShutdown() + let count = await attempt.calls + XCTAssertEqual(count, 2) + } + + private func assertAdmissionClosed(_ client: TeraRuntimeClient) async { + do { + _ = try await client.snapshot() + XCTFail("Closing runtime admitted a command") + } catch { XCTAssertEqual(error as? TeraRuntimeClientError, .notRunning) } + } + + private func assertShutdownDeadline(_ client: TeraRuntimeClient) async { + do { + _ = try await client.stop() + XCTFail("Outstanding work cannot report closed") + } catch let TeraRuntimeClientError.shutdown(failure) { + XCTAssertEqual(failure.code, "ios.runtime.deadline_exceeded") + } catch { XCTFail("Unexpected close failure: \(error)") } + } + + private func awaitClosed(_ backend: ResourceTestBackend) async { + let completed = expectation(description: "Native close completed") + let wait = Task { await backend.closed.wait(); completed.fulfill() } + await fulfillment(of: [completed], timeout: 2) + await backend.closed.open() + await wait.value + } +} + +private actor ShutdownAttemptFixture { + let pause = ResourceTestPause() + private(set) var calls = 0 + func close() async -> Bool { + calls += 1 + if calls == 1 { + await pause.wait(); return false + } + return true + } +} diff --git a/core/crates/tera_core/src/runtime/lifecycle.rs b/core/crates/tera_core/src/runtime/lifecycle.rs @@ -0,0 +1,229 @@ +//! Caller-owned command leases and one resumable close attempt. +//! +//! Closing admission is permanent. No lock crosses an await, no task or runtime +//! is created here, and dropping a lease never edits durable operation evidence. + +use std::{ + future::poll_fn, + sync::Mutex, + task::{Poll, Waker}, +}; + +use radroots_protocol::error::v1::{KnownCode, SCHEMA_VERSION}; + +use crate::{SdkErrorRecord, TeraAppError}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum RuntimeLifecycleError { + #[error("runtime close requires completion or retry")] + Closing, + #[error("runtime is closed")] + Closed, + #[error("runtime close is in progress")] + CloseInProgress, + #[error("runtime lifecycle is unavailable")] + Unavailable, +} + +impl RuntimeLifecycleError { + pub const fn code(self) -> &'static str { + self.known_code().as_str() + } + + const fn known_code(self) -> KnownCode { + match self { + Self::Closing => KnownCode::ClientClosing, + Self::Closed => KnownCode::ClientClosed, + Self::CloseInProgress => KnownCode::ClientCloseInProgress, + Self::Unavailable => KnownCode::InternalError, + } + } +} + +impl From<RuntimeLifecycleError> for TeraAppError { + fn from(error: RuntimeLifecycleError) -> Self { + let descriptor = error.known_code().descriptor(); + let sdk = match error { + RuntimeLifecycleError::Closing => Some(radroots_sdk::error::ErrorKind::ClientClosing), + RuntimeLifecycleError::Closed => Some(radroots_sdk::error::ErrorKind::ClientClosed), + RuntimeLifecycleError::CloseInProgress => { + Some(radroots_sdk::error::ErrorKind::CloseInProgress) + } + RuntimeLifecycleError::Unavailable => None, + } + .map(radroots_sdk::error::ErrorKind::descriptor); + Self::Sdk { + report: SdkErrorRecord { + schema_version: SCHEMA_VERSION, + code: error.code().to_owned(), + class: descriptor.class.as_str().to_owned(), + retryable: descriptor.retryable, + recovery_actions: descriptor + .recovery_actions + .iter() + .map(|action| action.as_str().to_owned()) + .collect(), + operation_id: None, + capability_id: sdk + .and_then(|value| value.capability()) + .map(|value| value.as_str().to_owned()), + message: sdk.map_or_else(|| error.to_string(), |value| value.message().to_owned()), + }, + } + } +} + +#[derive(Default)] +struct State { + closing: bool, + active: usize, + close_active: bool, + completed: Option<Result<(), SdkErrorRecord>>, + close_waker: Option<Waker>, +} + +#[derive(Default)] +pub(super) struct RuntimeLifecycle { + state: Mutex<State>, +} + +impl RuntimeLifecycle { + pub(super) fn enter(&self) -> Result<CommandLease<'_>, RuntimeLifecycleError> { + let mut state = self + .state + .lock() + .map_err(|_| RuntimeLifecycleError::Unavailable)?; + if state.closing { + return Err(if state.completed.is_some() { + RuntimeLifecycleError::Closed + } else { + RuntimeLifecycleError::Closing + }); + } + state.active = state + .active + .checked_add(1) + .ok_or(RuntimeLifecycleError::Unavailable)?; + Ok(CommandLease(self)) + } + + pub(super) fn begin_close(&self) -> Result<CloseAttempt<'_>, RuntimeLifecycleError> { + let mut state = self + .state + .lock() + .map_err(|_| RuntimeLifecycleError::Unavailable)?; + state.closing = true; + if state.close_active { + return Err(RuntimeLifecycleError::CloseInProgress); + } + state.close_active = true; + Ok(CloseAttempt(self)) + } +} + +pub(super) struct CommandLease<'a>(&'a RuntimeLifecycle); + +impl Drop for CommandLease<'_> { + fn drop(&mut self) { + let wake = if let Ok(mut state) = self.0.state.lock() { + state.active -= 1; + if state.active == 0 { + state.close_waker.take() + } else { + None + } + } else { + None + }; + if let Some(wake) = wake { + wake.wake(); + } + } +} + +pub(super) struct CloseAttempt<'a>(&'a RuntimeLifecycle); + +impl CloseAttempt<'_> { + pub(super) async fn drain(&self) -> Result<(), RuntimeLifecycleError> { + poll_fn(|context| { + let Ok(mut state) = self.0.state.lock() else { + return Poll::Ready(Err(RuntimeLifecycleError::Unavailable)); + }; + if state.active == 0 { + Poll::Ready(Ok(())) + } else { + state.close_waker = Some(context.waker().clone()); + Poll::Pending + } + }) + .await + } + + pub(super) fn completed( + &self, + ) -> Result<Option<Result<(), SdkErrorRecord>>, RuntimeLifecycleError> { + let state = self + .0 + .state + .lock() + .map_err(|_| RuntimeLifecycleError::Unavailable)?; + Ok(state.completed.clone()) + } + + pub(super) fn complete( + &self, + result: Result<(), SdkErrorRecord>, + ) -> Result<(), RuntimeLifecycleError> { + let mut state = self + .0 + .state + .lock() + .map_err(|_| RuntimeLifecycleError::Unavailable)?; + state.completed = Some(result); + Ok(()) + } +} + +impl Drop for CloseAttempt<'_> { + fn drop(&mut self) { + if let Ok(mut state) = self.0.state.lock() { + state.close_active = false; + state.close_waker = None; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn completed_close_failure_is_replayed_without_false_success() { + let runtime = crate::TeraRuntime::test_memory().unwrap(); + let report = SdkErrorRecord { + schema_version: 1, + code: "storage_close_failed".to_owned(), + class: "storage".to_owned(), + retryable: false, + recovery_actions: vec!["inspect_local_stores".to_owned()], + operation_id: None, + capability_id: Some("storage.canonical".to_owned()), + message: "SDK storage close failed".to_owned(), + }; + // Inject the exact terminal SDK report at the close commit point. + // This exercises repeat-close behavior without changing the producer. + runtime + .lifecycle + .begin_close() + .unwrap() + .complete(Err(report.clone())) + .unwrap(); + for _ in 0..2 { + let TeraAppError::Sdk { report: replay } = runtime.shutdown().await.unwrap_err() else { + panic!("stable close report"); + }; + assert_eq!(replay, report); + assert!(runtime.sdk_storage_status().await.is_err()); + } + } +} diff --git a/core/crates/tera_core/src/runtime/mod.rs b/core/crates/tera_core/src/runtime/mod.rs @@ -1,6 +1,7 @@ pub mod app_info; pub mod builder; pub mod info; +pub mod lifecycle; #[cfg(feature = "mobile-social")] mod mutation_admission; pub mod product_surface; @@ -27,6 +28,7 @@ pub struct TeraRuntime { pub(crate) client: Client, pub(crate) started_unix_ms: i64, pub(crate) shutting_down: AtomicBool, + lifecycle: lifecycle::RuntimeLifecycle, pub(crate) platform_app: RwLock<Option<AppInfoPlatform>>, pub(crate) store_public_key: Option<PublicKey>, #[cfg(feature = "mobile-social")] @@ -86,6 +88,7 @@ impl TeraRuntime { client, started_unix_ms: Utc::now().timestamp_millis(), shutting_down: AtomicBool::new(false), + lifecycle: lifecycle::RuntimeLifecycle::default(), platform_app: RwLock::new(None), store_public_key, #[cfg(feature = "mobile-social")] @@ -120,13 +123,30 @@ impl TeraRuntime { /// Closes SDK resources asynchronously across every runtime reference. /// /// Dropping the returned future before its first poll has no effect. If a - /// host cancels after close begins, it must call `shutdown` again; the SDK - /// remains unavailable and resumes the explicit close attempt. Completed - /// calls are idempotent and no blocking destructor is installed. + /// host cancels after close begins, it must call `shutdown` again. Command + /// admission remains closed while existing caller-owned work drains. The + /// SDK remains the storage owner; its terminal close failure is retained. + /// Closing storage does not erase durable claims or imply external rollback. + /// No worker, executor, or blocking destructor is installed. pub async fn shutdown(&self) -> Result<sdk::SdkShutdownRecord, TeraAppError> { - let already_closed = self.client.is_closed(); + let attempt = self.lifecycle.begin_close()?; self.shutting_down.store(true, Ordering::Release); - self.client.close().await.map_err(TeraAppError::from_sdk)?; + attempt.drain().await?; + let completed = attempt.completed()?; + let already_closed = completed.is_some(); + let result = if let Some(result) = completed { + result + } else { + let result = self.client.close().await.map_err(|error| { + let TeraAppError::Sdk { report } = TeraAppError::from_sdk(error) else { + unreachable!("SDK conversion always retains its typed report") + }; + report + }); + attempt.complete(result.clone())?; + result + }; + result.map_err(|report| TeraAppError::Sdk { report })?; Ok(sdk::SdkShutdownRecord { state: "closed".to_owned(), already_closed, @@ -160,6 +180,9 @@ impl TeraRuntime { build_number: Option<String>, build_sha: Option<String>, ) { + let Ok(_command) = self.lifecycle.enter() else { + return; + }; let platform_info = AppInfoPlatform::new(platform, bundle_id, version, build_number, build_sha); if let Ok(mut guard) = self.platform_app.write() { diff --git a/core/crates/tera_core/src/runtime/product_surface/outbox.rs b/core/crates/tera_core/src/runtime/product_surface/outbox.rs @@ -966,6 +966,8 @@ impl Phase1DraftStatus { #[derive(Clone, Debug, Error, Eq, PartialEq)] pub enum Phase1DraftError { + #[error(transparent)] + Lifecycle(#[from] crate::runtime::lifecycle::RuntimeLifecycleError), #[error("authenticated draft identity is unavailable")] IdentityUnavailable, #[error("phase 1 draft input is invalid")] @@ -1222,6 +1224,7 @@ impl TeraRuntime { &self, command: ProfileMetadataCommand, ) -> Result<Phase1ProfileStatus, Phase1DraftError> { + let _command = self.lifecycle.enter()?; let now_unix_ms = phase1_operation_now_unix_ms()?; let author = self.draft_author()?; let draft_id = AuthoredDraftId::new(phase1_random_id()?) @@ -1259,6 +1262,7 @@ impl TeraRuntime { &self, draft_id: [u8; 16], ) -> Result<Phase1ProfileStatus, Phase1DraftError> { + let _command = self.lifecycle.enter()?; let draft_id = AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; let head = self @@ -1276,6 +1280,7 @@ impl TeraRuntime { &self, draft_id: [u8; 16], ) -> Result<Phase1ProfileStatus, Phase1DraftError> { + let _command = self.lifecycle.enter()?; let _admission = self.mutations.draft(draft_id)?; let mut status = self.phase1_profile_status(draft_id).await?; if status.draft.stage() == AuthoredDraftStage::Draft { @@ -1351,6 +1356,7 @@ impl TeraRuntime { draft_id: [u8; 16], expected_revision: u64, ) -> Result<Phase1ProfileStatus, Phase1DraftError> { + let _command = self.lifecycle.enter()?; let _admission = self.mutations.draft(draft_id)?; let draft_id = AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; @@ -1399,6 +1405,7 @@ impl TeraRuntime { &self, intent: Phase1AddIntent, ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let _command = self.lifecycle.enter()?; let now_unix_ms = phase1_operation_now_unix_ms()?; let authored_at_unix_s = now_unix_ms / 1_000; let (draft_id, expected_revision) = match intent.existing { @@ -1426,6 +1433,7 @@ impl TeraRuntime { &self, intent: Phase1QueueIntent, ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let _command = self.lifecycle.enter()?; let now_unix_ms = phase1_operation_now_unix_ms()?; let policy = self.active_queue_policy(now_unix_ms)?; self.phase1_queue_draft( @@ -1468,6 +1476,7 @@ impl TeraRuntime { &self, draft_id: [u8; 16], ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let _command = self.lifecycle.enter()?; self.phase1_recover_draft_queue(draft_id, phase1_operation_now_unix_ms()?) .await } @@ -1478,6 +1487,7 @@ impl TeraRuntime { &self, intent: Phase1UploadIntent, ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let _command = self.lifecycle.enter()?; let now_unix_ms = phase1_operation_now_unix_ms()?; let plan = Phase1UploadPlan::derive(now_unix_ms, phase1_random_id()?, phase1_random_id()?)?; let request = radroots_sdk::transport::BlossomUploadRequest::new( @@ -1515,6 +1525,7 @@ impl TeraRuntime { &self, intent: Phase1UploadIntent, ) -> Result<(Phase1DraftStatus, Phase1NativeUploadJob), Phase1DraftError> { + let _command = self.lifecycle.enter()?; let admission = self.mutations.draft(*intent.draft_id.as_bytes())?; let now_unix_ms = phase1_operation_now_unix_ms()?; let plan = Phase1UploadPlan::derive(now_unix_ms, phase1_random_id()?, phase1_random_id()?)?; @@ -1589,6 +1600,7 @@ impl TeraRuntime { response_content_encoding: Option<&str>, response_body: &[u8], ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let _command = self.lifecycle.enter()?; let admission = self.mutations.draft(*intent.draft_id.as_bytes())?; let now_unix_ms = phase1_operation_now_unix_ms()?; let request = radroots_sdk::transport::BlossomUploadRequest::new( @@ -1650,6 +1662,7 @@ impl TeraRuntime { draft_id: [u8; 16], expected_revision: u64, ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let _command = self.lifecycle.enter()?; self.phase1_cancel_draft(draft_id, expected_revision, phase1_operation_now_unix_ms()?) .await } @@ -1661,6 +1674,7 @@ impl TeraRuntime { &self, intent: Phase1ReviseIntent, ) -> Result<Phase1RevisionStatus, Phase1DraftError> { + let _command = self.lifecycle.enter()?; let now_unix_ms = phase1_operation_now_unix_ms()?; let authored_at_unix_s = now_unix_ms / 1_000; let author = self.draft_author()?; @@ -1723,6 +1737,7 @@ impl TeraRuntime { &self, replacement_draft_id: [u8; 16], ) -> Result<Phase1RevisionStatus, Phase1DraftError> { + let _command = self.lifecycle.enter()?; let replacement = self.phase1_draft_status(replacement_draft_id).await?; let payload = Phase1DraftPayload::decode(replacement.draft())?; let revision = payload.revision.ok_or(Phase1DraftError::InvalidRevision)?; @@ -1753,6 +1768,7 @@ impl TeraRuntime { &self, replacement_draft_id: [u8; 16], ) -> Result<Phase1RevisionStatus, Phase1DraftError> { + let _command = self.lifecycle.enter()?; let admission = self.mutations.draft(replacement_draft_id)?; let mut status = self.phase1_revision_status(replacement_draft_id).await?; if matches!( @@ -1835,6 +1851,7 @@ impl TeraRuntime { &self, replacement_draft_id: [u8; 16], ) -> Result<Phase1RevisionStatus, Phase1DraftError> { + let _command = self.lifecycle.enter()?; let admission = self.mutations.draft(replacement_draft_id)?; let mut status = self.phase1_revision_status(replacement_draft_id).await?; if !matches!( @@ -1917,6 +1934,7 @@ impl TeraRuntime { expected_revision: Option<u64>, persisted_at_unix_ms: u64, ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let _command = self.lifecycle.enter()?; self.phase1_save_draft_inner( draft_id, command, @@ -1941,6 +1959,7 @@ impl TeraRuntime { expected_revision: Option<u64>, persisted_at_unix_ms: u64, ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let _command = self.lifecycle.enter()?; self.phase1_save_draft_inner( draft_id, command, @@ -2036,6 +2055,7 @@ impl TeraRuntime { authored_at_unix_s: u64, persisted_at_unix_ms: u64, ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let _command = self.lifecycle.enter()?; let admission = self.mutations.draft(draft_id)?; self.phase1_save_retraction_draft_admitted( &admission, @@ -2129,6 +2149,7 @@ impl TeraRuntime { failure_code: Option<String>, updated_at_unix_ms: u64, ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let _command = self.lifecycle.enter()?; let admission = self.mutations.draft(draft_id)?; self.phase1_update_draft_media_admitted( &admission, @@ -2212,6 +2233,7 @@ impl TeraRuntime { receipt: radroots_sdk::transport::BlossomUploadReceipt, updated_at_unix_ms: u64, ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let _command = self.lifecycle.enter()?; let admission = self.mutations.draft(draft_id)?; self.phase1_complete_draft_media_admitted( &admission, @@ -2287,6 +2309,7 @@ impl TeraRuntime { error: &radroots_sdk::transport::BlossomError, updated_at_unix_ms: u64, ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let _command = self.lifecycle.enter()?; let admission = self.mutations.draft(draft_id)?; self.phase1_fail_draft_media_admitted( &admission, @@ -2374,6 +2397,7 @@ impl TeraRuntime { policy: Phase1QueuePolicy, queued_at_unix_ms: u64, ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let _command = self.lifecycle.enter()?; let admission = self.mutations.draft(draft_id)?; self.phase1_queue_draft_admitted( &admission, @@ -2462,6 +2486,7 @@ impl TeraRuntime { draft_id: [u8; 16], recovered_at_unix_ms: u64, ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let _command = self.lifecycle.enter()?; let _admission = self.mutations.draft(draft_id)?; let draft_id = AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; @@ -2492,6 +2517,7 @@ impl TeraRuntime { draft_id: [u8; 16], expected_revision: u64, ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let _command = self.lifecycle.enter()?; let _admission = self.mutations.draft(draft_id)?; let draft_id = AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; @@ -2520,6 +2546,7 @@ impl TeraRuntime { draft_id: [u8; 16], expected_revision: u64, ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let _command = self.lifecycle.enter()?; let admission = self.mutations.draft(draft_id)?; self.phase1_advance_draft_admitted(&admission, draft_id, expected_revision) .await @@ -2607,6 +2634,7 @@ impl TeraRuntime { deadline_unix_ms: u64, cancellation: Phase1CancellationPolicy, ) -> Result<radroots_sdk::signing::AuthorizationHeader, Phase1DraftError> { + let _command = self.lifecycle.enter()?; let public_key = self .store_public_key .ok_or(Phase1DraftError::IdentityUnavailable)?; @@ -2658,6 +2686,7 @@ impl TeraRuntime { transfer_cancellation: radroots_sdk::transport::BlossomCancellation, updated_at_unix_ms: u64, ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let _command = self.lifecycle.enter()?; let admission = self.mutations.draft(draft_id)?; let blossom = self .client @@ -2761,6 +2790,7 @@ impl TeraRuntime { &self, draft_id: [u8; 16], ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let _command = self.lifecycle.enter()?; let draft_id = AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; let head = self @@ -2777,6 +2807,7 @@ impl TeraRuntime { &self, limit: u16, ) -> Result<Vec<Phase1DraftStatus>, Phase1DraftError> { + let _command = self.lifecycle.enter()?; let drafts = self .storage()? .authored_draft_heads(self.draft_author()?, limit) @@ -2797,6 +2828,7 @@ impl TeraRuntime { expected_revision: u64, cancelled_at_unix_ms: u64, ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let _command = self.lifecycle.enter()?; let admission = self.mutations.draft(draft_id)?; self.phase1_cancel_draft_admitted( &admission, @@ -2865,6 +2897,7 @@ impl TeraRuntime { context: &LocalNetwork, draft_id: [u8; 16], ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let _command = self.lifecycle.enter()?; let _admission = self.mutations.draft(draft_id)?; let status = self.phase1_draft_status(draft_id).await?; let operation_id = status diff --git a/core/crates/tera_core/src/runtime/product_surface/outbox/mutation_admission_tests.rs b/core/crates/tera_core/src/runtime/product_surface/outbox/mutation_admission_tests.rs @@ -326,3 +326,67 @@ async fn mutation_admission_upload_authorization_uses_the_existing_operation_ide assert!(!owner.await.unwrap().unwrap().as_str().is_empty()); assert_eq!(signer.signatures.load(Ordering::SeqCst), 1); } + +#[tokio::test] +async fn shutdown_drains_signing_and_cancelled_close_never_reopens_admission() { + use crate::runtime::lifecycle::RuntimeLifecycleError; + use std::{ + future::Future, + task::{Context, Poll, Waker}, + }; + let signer = PausedSigner::new(false, true); + let runtime = runtime(Arc::clone(&signer)); + let status = queued(&runtime, [8; 16]).await; + let revision = status.draft().revision().get(); + let owner = signing(&runtime, [8; 16], revision); + signer.wait().await; + let mut close = Box::pin(runtime.shutdown()); + assert!(matches!( + close.as_mut().poll(&mut Context::from_waker(Waker::noop())), + Poll::Pending + )); + assert!(!runtime.client.is_closed()); + assert!(runtime.info().app.shutting_down); + let crate::TeraAppError::Sdk { report } = runtime.shutdown().await.unwrap_err() else { + panic!("typed close failure"); + }; + assert_eq!(report.code, "client_close_in_progress"); + assert_eq!( + runtime + .phase1_sign_queued_draft([8; 16], revision) + .await + .unwrap_err(), + Phase1DraftError::Lifecycle(RuntimeLifecycleError::Closing) + ); + assert!(matches!( + runtime.phase1_settings().await, + Err(super::super::SettingsError::Lifecycle( + RuntimeLifecycleError::Closing + )) + )); + assert!(runtime.sdk_storage_status().await.is_err()); + drop(close); + assert_eq!( + runtime.phase1_draft_heads(10).await.unwrap_err(), + Phase1DraftError::Lifecycle(RuntimeLifecycleError::Closing) + ); + signer.resume.notify_one(); + let signed = owner.await.unwrap().unwrap(); + assert!(signed.push().unwrap().artifact().signed().is_some()); + assert_eq!(signer.signatures.load(Ordering::SeqCst), 1); + assert!(!runtime.shutdown().await.unwrap().already_closed); + assert!(runtime.shutdown().await.unwrap().already_closed); + assert_eq!( + runtime.phase1_draft_status([8; 16]).await.unwrap_err(), + Phase1DraftError::Lifecycle(RuntimeLifecycleError::Closed) + ); +} + +#[tokio::test] +async fn shutdown_of_an_unpolled_future_does_not_close_command_admission() { + let runtime = runtime(PausedSigner::new(false, false)); + drop(runtime.shutdown()); + assert!(!runtime.info().app.shutting_down); + assert!(runtime.phase1_settings().await.is_ok()); + assert_eq!(runtime.shutdown().await.unwrap().state, "closed"); +} diff --git a/core/crates/tera_core/src/runtime/product_surface/settings.rs b/core/crates/tera_core/src/runtime/product_surface/settings.rs @@ -851,6 +851,8 @@ pub struct SettingsTransition { #[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] pub enum SettingsError { + #[error(transparent)] + Lifecycle(#[from] crate::runtime::lifecycle::RuntimeLifecycleError), #[error("relay access value is unknown")] UnknownRelayAccess, #[error("relay endpoint is invalid")] @@ -886,6 +888,7 @@ pub enum SettingsError { impl SettingsError { pub const fn code(&self) -> &'static str { match self { + Self::Lifecycle(error) => error.code(), Self::UnknownRelayAccess => "unknown_relay_access", Self::InvalidRelayEndpoint => "invalid_relay_endpoint", Self::InvalidRelayEndpointCount => "invalid_relay_endpoint_count", @@ -907,6 +910,7 @@ impl SettingsError { impl TeraRuntime { pub async fn phase1_settings(&self) -> Result<MobileSettings, SettingsError> { + let _command = self.lifecycle.enter()?; let storage = self.client.storage().map_err(|_| SettingsError::Storage)?; let mut settings = load_settings(storage).await?; let session = self.identity_session.read().await; @@ -922,6 +926,7 @@ impl TeraRuntime { &self, command: ReplaceMobileSettings, ) -> Result<SettingsTransition, SettingsError> { + let _command = self.lifecycle.enter()?; let _guard = self.settings_lock.lock().await; let storage = self.client.storage().map_err(|_| SettingsError::Storage)?; let transition = replace_settings(storage, command).await?; @@ -937,6 +942,7 @@ impl TeraRuntime { expected_revision: u64, command: IdentityCommand, ) -> Result<SettingsTransition, SettingsError> { + let _command = self.lifecycle.enter()?; let _guard = self.settings_lock.lock().await; let storage = self.client.storage().map_err(|_| SettingsError::Storage)?; let mut prior = load_settings(storage).await?; diff --git a/core/crates/tera_core/src/runtime/product_surface/today.rs b/core/crates/tera_core/src/runtime/product_surface/today.rs @@ -146,6 +146,8 @@ impl TodayPageRequest { #[derive(Debug, Error)] pub enum TodayError { + #[error(transparent)] + Lifecycle(#[from] crate::runtime::lifecycle::RuntimeLifecycleError), #[error("today runtime is unavailable")] RuntimeUnavailable, #[error("today request is invalid")] @@ -226,6 +228,7 @@ impl TeraRuntime { now_unix_seconds: u64, update: TodayProjectionUpdate, ) -> Result<TodaySyncReceipt, TodayError> { + let _command = self.lifecycle.enter()?; if now_unix_seconds == 0 { return Err(TodayError::InvalidRequest); } @@ -291,6 +294,7 @@ impl TeraRuntime { context: &LocalNetwork, now_unix_seconds: u64, ) -> Result<TodayIngestReceipt, TodayError> { + let _command = self.lifecycle.enter()?; if admission.visible_event().is_none() { return Err(TodayError::EventNotVisible); } @@ -316,6 +320,7 @@ impl TeraRuntime { now_unix_seconds: u64, update: TodayProjectionUpdate, ) -> Result<TodayRefreshReceipt, TodayError> { + let _command = self.lifecycle.enter()?; if now_unix_seconds == 0 { return Err(TodayError::InvalidRequest); } @@ -415,6 +420,7 @@ impl TeraRuntime { context: &LocalNetwork, request: TodayPageRequest, ) -> Result<TodayPage, TodayError> { + let _command = self.lifecycle.enter()?; if request.limit == 0 || request.limit > TODAY_PAGE_LIMIT_MAX { return Err(TodayError::InvalidRequest); } @@ -480,6 +486,7 @@ impl TeraRuntime { limit: u16, as_of: u64, ) -> Result<Vec<SearchResult>, TodayError> { + let _command = self.lifecycle.enter()?; if limit == 0 || limit > TODAY_SEARCH_LIMIT_MAX || as_of == 0 { return Err(TodayError::InvalidRequest); } @@ -552,6 +559,7 @@ impl TeraRuntime { public_key: &str, as_of: u64, ) -> Result<MeSnapshot, TodayError> { + let _command = self.lifecycle.enter()?; if !valid_public_key(public_key) || as_of == 0 { return Err(TodayError::InvalidRequest); } @@ -587,6 +595,7 @@ impl TeraRuntime { reference_fingerprint: [u8; 32], pending: Phase1InboundMediaPending, ) -> Result<bool, TodayError> { + let _command = self.lifecycle.enter()?; let storage = self .client .storage() @@ -620,6 +629,7 @@ impl TeraRuntime { reference_fingerprint: [u8; 32], failure: Phase1InboundMediaFailure, ) -> Result<bool, TodayError> { + let _command = self.lifecycle.enter()?; let storage = self .client .storage() @@ -682,6 +692,7 @@ impl TeraRuntime { artifact_id: Phase1MediaArtifactId, observed_at_unix_ms: u64, ) -> Result<bool, TodayError> { + let _command = self.lifecycle.enter()?; let storage = self .client .storage() @@ -703,6 +714,7 @@ impl TeraRuntime { context: &LocalNetwork, artifact_id: Phase1MediaArtifactId, ) -> Result<bool, TodayError> { + let _command = self.lifecycle.enter()?; #[cfg(feature = "mobile-social")] let _guard = self.inbound_media_lock.lock().await; let storage = self @@ -732,6 +744,7 @@ impl TeraRuntime { context: &LocalNetwork, configuration: Phase1MediaConfigurationFingerprint, ) -> Result<Vec<Phase1MediaArtifactId>, TodayError> { + let _command = self.lifecycle.enter()?; #[cfg(feature = "mobile-social")] let _guard = self.inbound_media_lock.lock().await; let storage = self @@ -763,6 +776,7 @@ impl TeraRuntime { &self, context: &LocalNetwork, ) -> Result<Phase1MediaCacheStatus, TodayError> { + let _command = self.lifecycle.enter()?; let storage = self .client .storage() @@ -782,6 +796,7 @@ impl TeraRuntime { artifact_id: Phase1MediaArtifactId, observed_at_unix_ms: u64, ) -> Result<Option<Phase1LocalMediaArtifact>, TodayError> { + let _command = self.lifecycle.enter()?; let _guard = self.inbound_media_lock.lock().await; let directory = self .inbound_media_directory @@ -826,6 +841,7 @@ impl TeraRuntime { policy: Phase1MediaCachePolicy, cancellation: BlossomCancellation, ) -> Result<Phase1LocalMediaArtifact, TodayError> { + let _command = self.lifecycle.enter()?; let _guard = self.inbound_media_lock.lock().await; let directory = self .inbound_media_directory @@ -972,6 +988,7 @@ impl TeraRuntime { card_id: CardId, overlay: Option<LocalAuthorOverlay>, ) -> Result<(), TodayError> { + let _command = self.lifecycle.enter()?; let storage = self .client .storage() @@ -2405,6 +2422,7 @@ mod tests { client, started_unix_ms: 1, shutting_down: AtomicBool::new(false), + lifecycle: Default::default(), platform_app: RwLock::new(None), store_public_key: None, mutations: Default::default(), diff --git a/core/crates/tera_core/src/runtime/sdk.rs b/core/crates/tera_core/src/runtime/sdk.rs @@ -103,6 +103,7 @@ impl TeraRuntime { } pub async fn sdk_storage_status(&self) -> Result<SdkStorageStatusRecord, TeraAppError> { + let _command = self.lifecycle.enter()?; let status = self .client .storage_status() @@ -122,6 +123,7 @@ impl TeraRuntime { &self, writable_relays: Vec<String>, ) -> Result<(), TeraAppError> { + let _command = self.lifecycle.enter()?; self.configure_relay_endpoints( radroots_sdk::transport::RelayProfileKind::Public, radroots_sdk::transport::RelayUrlPolicy::Public, @@ -135,6 +137,7 @@ impl TeraRuntime { &self, loopback_relays: Vec<String>, ) -> Result<(), TeraAppError> { + let _command = self.lifecycle.enter()?; self.configure_relay_endpoints( radroots_sdk::transport::RelayProfileKind::Simulator, radroots_sdk::transport::RelayUrlPolicy::Local, @@ -148,6 +151,7 @@ impl TeraRuntime { &self, writable_relays: Vec<String>, ) -> Result<(), TeraAppError> { + let _command = self.lifecycle.enter()?; self.configure_relay_endpoints( radroots_sdk::transport::RelayProfileKind::Device, radroots_sdk::transport::RelayUrlPolicy::PrivateNetwork, @@ -194,6 +198,7 @@ impl TeraRuntime { &self, preferences: &RelayPreferences, ) -> Result<(), TeraAppError> { + let _command = self.lifecycle.enter()?; self.configure_relay_profile( preferences .sdk_profile() @@ -210,6 +215,7 @@ impl TeraRuntime { primary_origin: String, fallback_origins: Vec<String>, ) -> Result<(), TeraAppError> { + let _command = self.lifecycle.enter()?; self.configure_blossom_profile( radroots_sdk::transport::BlossomProfile::new( host_kind, @@ -239,6 +245,7 @@ impl TeraRuntime { &self, preferences: &BlossomPreferences, ) -> Result<(), TeraAppError> { + let _command = self.lifecycle.enter()?; self.configure_blossom_profile( preferences .sdk_profile() @@ -251,6 +258,7 @@ impl TeraRuntime { pub fn sdk_blossom_slot( &self, ) -> Result<Option<radroots_sdk::transport::BlossomSlot>, TeraAppError> { + let _command = self.lifecycle.enter()?; self.client .blossom() .map(|slot| slot.cloned()) @@ -262,6 +270,7 @@ impl TeraRuntime { pub fn sdk_blossom_configuration( &self, ) -> Result<Option<SdkBlossomConfigurationRecord>, TeraAppError> { + let _command = self.lifecycle.enter()?; let configuration = self .client .blossom() @@ -285,6 +294,7 @@ impl TeraRuntime { /// Returns the latest passive Blossom evidence without network I/O. #[cfg(feature = "mobile-social")] pub fn sdk_blossom_evidence(&self) -> Result<Option<SdkBlossomEvidenceRecord>, TeraAppError> { + let _command = self.lifecycle.enter()?; let evidence = self .client .blossom() @@ -296,6 +306,7 @@ impl TeraRuntime { /// Explicitly probes the primary Blossom origin without mutation or authorization. #[cfg(feature = "mobile-social")] pub async fn probe_blossom(&self) -> Result<SdkBlossomEvidenceRecord, TeraAppError> { + let _command = self.lifecycle.enter()?; let blossom = self .client .blossom() @@ -311,6 +322,7 @@ impl TeraRuntime { /// Returns passive relay evidence without DNS, socket, or probe work. #[cfg(feature = "mobile-social")] pub fn sdk_relay_status(&self) -> Result<Option<SdkRelayStatusReportRecord>, TeraAppError> { + let _command = self.lifecycle.enter()?; let report = self.client.nostr_status().map_err(TeraAppError::from_sdk)?; Ok(report.map(|report| SdkRelayStatusReportRecord { profile: relay_profile_label(report.profile_kind()).to_owned(), diff --git a/core/crates/tera_ffi/Cargo.toml b/core/crates/tera_ffi/Cargo.toml @@ -28,6 +28,7 @@ radroots_sdk = { workspace = true, features = ["blossom"] } radroots_signing = { workspace = true, features = ["serde", "std"] } radroots_storage = { workspace = true } serde_json = { workspace = true, features = ["std"] } +tokio = { workspace = true, features = ["sync"] } tracing = { workspace = true } tracing-appender = { workspace = true } tracing-subscriber = { workspace = true } diff --git a/core/crates/tera_ffi/src/error.rs b/core/crates/tera_ffi/src/error.rs @@ -144,6 +144,7 @@ impl From<tera_core::TeraAppError> for TeraAppError { impl From<TodayError> for TeraAppError { fn from(error: TodayError) -> Self { let (code, retryable, actions) = match error { + TodayError::Lifecycle(error) => return tera_core::TeraAppError::from(error).into(), TodayError::InvalidRequest | TodayError::EventNotVisible => { ("today_invalid_request", false, &["correct_input"][..]) } @@ -184,6 +185,9 @@ impl From<TodayError> for TeraAppError { impl From<Phase1DraftError> for TeraAppError { fn from(error: Phase1DraftError) -> Self { let (code, retryable, actions) = match error { + Phase1DraftError::Lifecycle(error) => { + return tera_core::TeraAppError::from(error).into(); + } Phase1DraftError::IdentityUnavailable => ( "identity_unavailable", true, @@ -251,6 +255,9 @@ impl From<Phase1DraftError> for TeraAppError { impl From<SettingsError> for TeraAppError { fn from(error: SettingsError) -> Self { + if let SettingsError::Lifecycle(error) = error { + return tera_core::TeraAppError::from(error).into(); + } let retryable = matches!( error, SettingsError::RevisionConflict | SettingsError::Storage diff --git a/core/crates/tera_ffi/src/runtime.rs b/core/crates/tera_ffi/src/runtime.rs @@ -93,6 +93,7 @@ impl TeraRuntime { } pub async fn shutdown(&self) -> Result<FfiShutdownRecord, TeraAppError> { + self.subscriptions.close(); let result = self .inner .shutdown() @@ -100,9 +101,7 @@ impl TeraRuntime { .map(Into::into) .map_err(Into::into); if result.is_ok() { - self.subscriptions - .notify(FfiRuntimeChangeKind::Lifecycle, None); - self.subscriptions.close(); + self.subscriptions.drain().await; } result } diff --git a/core/crates/tera_ffi/src/subscription.rs b/core/crates/tera_ffi/src/subscription.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use std::panic::{AssertUnwindSafe, catch_unwind}; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::mpsc::{SyncSender, TrySendError, sync_channel}; use std::sync::{Arc, Mutex, Weak}; @@ -41,6 +41,7 @@ pub(crate) struct SubscriptionHub { next_id: AtomicU64, generation: AtomicU64, closed: AtomicBool, + workers: Arc<WorkerState>, subscriptions: Mutex<BTreeMap<u64, SyncSender<FfiRuntimeChangeRecord>>>, } @@ -50,6 +51,7 @@ impl SubscriptionHub { next_id: AtomicU64::new(1), generation: AtomicU64::new(1), closed: AtomicBool::new(false), + workers: Arc::new(WorkerState::default()), subscriptions: Mutex::new(BTreeMap::new()), }) } @@ -58,35 +60,44 @@ impl SubscriptionHub { self: &Arc<Self>, observer: Box<dyn TeraRuntimeObserver>, ) -> Result<Arc<FfiSubscriptionHandle>, TeraAppError> { - if self.closed.load(Ordering::Acquire) { - return Err(subscription_error("runtime_closed", false)); - } let id = self.next_id.fetch_add(1, Ordering::AcqRel); - let (sender, receiver) = sync_channel(CHANGE_BUFFER_CAPACITY); - let observer: Arc<dyn TeraRuntimeObserver> = Arc::from(observer); - let hub = Arc::downgrade(self); - std::thread::Builder::new() - .name(format!("tera-ffi-observer-{id}")) - .spawn(move || { - while let Ok(change) = receiver.recv() { - if catch_unwind(AssertUnwindSafe(|| observer.on_change(change))).is_err() { - break; - } - } - if let Some(hub) = hub.upgrade() { - hub.remove(id); - } - }) - .map_err(|_| subscription_error("subscription_worker_unavailable", true))?; - + let (sender, receiver) = sync_channel::<FfiRuntimeChangeRecord>(CHANGE_BUFFER_CAPACITY); { let mut subscriptions = self .subscriptions .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - if self.closed.load(Ordering::Acquire) || subscriptions.len() >= MAX_SUBSCRIPTIONS { + if self.closed.load(Ordering::Acquire) { + return Err(subscription_error("runtime_closed", false)); + } + if subscriptions.len() >= MAX_SUBSCRIPTIONS { return Err(subscription_error("subscription_limit_reached", true)); } + self.workers.active.fetch_add(1, Ordering::AcqRel); + let worker = WorkerLease(Arc::clone(&self.workers)); + let hub = Arc::downgrade(self); + std::thread::Builder::new() + .name(format!("tera-ffi-observer-{id}")) + .spawn(move || { + let _worker = worker; + while let Ok(change) = receiver.recv() { + let Some(hub) = hub.upgrade() else { + break; + }; + let closed = hub.closed.load(Ordering::Acquire); + drop(hub); + if closed && change.kind != FfiRuntimeChangeKind::Lifecycle { + continue; + } + if catch_unwind(AssertUnwindSafe(|| observer.on_change(change))).is_err() { + break; + } + } + if let Some(hub) = hub.upgrade() { + hub.remove(id); + } + }) + .map_err(|_| subscription_error("subscription_worker_unavailable", true))?; subscriptions.insert(id, sender.clone()); } @@ -140,11 +151,32 @@ impl SubscriptionHub { pub(crate) fn close(&self) { if !self.closed.swap(true, Ordering::AcqRel) { - self.generation.fetch_add(1, Ordering::AcqRel); - self.subscriptions + let change = FfiRuntimeChangeRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + generation: self.generation.fetch_add(1, Ordering::AcqRel) + 1, + kind: FfiRuntimeChangeKind::Lifecycle, + entity_id: None, + }; + let mut subscriptions = self + .subscriptions .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .clear(); + .unwrap_or_else(std::sync::PoisonError::into_inner); + for sender in subscriptions.values() { + let _ = sender.try_send(change.clone()); + } + subscriptions.clear(); + } + } + + pub(crate) async fn drain(&self) { + loop { + let notified = self.workers.drained.notified(); + let mut notified = std::pin::pin!(notified); + notified.as_mut().enable(); + if self.workers.active.load(Ordering::Acquire) == 0 { + return; + } + notified.await; } } @@ -156,6 +188,24 @@ impl SubscriptionHub { } } +// Workers retain only their settlement counter, avoiding a hub/sender cycle. +// Close rejects new observers and drains callbacks without blocking an executor. +#[derive(Default)] +struct WorkerState { + active: AtomicUsize, + drained: tokio::sync::Notify, +} + +struct WorkerLease(Arc<WorkerState>); + +impl Drop for WorkerLease { + fn drop(&mut self) { + if self.0.active.fetch_sub(1, Ordering::AcqRel) == 1 { + self.0.drained.notify_waiters(); + } + } +} + #[derive(uniffi::Object)] pub struct FfiSubscriptionHandle { hub: Weak<SubscriptionHub>, @@ -252,6 +302,72 @@ mod tests { } } + struct PausedObserver { + entered: Arc<tokio::sync::Notify>, + gate: Arc<(Mutex<bool>, Condvar)>, + calls: Arc<AtomicUsize>, + } + + impl TeraRuntimeObserver for PausedObserver { + fn on_change(&self, _: FfiRuntimeChangeRecord) { + self.calls.fetch_add(1, Ordering::AcqRel); + self.entered.notify_one(); + let (released, wake) = &*self.gate; + drop( + wake.wait_while(released.lock().unwrap(), |released| !*released) + .unwrap(), + ); + } + } + + #[tokio::test] + async fn close_drains_native_callbacks_even_after_the_wait_is_cancelled() { + use std::{ + future::Future, + task::{Context, Poll, Waker}, + }; + let hub = SubscriptionHub::new(); + let entered = Arc::new(tokio::sync::Notify::new()); + let gate = Arc::new((Mutex::new(false), Condvar::new())); + let calls = Arc::new(AtomicUsize::new(0)); + let handle = hub + .subscribe(Box::new(PausedObserver { + entered: Arc::clone(&entered), + gate: Arc::clone(&gate), + calls: Arc::clone(&calls), + })) + .unwrap(); + tokio::time::timeout(Duration::from_secs(5), entered.notified()) + .await + .unwrap(); + hub.notify(FfiRuntimeChangeKind::Today, None); + hub.close(); + let mut drain = Box::pin(hub.drain()); + assert!(matches!( + drain.as_mut().poll(&mut Context::from_waker(Waker::noop())), + Poll::Pending + )); + assert!(!handle.is_active()); + assert_eq!( + hub.subscribe(Box::new(NoopObserver)) + .err() + .unwrap() + .report() + .code, + "runtime_closed" + ); + drop(drain); + *gate.0.lock().unwrap() = true; + gate.1.notify_all(); + tokio::time::timeout(Duration::from_secs(5), hub.drain()) + .await + .unwrap(); + hub.close(); + hub.drain().await; + assert_eq!(calls.load(Ordering::Acquire), 2); + assert_eq!(hub.workers.active.load(Ordering::Acquire), 0); + } + #[test] fn closed_limit_and_detached_handle_paths_are_typed_and_idempotent() { let closed = SubscriptionHub::new(); diff --git a/core/crates/tera_ffi/tests/recovery_contract.rs b/core/crates/tera_ffi/tests/recovery_contract.rs @@ -208,3 +208,31 @@ fn complete_safe_envelopes_survive_mapping_without_relabeling_future_versions() assert_eq!(report.operation_id, None); assert_eq!(report.capability_id, None); } + +#[test] +fn closing_admission_preserves_the_same_typed_catalog_across_product_families() { + use tera_core::runtime::{lifecycle::RuntimeLifecycleError, product_surface::SettingsError}; + for state in [ + RuntimeLifecycleError::Closing, + RuntimeLifecycleError::Closed, + RuntimeLifecycleError::CloseInProgress, + ] { + let expected = TeraAppError::from(tera_core::TeraAppError::from(state)); + for observed in [ + TeraAppError::from(TodayError::Lifecycle(state)), + TeraAppError::from(Phase1DraftError::Lifecycle(state)), + TeraAppError::from(SettingsError::Lifecycle(state)), + ] { + assert_eq!(observed.report(), expected.report()); + let recovery = classify_error_recovery(1, observed.report().code.clone()); + assert_eq!( + recovery.disposition, + if state == RuntimeLifecycleError::Closed { + Recovery::RuntimeUnavailable + } else { + Recovery::OutcomeUnknown + } + ); + } + } +} diff --git a/core/crates/tera_ffi/tests/runtime_lifecycle.rs b/core/crates/tera_ffi/tests/runtime_lifecycle.rs @@ -42,3 +42,92 @@ async fn concurrent_host_references_converge_and_repeated_close_is_idempotent() let repeated = runtime.shutdown().await.expect("repeated close"); assert!(repeated.already_closed); } + +struct CallbackGate { + entered: Arc<tokio::sync::Notify>, + released: Arc<(std::sync::Mutex<bool>, std::sync::Condvar)>, +} + +impl tera_ffi::TeraRuntimeObserver for CallbackGate { + fn on_change(&self, _: tera_ffi::FfiRuntimeChangeRecord) { + self.entered.notify_one(); + let (released, wake) = &*self.released; + drop( + wake.wait_while(released.lock().unwrap(), |released| !*released) + .unwrap(), + ); + } +} + +struct ReleaseCallback(Arc<(std::sync::Mutex<bool>, std::sync::Condvar)>); + +impl Drop for ReleaseCallback { + fn drop(&mut self) { + *self.0.0.lock().unwrap() = true; + self.0.1.notify_all(); + } +} + +#[tokio::test] +async fn ffi_close_retains_a_slow_native_callback_across_cancelled_and_repeated_waits() { + use std::{ + future::{Future, poll_fn}, + task::Poll, + }; + let (_root, runtime) = support::runtime().await; + let entered = Arc::new(tokio::sync::Notify::new()); + let released = Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new())); + let release = ReleaseCallback(Arc::clone(&released)); + let observer = runtime + .subscribe_changes(Box::new(CallbackGate { + entered: Arc::clone(&entered), + released, + })) + .unwrap(); + tokio::time::timeout(Duration::from_secs(5), entered.notified()) + .await + .unwrap(); + let mut close = Box::pin(runtime.shutdown()); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + // Poll the actual exported close until storage has settled. Native + // callback ownership must still keep its result pending. + let outcome = poll_fn(|context| Poll::Ready(close.as_mut().poll(context))).await; + assert!( + outcome.is_pending(), + "FFI close returned before its native callback" + ); + if runtime.info().sdk_closed { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert!(!observer.is_active()); + assert_eq!( + runtime + .sdk_storage_status() + .await + .unwrap_err() + .report() + .code, + "client_closed" + ); + drop(close); + let mut repeat = Box::pin(runtime.shutdown()); + let outcome = poll_fn(|context| Poll::Ready(repeat.as_mut().poll(context))).await; + assert!( + outcome.is_pending(), + "Cancelled close must retain its callback drain" + ); + drop(release); + let receipt = tokio::time::timeout(Duration::from_secs(5), repeat) + .await + .unwrap() + .unwrap(); + assert_eq!(receipt.state, "closed"); + assert!(receipt.already_closed); + assert!(runtime.shutdown().await.unwrap().already_closed); +} diff --git a/project.yml b/project.yml @@ -53,6 +53,7 @@ targets: - path: TeraTests/TeraRuntimeResourceFixtures.swift - path: TeraTests/TeraRuntimeResourceTaskTests.swift - path: TeraTests/TeraRuntimeResourceLifecycleTests.swift + - path: TeraTests/TeraRuntimeShutdownTests.swift - path: TeraTests/TeraSessionResourceTests.swift - path: TeraTests/TeraStateTestFixtures.swift - path: TeraTests/TeraRootShellTests.swift diff --git a/release/provenance.json b/release/provenance.json @@ -2,11 +2,11 @@ "artifacts": { "app_api_sha256": "020924097c0d7efc33128cb8fd3d3b2026d95f57c44da71880e585aff80f070b", "ffi_api_sha256": "37ba223303e6268d12d8581ba9a1a77a327b132122e29284bec715cca3c19369", - "ffi_provenance_sha256": "6c26d7069a9864fb5c70f1bd8633b6102d2aa9cb5774c06bf6c01e542cb3315d", + "ffi_provenance_sha256": "f279048f5256c2ab0fad81300201bcb342cba1abf318c50d4f0c910dd1681628", "info_plist_sha256": "15ef08b1cdd1096cfb9eeaf5be5bf8f814807a7ca9350bbbb47860fa72ec13ef", "privacy_manifest_sha256": "a331d51864743ebe4e00dd22360b4a538b6b3ac26a6b3eb54094e60a36959a12", "sbom_sha256": "6b5ce897d5273290febc8b831663e12dea759cb0084f182ba7949e1c593fc410", - "xcode_project_sha256": "34c15094c5665c920f02860bc0083fb699052a7d481c87523192e863f53e7ecf" + "xcode_project_sha256": "fbfb6c2a9c966901e6f6fbe267e34a3f429c7a5512c99d411947c330777c43ad" }, "disposition": "unsigned", "platforms": [ @@ -22,7 +22,7 @@ "lib_revision": "ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb", "source_date_epoch": 1787871027, "swift_package_lock_sha256": "94ae067a374726cdaf6b4ca0a5e44663c57fcdc5334060c5ffef5e79cfbf04c0", - "tera_ffi_source_tree": "f6bfeb00965c4cf264b5f74e488427092965762f", + "tera_ffi_source_tree": "f4a8a67798881cd35e23c5817dc831c71305d61a", "xcode_package_lock_sha256": "c7f41934ea25f7a287bdc4f3a6ecabbf09a3a0bdd0f5a3e58183f355ca814096" }, "version": "0.1.0-alpha" diff --git a/test-fixtures/legacy-identifiers.v1.json b/test-fixtures/legacy-identifiers.v1.json @@ -4233,6 +4233,10 @@ "count": 1 }, { + "path": "core/crates/tera_core/src/runtime/lifecycle.rs", + "count": 1 + }, + { "path": "core/crates/tera_core/src/runtime/product_surface/outbox.rs", "count": 1 } @@ -4263,6 +4267,10 @@ "count": 2 }, { + "path": "core/crates/tera_core/src/runtime/lifecycle.rs", + "count": 4 + }, + { "path": "core/crates/tera_core/src/runtime/mod.rs", "count": 8 }, diff --git a/test-fixtures/swiftlint-maintainability-baseline.v1.json b/test-fixtures/swiftlint-maintainability-baseline.v1.json @@ -1 +1 @@ -[{"text":"final class TeraAppModel: ObservableObject {","violation":{"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","reason":"Class body should span 250 lines or less excluding comments and whitespace: currently spans 255 lines","ruleName":"Type Body Length","location":{"character":7,"file":"Tera\/App\/TeraAppModel.swift","line":55}}},{"text":" init(","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Initializer body should span 50 lines or less excluding comments and whitespace: currently spans 52 lines","ruleName":"Function Body Length","location":{"character":3,"file":"Tera\/App\/TeraAppModel.swift","line":71}}},{"text":"actor TeraAddMediaCoordinator: TeraAddMediaHandling {","violation":{"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","reason":"Actor body should span 250 lines or less excluding comments and whitespace: currently spans 330 lines","ruleName":"Type Body Length","location":{"character":1,"file":"Tera\/Runtime\/TeraAddMediaCoordinator.swift","line":73}}},{"text":"#endif","violation":{"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","reason":"File should contain 400 lines or less: currently contains 480","ruleName":"File Length","location":{"character":1,"file":"Tera\/Runtime\/TeraAddMediaCoordinator.swift","line":480}}},{"text":"private final class TeraGeneratedRuntimeBackend: TeraRuntimeBackend, @unchecked Sendable {","violation":{"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 545 lines","ruleName":"Type Body Length","location":{"character":15,"file":"Tera\/Runtime\/TeraGeneratedRuntimeBackend.swift","line":134}}},{"text":"}","violation":{"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","reason":"File should contain 1000 lines or less: currently contains 1696","ruleName":"File Length","location":{"character":1,"file":"Tera\/Runtime\/TeraGeneratedRuntimeBackend.swift","line":1696}}},{"text":"}","violation":{"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","reason":"File should contain 400 lines or less: currently contains 434","ruleName":"File Length","location":{"character":1,"file":"Tera\/Runtime\/TeraLifecycleCoordinator.swift","line":434}}},{"text":"actor TeraRuntimeClient {","violation":{"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 821 lines","ruleName":"Type Body Length","location":{"character":1,"file":"Tera\/Runtime\/TeraRuntimeClient.swift","line":291}}},{"text":" func changes(bufferCapacity: Int = 16) async throws -> AsyncStream<TeraRuntimeChange> {","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 71 lines","ruleName":"Function Body Length","location":{"character":3,"file":"Tera\/Runtime\/TeraRuntimeClient.swift","line":708}}},{"text":" private func beginShutdown() -> ShutdownOperation {","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 53 lines","ruleName":"Function Body Length","location":{"character":11,"file":"Tera\/Runtime\/TeraRuntimeClient.swift","line":887}}},{"text":"}","violation":{"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","reason":"File should contain 1000 lines or less: currently contains 1197","ruleName":"File Length","location":{"character":1,"file":"Tera\/Runtime\/TeraRuntimeClient.swift","line":1197}}},{"text":"}","violation":{"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","reason":"File should contain 1000 lines or less: currently contains 1234","ruleName":"File Length","location":{"character":1,"file":"Tera\/Runtime\/TeraRuntimeModels.swift","line":1234}}},{"text":"final class TeraAddStore: ObservableObject {","violation":{"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 741 lines","ruleName":"Type Body Length","location":{"character":7,"file":"Tera\/State\/TeraAddStore.swift","line":11}}},{"text":" func submit() async {","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 53 lines","ruleName":"Function Body Length","location":{"character":3,"file":"Tera\/State\/TeraAddStore.swift","line":280}}},{"text":" func retract(_ card: TeraTodayCard) async {","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 51 lines","ruleName":"Function Body Length","location":{"character":3,"file":"Tera\/State\/TeraAddStore.swift","line":438}}},{"text":" private func uploadPendingMedia(_ initial: TeraDraftStatus) async throws","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 60 lines","ruleName":"Function Body Length","location":{"character":11,"file":"Tera\/State\/TeraAddStore.swift","line":524}}},{"text":"}","violation":{"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","reason":"File should contain 400 lines or less: currently contains 810","ruleName":"File Length","location":{"character":1,"file":"Tera\/State\/TeraAddStore.swift","line":810}}},{"text":"actor TeraConfigurationStore {","violation":{"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 468 lines","ruleName":"Type Body Length","location":{"character":1,"file":"Tera\/State\/TeraConfigurationStore.swift","line":78}}},{"text":" func load() throws -> TeraAppConfiguration {","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Function body should span 100 lines or less excluding comments and whitespace: currently spans 109 lines","ruleName":"Function Body Length","location":{"character":5,"file":"Tera\/State\/TeraConfigurationStore.swift","line":148}}},{"text":"}","violation":{"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","reason":"File should contain 400 lines or less: currently contains 759","ruleName":"File Length","location":{"character":1,"file":"Tera\/State\/TeraConfigurationStore.swift","line":759}}},{"text":"actor TeraSessionStore {","violation":{"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 430 lines","ruleName":"Type Body Length","location":{"character":1,"file":"Tera\/State\/TeraSessionStore.swift","line":40}}},{"text":" static func production(","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 52 lines","ruleName":"Function Body Length","location":{"character":10,"file":"Tera\/State\/TeraSessionStore.swift","line":70}}},{"text":" private func start(acceptingReconfiguration: Bool) async -> TeraSessionPhase {","violation":{"ruleIdentifier":"cyclomatic_complexity","ruleDescription":"Complexity of function bodies should be limited.","severity":"error","reason":"Function should have complexity 10 or less; currently complexity is 14","ruleName":"Cyclomatic Complexity","location":{"character":11,"file":"Tera\/State\/TeraSessionStore.swift","line":182}}},{"text":" private func start(acceptingReconfiguration: Bool) async -> TeraSessionPhase {","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 64 lines","ruleName":"Function Body Length","location":{"character":11,"file":"Tera\/State\/TeraSessionStore.swift","line":182}}},{"text":" private func reconcileIdentity(_ identity: TeraAppIdentity) async throws {","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 61 lines","ruleName":"Function Body Length","location":{"character":11,"file":"Tera\/State\/TeraSessionStore.swift","line":388}}},{"text":"}","violation":{"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","reason":"File should contain 400 lines or less: currently contains 492","ruleName":"File Length","location":{"character":1,"file":"Tera\/State\/TeraSessionStore.swift","line":492}}},{"text":"struct TeraAddView: View {","violation":{"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","reason":"Struct body should span 350 lines or less excluding comments and whitespace: currently spans 454 lines","ruleName":"Type Body Length","location":{"character":1,"file":"Tera\/Views\/TeraAddView.swift","line":4}}},{"text":"}","violation":{"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","reason":"File should contain 400 lines or less: currently contains 560","ruleName":"File Length","location":{"character":1,"file":"Tera\/Views\/TeraAddView.swift","line":560}}},{"text":"struct TeraSettingsView: View {","violation":{"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","reason":"Struct body should span 250 lines or less excluding comments and whitespace: currently spans 259 lines","ruleName":"Type Body Length","location":{"character":1,"file":"Tera\/Views\/TeraSupportingViews.swift","line":365}}},{"text":"}","violation":{"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","reason":"File should contain 400 lines or less: currently contains 629","ruleName":"File Length","location":{"character":1,"file":"Tera\/Views\/TeraSupportingViews.swift","line":629}}},{"text":"}","violation":{"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","reason":"File should contain 400 lines or less: currently contains 493","ruleName":"File Length","location":{"character":1,"file":"Tera\/Views\/TeraTodayView.swift","line":493}}},{"text":"final class TeraAddStoreTests: XCTestCase {","violation":{"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 665 lines","ruleName":"Type Body Length","location":{"character":7,"file":"TeraTests\/TeraAddStoreTests.swift","line":6}}},{"text":"private actor AddBackend: TeraRuntimeBackend {","violation":{"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 492 lines","ruleName":"Type Body Length","location":{"character":9,"file":"TeraTests\/TeraAddStoreTests.swift","line":847}}},{"text":" private func makeStatus(","violation":{"ruleIdentifier":"function_parameter_count","ruleDescription":"Number of function parameters should be low.","severity":"error","reason":"Function should have 5 parameters or less: it currently has 8","ruleName":"Function Parameter Count","location":{"character":11,"file":"TeraTests\/TeraAddStoreTests.swift","line":1283}}},{"text":" nonisolated static func schemas() -> [TeraAddSchema] {","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 89 lines","ruleName":"Function Body Length","location":{"character":22,"file":"TeraTests\/TeraAddStoreTests.swift","line":1380}}},{"text":"}","violation":{"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","reason":"File should contain 1000 lines or less: currently contains 1528","ruleName":"File Length","location":{"character":1,"file":"TeraTests\/TeraAddStoreTests.swift","line":1528}}},{"text":"final class TeraRemoteQualificationTests: XCTestCase {","violation":{"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","reason":"Class body should span 250 lines or less excluding comments and whitespace: currently spans 307 lines","ruleName":"Type Body Length","location":{"character":7,"file":"TeraTests\/TeraRemoteQualificationTests.swift","line":7}}},{"text":" func testQualificationRejectsAmbiguousOrUnsafeHarnessValues() {","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 94 lines","ruleName":"Function Body Length","location":{"character":3,"file":"TeraTests\/TeraRemoteQualificationTests.swift","line":45}}},{"text":" func testAuthorizationEvidenceIsRedactedEphemeralAndRelaunchCleaned() throws {","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 53 lines","ruleName":"Function Body Length","location":{"character":3,"file":"TeraTests\/TeraRemoteQualificationTests.swift","line":212}}},{"text":"final class TeraRuntimeClientTests: XCTestCase {","violation":{"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","reason":"Class body should span 250 lines or less excluding comments and whitespace: currently spans 334 lines","ruleName":"Type Body Length","location":{"character":7,"file":"TeraTests\/TeraRuntimeClientTests.swift","line":4}}},{"text":"final class TeraStateMigrationTests: XCTestCase {","violation":{"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 580 lines","ruleName":"Type Body Length","location":{"character":7,"file":"TeraTests\/TeraStateMigrationTests.swift","line":6}}},{"text":" func testRelayValidationMatchesRuntimeProfiles() throws {","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 61 lines","ruleName":"Function Body Length","location":{"character":5,"file":"TeraTests\/TeraStateMigrationTests.swift","line":7}}},{"text":"}","violation":{"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","reason":"File should contain 400 lines or less: currently contains 622","ruleName":"File Length","location":{"character":1,"file":"TeraTests\/TeraStateMigrationTests.swift","line":622}}},{"text":"private actor SupportingBackend: TeraRuntimeBackend {","violation":{"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","reason":"Actor body should span 250 lines or less excluding comments and whitespace: currently spans 253 lines","ruleName":"Type Body Length","location":{"character":9,"file":"TeraTests\/TeraSupportingStoreTests.swift","line":207}}},{"text":"}","violation":{"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","reason":"File should contain 400 lines or less: currently contains 487","ruleName":"File Length","location":{"character":1,"file":"TeraTests\/TeraSupportingStoreTests.swift","line":487}}},{"text":"final class TeraRemoteQualificationUITests: XCTestCase {","violation":{"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 1493 lines","ruleName":"Type Body Length","location":{"character":7,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":4}}},{"text":" func testLocalSocialFiveFlowScenario() throws {","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 73 lines","ruleName":"Function Body Length","location":{"character":3,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":62}}},{"text":" func testLocalSocialDeterministicPersonas() throws {","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 66 lines","ruleName":"Function Body Length","location":{"character":3,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":174}}},{"text":" func testRemoteBlossomUploadAndRecovery() throws {","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 54 lines","ruleName":"Function Body Length","location":{"character":3,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":247}}},{"text":" private func expectedAddFieldLabel(_ identifier: String, type: String) -> String {","violation":{"ruleIdentifier":"cyclomatic_complexity","ruleDescription":"Complexity of function bodies should be limited.","severity":"error","reason":"Function should have complexity 10 or less; currently complexity is 18","ruleName":"Cyclomatic Complexity","location":{"character":11,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":573}}},{"text":" private func preparePhotoUpdate(_ app: XCUIApplication, marker: String) -> XCUIElement? {","violation":{"ruleIdentifier":"cyclomatic_complexity","ruleDescription":"Complexity of function bodies should be limited.","severity":"error","reason":"Function should have complexity 10 or less; currently complexity is 11","ruleName":"Cyclomatic Complexity","location":{"character":11,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":767}}},{"text":" private func preparePhotoUpdate(_ app: XCUIApplication, marker: String) -> XCUIElement? {","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 66 lines","ruleName":"Function Body Length","location":{"character":11,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":767}}},{"text":" static func environment(","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 67 lines","ruleName":"Function Body Length","location":{"character":10,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":1736}}},{"text":"}","violation":{"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","reason":"File should contain 1000 lines or less: currently contains 2212","ruleName":"File Length","location":{"character":1,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":2212}}}] -\ No newline at end of file +[{"violation":{"severity":"error","location":{"line":71,"file":"Tera\/App\/TeraAppModel.swift","character":3},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","reason":"Initializer body should span 50 lines or less excluding comments and whitespace: currently spans 52 lines"},"text":" init("},{"violation":{"severity":"error","location":{"line":73,"file":"Tera\/Runtime\/TeraAddMediaCoordinator.swift","character":1},"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","reason":"Actor body should span 250 lines or less excluding comments and whitespace: currently spans 330 lines"},"text":"actor TeraAddMediaCoordinator: TeraAddMediaHandling {"},{"violation":{"severity":"error","location":{"line":480,"file":"Tera\/Runtime\/TeraAddMediaCoordinator.swift","character":1},"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 480"},"text":"#endif"},{"violation":{"severity":"error","location":{"line":134,"file":"Tera\/Runtime\/TeraGeneratedRuntimeBackend.swift","character":15},"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 545 lines"},"text":"private final class TeraGeneratedRuntimeBackend: TeraRuntimeBackend, @unchecked Sendable {"},{"violation":{"severity":"error","location":{"line":1696,"file":"Tera\/Runtime\/TeraGeneratedRuntimeBackend.swift","character":1},"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","reason":"File should contain 1000 lines or less: currently contains 1696"},"text":"}"},{"violation":{"severity":"error","location":{"line":291,"file":"Tera\/Runtime\/TeraRuntimeClient.swift","character":1},"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 790 lines"},"text":"actor TeraRuntimeClient {"},{"violation":{"severity":"error","location":{"line":709,"file":"Tera\/Runtime\/TeraRuntimeClient.swift","character":3},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 70 lines"},"text":" func changes(bufferCapacity: Int = 16) async throws -> AsyncStream<TeraRuntimeChange> {"},{"violation":{"severity":"error","location":{"line":1163,"file":"Tera\/Runtime\/TeraRuntimeClient.swift","character":1},"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","reason":"File should contain 1000 lines or less: currently contains 1163"},"text":"}"},{"violation":{"severity":"error","location":{"line":1234,"file":"Tera\/Runtime\/TeraRuntimeModels.swift","character":1},"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","reason":"File should contain 1000 lines or less: currently contains 1234"},"text":"}"},{"violation":{"severity":"error","location":{"line":11,"file":"Tera\/State\/TeraAddStore.swift","character":7},"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 741 lines"},"text":"final class TeraAddStore: ObservableObject {"},{"violation":{"severity":"error","location":{"line":280,"file":"Tera\/State\/TeraAddStore.swift","character":3},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 53 lines"},"text":" func submit() async {"},{"violation":{"severity":"error","location":{"line":438,"file":"Tera\/State\/TeraAddStore.swift","character":3},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 51 lines"},"text":" func retract(_ card: TeraTodayCard) async {"},{"violation":{"severity":"error","location":{"line":524,"file":"Tera\/State\/TeraAddStore.swift","character":11},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 60 lines"},"text":" private func uploadPendingMedia(_ initial: TeraDraftStatus) async throws"},{"violation":{"severity":"error","location":{"line":810,"file":"Tera\/State\/TeraAddStore.swift","character":1},"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 810"},"text":"}"},{"violation":{"severity":"error","location":{"line":78,"file":"Tera\/State\/TeraConfigurationStore.swift","character":1},"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 468 lines"},"text":"actor TeraConfigurationStore {"},{"violation":{"severity":"error","location":{"line":148,"file":"Tera\/State\/TeraConfigurationStore.swift","character":5},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","reason":"Function body should span 100 lines or less excluding comments and whitespace: currently spans 109 lines"},"text":" func load() throws -> TeraAppConfiguration {"},{"violation":{"severity":"error","location":{"line":759,"file":"Tera\/State\/TeraConfigurationStore.swift","character":1},"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 759"},"text":"}"},{"violation":{"severity":"error","location":{"line":40,"file":"Tera\/State\/TeraSessionStore.swift","character":1},"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 430 lines"},"text":"actor TeraSessionStore {"},{"violation":{"severity":"error","location":{"line":70,"file":"Tera\/State\/TeraSessionStore.swift","character":10},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 52 lines"},"text":" static func production("},{"violation":{"severity":"error","location":{"line":182,"file":"Tera\/State\/TeraSessionStore.swift","character":11},"ruleIdentifier":"cyclomatic_complexity","ruleDescription":"Complexity of function bodies should be limited.","ruleName":"Cyclomatic Complexity","reason":"Function should have complexity 10 or less; currently complexity is 14"},"text":" private func start(acceptingReconfiguration: Bool) async -> TeraSessionPhase {"},{"violation":{"severity":"error","location":{"line":182,"file":"Tera\/State\/TeraSessionStore.swift","character":11},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 64 lines"},"text":" private func start(acceptingReconfiguration: Bool) async -> TeraSessionPhase {"},{"violation":{"severity":"error","location":{"line":388,"file":"Tera\/State\/TeraSessionStore.swift","character":11},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 61 lines"},"text":" private func reconcileIdentity(_ identity: TeraAppIdentity) async throws {"},{"violation":{"severity":"error","location":{"line":492,"file":"Tera\/State\/TeraSessionStore.swift","character":1},"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 492"},"text":"}"},{"violation":{"severity":"error","location":{"line":4,"file":"Tera\/Views\/TeraAddView.swift","character":1},"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","reason":"Struct body should span 350 lines or less excluding comments and whitespace: currently spans 454 lines"},"text":"struct TeraAddView: View {"},{"violation":{"severity":"error","location":{"line":560,"file":"Tera\/Views\/TeraAddView.swift","character":1},"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 560"},"text":"}"},{"violation":{"severity":"error","location":{"line":365,"file":"Tera\/Views\/TeraSupportingViews.swift","character":1},"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","reason":"Struct body should span 250 lines or less excluding comments and whitespace: currently spans 259 lines"},"text":"struct TeraSettingsView: View {"},{"violation":{"severity":"error","location":{"line":629,"file":"Tera\/Views\/TeraSupportingViews.swift","character":1},"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 629"},"text":"}"},{"violation":{"severity":"error","location":{"line":493,"file":"Tera\/Views\/TeraTodayView.swift","character":1},"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 493"},"text":"}"},{"violation":{"severity":"error","location":{"line":6,"file":"TeraTests\/TeraAddStoreTests.swift","character":7},"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 665 lines"},"text":"final class TeraAddStoreTests: XCTestCase {"},{"violation":{"severity":"error","location":{"line":847,"file":"TeraTests\/TeraAddStoreTests.swift","character":9},"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 492 lines"},"text":"private actor AddBackend: TeraRuntimeBackend {"},{"violation":{"severity":"error","location":{"line":1283,"file":"TeraTests\/TeraAddStoreTests.swift","character":11},"ruleIdentifier":"function_parameter_count","ruleDescription":"Number of function parameters should be low.","ruleName":"Function Parameter Count","reason":"Function should have 5 parameters or less: it currently has 8"},"text":" private func makeStatus("},{"violation":{"severity":"error","location":{"line":1380,"file":"TeraTests\/TeraAddStoreTests.swift","character":22},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 89 lines"},"text":" nonisolated static func schemas() -> [TeraAddSchema] {"},{"violation":{"severity":"error","location":{"line":1528,"file":"TeraTests\/TeraAddStoreTests.swift","character":1},"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","reason":"File should contain 1000 lines or less: currently contains 1528"},"text":"}"},{"violation":{"severity":"error","location":{"line":7,"file":"TeraTests\/TeraRemoteQualificationTests.swift","character":7},"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","reason":"Class body should span 250 lines or less excluding comments and whitespace: currently spans 307 lines"},"text":"final class TeraRemoteQualificationTests: XCTestCase {"},{"violation":{"severity":"error","location":{"line":45,"file":"TeraTests\/TeraRemoteQualificationTests.swift","character":3},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 94 lines"},"text":" func testQualificationRejectsAmbiguousOrUnsafeHarnessValues() {"},{"violation":{"severity":"error","location":{"line":212,"file":"TeraTests\/TeraRemoteQualificationTests.swift","character":3},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 53 lines"},"text":" func testAuthorizationEvidenceIsRedactedEphemeralAndRelaunchCleaned() throws {"},{"violation":{"severity":"error","location":{"line":4,"file":"TeraTests\/TeraRuntimeClientTests.swift","character":7},"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","reason":"Class body should span 250 lines or less excluding comments and whitespace: currently spans 332 lines"},"text":"final class TeraRuntimeClientTests: XCTestCase {"},{"violation":{"severity":"error","location":{"line":6,"file":"TeraTests\/TeraStateMigrationTests.swift","character":7},"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 580 lines"},"text":"final class TeraStateMigrationTests: XCTestCase {"},{"violation":{"severity":"error","location":{"line":7,"file":"TeraTests\/TeraStateMigrationTests.swift","character":5},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 61 lines"},"text":" func testRelayValidationMatchesRuntimeProfiles() throws {"},{"violation":{"severity":"error","location":{"line":622,"file":"TeraTests\/TeraStateMigrationTests.swift","character":1},"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 622"},"text":"}"},{"violation":{"severity":"error","location":{"line":207,"file":"TeraTests\/TeraSupportingStoreTests.swift","character":9},"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","reason":"Actor body should span 250 lines or less excluding comments and whitespace: currently spans 253 lines"},"text":"private actor SupportingBackend: TeraRuntimeBackend {"},{"violation":{"severity":"error","location":{"line":487,"file":"TeraTests\/TeraSupportingStoreTests.swift","character":1},"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 487"},"text":"}"},{"violation":{"severity":"error","location":{"line":4,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":7},"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 1493 lines"},"text":"final class TeraRemoteQualificationUITests: XCTestCase {"},{"violation":{"severity":"error","location":{"line":62,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":3},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 73 lines"},"text":" func testLocalSocialFiveFlowScenario() throws {"},{"violation":{"severity":"error","location":{"line":174,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":3},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 66 lines"},"text":" func testLocalSocialDeterministicPersonas() throws {"},{"violation":{"severity":"error","location":{"line":247,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":3},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 54 lines"},"text":" func testRemoteBlossomUploadAndRecovery() throws {"},{"violation":{"severity":"error","location":{"line":573,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":11},"ruleIdentifier":"cyclomatic_complexity","ruleDescription":"Complexity of function bodies should be limited.","ruleName":"Cyclomatic Complexity","reason":"Function should have complexity 10 or less; currently complexity is 18"},"text":" private func expectedAddFieldLabel(_ identifier: String, type: String) -> String {"},{"violation":{"severity":"error","location":{"line":767,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":11},"ruleIdentifier":"cyclomatic_complexity","ruleDescription":"Complexity of function bodies should be limited.","ruleName":"Cyclomatic Complexity","reason":"Function should have complexity 10 or less; currently complexity is 11"},"text":" private func preparePhotoUpdate(_ app: XCUIApplication, marker: String) -> XCUIElement? {"},{"violation":{"severity":"error","location":{"line":767,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":11},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 66 lines"},"text":" private func preparePhotoUpdate(_ app: XCUIApplication, marker: String) -> XCUIElement? {"},{"violation":{"severity":"error","location":{"line":1736,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":10},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 67 lines"},"text":" static func environment("},{"violation":{"severity":"error","location":{"line":2212,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":1},"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","reason":"File should contain 1000 lines or less: currently contains 2212"},"text":"}"}] +\ No newline at end of file