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:
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